diff --git a/src/crux/evaluation/datasets/querygen/README.md b/src/crux/evaluation/datasets/querygen/README.md new file mode 100644 index 0000000..4e80177 --- /dev/null +++ b/src/crux/evaluation/datasets/querygen/README.md @@ -0,0 +1,91 @@ +# Paper QueryGen + +![Web前端展示](assets/images/webapp.png) + +一个基于 Flask 的本地网页小工具,用来辅助为学术检索系统(尤其是 Agentic RAG)生成查询评测数据。 + +平时看 arXiv 论文时,你可能需要积累一些 Benchmark 数据来测 RAG 系统。这玩意儿可以随机抽取你本地准备好的 arXiv Parquet 里的论文,让你通过点按钮的方式,**自动请求大模型为这篇论文生成几组用户视角的自然语言 Query**,然后直接存入 `research_queries.csv`。 + +核心就是解决“手动想 Query -> 跑大模型 -> 复制粘贴进 CSV”这个繁琐的流程,并且加了异步生成队列,可以直接“点击生成 -> 下一篇”,不用硬干等着。 + +## 必备要求 + +### 1. Parquet 数据格式 +脚本默认读取根目录或指定路径下的 `arxiv-metadata-oai-snapshot.parquet` 文件。 +你的 `.parquet` 文件中必须包含(或能够容错读取)以下列(因为代码前端渲染和组装 Prompt 需要): +- `id`: 文章的 arxiv id (例如 "2401.00001") +- `title`: 文章标题 +- `abstract`: 摘要 +- `authors`: 作者信息 +- `update_date`: 更新日期(或发表/创建日期) +- `categories`: 学术分类 (例如 "cs.CL") +- *(可选)* `journal-ref`, `doi`, `versions` 等信息也会显示在网页上。 + +### 2. config.json 配置文件 +在与脚本同级的目录下,必须新建一个 `config.json` 来放 LLM 的调用配置: +```json +{ + "LLM_API_KEY": "sk-xxxxxx", + "LLM_API_BASE": "https://api.openai.com/v1", + "LLM_MODEL": "gpt-4o" +} +``` + +## 如何使用 + +**前置要求:** 请确保你的本地环境安装了 **Python 3.8 或更高版本**(推荐 Python 3.9+)。 + +1. 创建并激活虚拟环境(推荐): + + **macOS / Linux:** + ```bash + python -m venv .venv + source .venv/bin/activate + ``` + + **Windows:** + ```bash + python -m venv .venv + .venv\Scripts\activate + ``` + +2. 装好依赖: + ```bash + pip install flask pandas requests + ``` +3. 把你的 `arxiv-metadata-oai-snapshot.parquet` 和 `config.json` 准备好。 +3. 运行: + ```bash + python arxiv_querygen.py + ``` +4. 浏览器打开 `http://127.0.0.1:5050` + +> **⏳ 首次打开加载提示** +> 当你第一次在浏览器中打开页面时,系统需要将庞大的 `.parquet` 数据集文件整个读入内存。 +> 这个过程通常需要等待几秒到几十秒不等(取决于你的 Parquet 文件大小和硬盘读写速度),期间页面会显示加载动画,请耐心等待第一篇论文内容刷新出来。 +> +> ![首次加载内存等待示意图](assets/images/initial_loading.png) + +程序包含**手动模式**和**自动模式**两种操作方式,满足不同的使用场景: + +### 手动模式(精确筛选) +适合需要人工审阅、精细控制生成质量的场景。 +- 点击 **“🎲 随机挑选一个”** 刷新下一篇文章。 +- 如果你不确定这篇论文是否适合用来生成 Query,可以点击 **“🔍 自动评估适用性”** 让大模型帮你快速判断。 + + ![AI 自动评估适用性效果展示](assets/images/ai_eval.png) + +- 认为合适后,点击 **“💻 自动生成并存入CSV”** 即可触发 LLM 请求,它会在后台排队处理。 + +### 自动模式(批量处理) +适合希望无人值守、快速批量积累 Benchmark 数据的场景。 +- 开启自动模式后,系统会自动在后台随机抽取论文。 +- 自动进行论文适用性评估,如果大模型判断适合,则自动发起 Query 生成并存入 CSV;如果不适合则自动跳过并处理下一篇。 +- **配置参数说明:** + - **生成目标数量**:你需要成功生成的 Query 组数(例如 50,系统会在成功生成 50 组后自动结束任务)。 + - **最大尝试倍率**:防止因大量论文不合格而导致的死循环抽样。系统最大抽样尝试次数为 `目标数量 × 该倍率`(例:目标50,倍率2,则最多抽取 100 篇论文,若仍未达到设定目标也会报错停止)。 + - **API错误最大重试**:遇到网络抖动或大模型 API 报错时,针对当前单次请求允许的最大重试次数。 + +![自动模式运行日志或界面展示](assets/images/auto_mode.png) + +无论是手动还是自动模式,页面下方都有队列提示悬浮窗,如果成功、报错或者网络出现状况,都会有小气泡提醒。 diff --git a/src/crux/evaluation/datasets/querygen/arxiv_querygen.py b/src/crux/evaluation/datasets/querygen/arxiv_querygen.py new file mode 100644 index 0000000..2b7924a --- /dev/null +++ b/src/crux/evaluation/datasets/querygen/arxiv_querygen.py @@ -0,0 +1,78 @@ +import os +import json +from flask import Flask, jsonify, render_template, request + +from config import get_llm_config +from data_manager import get_random_record +from llm_service import is_paper_suitable_for_query +from task_worker import start_worker, add_task, get_status + +class ArxivJSONEncoder(json.JSONEncoder): + def default(self, obj): + import numpy as np + import pandas as pd + if isinstance(obj, np.integer): return int(obj) + if isinstance(obj, np.floating): return float(obj) + if isinstance(obj, np.ndarray): return obj.tolist() + if pd.isna(obj): return None + return super().default(obj) + +app = Flask(__name__) + +try: + from flask.json.provider import DefaultJSONProvider + class CustomJSONProvider(DefaultJSONProvider): + def default(self, obj): + import numpy as np + import pandas as pd + if isinstance(obj, np.integer): return int(obj) + if isinstance(obj, np.floating): return float(obj) + if isinstance(obj, np.ndarray): return obj.tolist() + if pd.isna(obj): return None + return super().default(obj) + app.json = CustomJSONProvider(app) +except ImportError: + app.json_encoder = ArxivJSONEncoder + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/api/evaluate_paper', methods=['POST']) +def evaluate_paper(): + try: + data = request.get_json() + print(f"\n[LLM 手动评估] 正在验证论文的适用性: 《{data.get('title')}》") + is_suitable, reason = is_paper_suitable_for_query(data) + print(f"[LLM 手动评估] 结果: {'✅ 适合' if is_suitable else '❌ 不适合'} | 原因: {reason}\n") + return jsonify({"suitable": is_suitable, "reason": reason}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route('/api/random') +def get_random(): + try: + return jsonify(get_random_record()) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route('/api/generate_and_save', methods=['POST']) +def generate_and_save(): + try: + data = request.get_json() + prompt = data.get('prompt') + title = data.get('title', '未知论文') + if not prompt: return jsonify({"error": "Prompt is empty"}), 400 + q_size = add_task(prompt, title) + short_title = title if len(title) <= 20 else title[:20] + "..." + return jsonify({"message": f"《{short_title}》已入队 (排队: {q_size})"}), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route('/api/poll_results') +def poll_results(): + return jsonify(get_status()) + +if __name__ == '__main__': + start_worker() + app.run(debug=False, use_reloader=False, port=5050) diff --git a/src/crux/evaluation/datasets/querygen/assets/images/ai_eval.png b/src/crux/evaluation/datasets/querygen/assets/images/ai_eval.png new file mode 100644 index 0000000..3620c31 Binary files /dev/null and b/src/crux/evaluation/datasets/querygen/assets/images/ai_eval.png differ diff --git a/src/crux/evaluation/datasets/querygen/assets/images/auto_mode.png b/src/crux/evaluation/datasets/querygen/assets/images/auto_mode.png new file mode 100644 index 0000000..dbee651 Binary files /dev/null and b/src/crux/evaluation/datasets/querygen/assets/images/auto_mode.png differ diff --git a/src/crux/evaluation/datasets/querygen/assets/images/initial_loading.png b/src/crux/evaluation/datasets/querygen/assets/images/initial_loading.png new file mode 100644 index 0000000..0f5d6db Binary files /dev/null and b/src/crux/evaluation/datasets/querygen/assets/images/initial_loading.png differ diff --git a/src/crux/evaluation/datasets/querygen/assets/images/webapp.png b/src/crux/evaluation/datasets/querygen/assets/images/webapp.png new file mode 100644 index 0000000..b93d1eb Binary files /dev/null and b/src/crux/evaluation/datasets/querygen/assets/images/webapp.png differ diff --git a/src/crux/evaluation/datasets/querygen/config.py b/src/crux/evaluation/datasets/querygen/config.py new file mode 100644 index 0000000..563a39b --- /dev/null +++ b/src/crux/evaluation/datasets/querygen/config.py @@ -0,0 +1,24 @@ +import os +import json + +def get_llm_config(): + api_key = os.environ.get("LLM_API_KEY", "your-api-key") + api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1") + model_name = os.environ.get("LLM_MODEL", "gpt-3.5-turbo") + + config_path = os.path.join(os.path.dirname(__file__), "config.json") + if not os.path.exists(config_path): + config_path = "config.json" + + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + config_data = json.load(f) + api_key = config_data.get("LLM_API_KEY", api_key) + api_base = config_data.get("LLM_API_BASE", api_base) + model_name = config_data.get("LLM_MODEL", model_name) + except Exception as e: + print(f"配置文件读取失败: {e}") + + api_base = api_base.rstrip('/') + return api_key, api_base, model_name diff --git a/src/crux/evaluation/datasets/querygen/data_manager.py b/src/crux/evaluation/datasets/querygen/data_manager.py new file mode 100644 index 0000000..41c8fee --- /dev/null +++ b/src/crux/evaluation/datasets/querygen/data_manager.py @@ -0,0 +1,44 @@ +import os +import random +import threading +import pandas as pd +import numpy as np + +DF = None +data_load_lock = threading.Lock() + +def load_data(): + global DF + file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "arxiv-metadata-oai-snapshot.parquet") + if not os.path.exists(file_path): + file_path = "arxiv-metadata-oai-snapshot.parquet" + + if DF is None: + with data_load_lock: + if DF is None: + print(f"正在从 Parquet 文件加载数据 ({file_path})...") + DF = pd.read_parquet(file_path) + print(f"成功加载 {len(DF)} 条数据") + return DF + +def get_random_record(): + df = load_data() + total_len = len(df) + idx = random.randint(0, total_len - 1) + row = df.iloc[idx].to_dict() + + def clean_obj(obj): + if isinstance(obj, np.ndarray): + return [clean_obj(item) for item in obj.tolist()] + if isinstance(obj, list): + return [clean_obj(item) for item in obj] + if isinstance(obj, dict): + return {k: clean_obj(v) for k, v in obj.items()} + if isinstance(obj, np.generic): + return obj.item() + if pd.isna(obj): + return None + return obj + + cleaned_row = clean_obj(row) + return cleaned_row diff --git a/src/crux/evaluation/datasets/querygen/llm_service.py b/src/crux/evaluation/datasets/querygen/llm_service.py new file mode 100644 index 0000000..5226ba8 --- /dev/null +++ b/src/crux/evaluation/datasets/querygen/llm_service.py @@ -0,0 +1,180 @@ +import re +import csv +import json +import os +import datetime +import requests +from config import get_llm_config + +def is_paper_suitable_for_query(row): + try: + abstract = str(row.get('abstract', '')).strip() + if len(abstract) < 50: + return False, "摘要过短,机械规则前置过滤" + + api_key, api_base, model_name = get_llm_config() + + current_year = datetime.datetime.now().year + prompt = ( + f"当前系统年份是 {current_year} 年。请评估以下 arXiv 论文是否适合作为生成“论文检索查询问题”的灵感源。\n" + "判断不适合的标准如下,请你结合论文的领域类别和具体研究内容进行灵活、智能的判断:\n" + "1. 论文发表时间与领域发展速度的匹配度。请你自行分析该细分领域的迭代速度:\n" + " - 对于新兴、快速迭代或高速发展的细分方向(如大模型、前沿生物技术、量子计算等任何快速发展学科),审查要从严,要求尽量在 2020 年及以后发表。\n" + " - 对于发展相对缓慢、注重基础理论或传统的方法的分支(无论是数学、物理还是 CS 中的传统基础理论分支),时间要求可适当放宽,但最多放宽至 2009 年及以后。\n" + " - 绝对不接受 2009 年以前的古早论文。\n" + "2. 即使论文日期较新,如果明确探讨的是已被业界公认淘汰、过时或不再具有研究价值的技术论题,也应视为不适合。\n" + "3. 摘要内容过短、过于空洞或缺乏具体的研究实体/概念,难以激起生成自然查询问题的灵感。\n\n" + "请根据上述条件对以下论文进行分析:\n" + f"标题:{row.get('title')}\n" + f"日期:{row.get('update_date')}\n" + f"领域类别:{row.get('categories')}\n" + f"摘要:{abstract}\n\n" + "请严格输出 JSON,不要添加多余 Markdown 格式,格式如下:\n" + "{\n" + ' "suitable": true 或 false,\n' + ' "reason": "你的判断理由,请简要说明"\n' + "}" + ) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": model_name, + "messages": [ + {"role": "system", "content": "你是一个专业的学术评价助手。"}, + {"role": "user", "content": prompt} + ], + "temperature": 0.3 + } + + response = requests.post(f"{api_base}/chat/completions", headers=headers, json=payload, timeout=20) + response.raise_for_status() + + reply_json = response.json() + ai_content = reply_json.get('choices', [{}])[0].get('message', {}).get('content', '') + + json_str = re.sub(r'^```json\s*|```\s*$', '', ai_content.strip(), flags=re.MULTILINE) + parsed_data = json.loads(json_str) + + is_suitable = bool(parsed_data.get('suitable', False)) + reason = parsed_data.get('reason', 'JSON中无理由') + + return is_suitable, reason + + except Exception as e: + return False, f"LLM评估发生错误: {e}" + +def generate_and_select_query_for_paper(prompt, title): + try: + api_key, api_base, model_name = get_llm_config() + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + # 第一阶段:生成问题 + payload_stage1 = { + "model": model_name, + "messages": [ + {"role": "system", "content": "你是一个专业的学术和研究助理。"}, + {"role": "user", "content": prompt} + ], + "temperature": 0.7 + } + + response1 = requests.post(f"{api_base}/chat/completions", headers=headers, json=payload_stage1, timeout=60) + response1.raise_for_status() + + reply_json1 = response1.json() + choices1 = reply_json1.get('choices') + if not choices1 or not isinstance(choices1, list) or len(choices1) == 0: + return "API 接口第1阶段返回格式异常或包含错误", True + + ai_content1 = choices1[0].get('message', {}).get('content') + if not ai_content1: + return "大模型第1阶段返回了空内容", True + + print("\n" + "="*40 + " API 第1阶段生成内容 " + "="*40) + print(f"模型 ({model_name}) 返回候选问题:\n{ai_content1}") + + prompt_stage2 = ( + "以上是你根据论文元数据生成的这三个候选检索问题。\n" + "现在请对这三个问题进行分析,评估它们各自是否满足作为优秀检索提问的要求(例如通用性、搜索意图代表性等,时间限制不作为评价依据)。\n" + "最后经过分析,选出一个符合要求且最终能被用来评估系统效果的问题。若都比较好,可随机选取一个。\n\n" + "请严格按照以下 JSON 数据结构输出你的回答结果,必须是一段可解析的 JSON,不需要包含任何 Markdown block 或是其他额外解释:\n" + "{\n" + ' "candidate_questions": ["问题1", "问题2", "问题3"],\n' + ' "analysis": "用一段连贯的话对这三个问题的优劣势和特点进行分析",\n' + ' "selected_question": "最终选出的那一个问题"\n' + "}" + ) + + payload_stage2 = { + "model": model_name, + "messages": [ + {"role": "system", "content": "你是一个专业的学术和研究助理。严格按照用户要求输出JSON格式。"}, + {"role": "user", "content": prompt}, + {"role": "assistant", "content": ai_content1}, + {"role": "user", "content": prompt_stage2} + ], + "temperature": 0.7 + } + + response2 = requests.post(f"{api_base}/chat/completions", headers=headers, json=payload_stage2, timeout=60) + response2.raise_for_status() + + reply_json2 = response2.json() + choices2 = reply_json2.get('choices') + if not choices2 or not isinstance(choices2, list) or len(choices2) == 0: + return "API 接口第2阶段返回格式异常或包含错误", True + + ai_content2 = choices2[0].get('message', {}).get('content') + if not ai_content2: + return "大模型第2阶段返回了空内容", True + + print("\n" + "="*40 + " API 第2阶段生成内容 " + "="*40) + print(f"模型 ({model_name}) 返回最终分析结果:\n{ai_content2}") + print("="*94 + "\n") + + try: + json_str = re.sub(r'^```json\s*|```\s*$', '', ai_content2.strip(), flags=re.MULTILINE) + parsed_data = json.loads(json_str) + selected_q = parsed_data.get('selected_question') + except json.JSONDecodeError: + return f"AI 返回的内容不是有效的 JSON 格式\n原文: {ai_content2}", True + + if not selected_q: + return f"JSON 中未找到 'selected_question' 字段\n原文: {ai_content2}", True + + csv_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "research_queries.csv") + + if not os.path.exists(os.path.dirname(csv_file)): + csv_file = "research_queries.csv" + + file_exists = os.path.isfile(csv_file) + + with open(csv_file, mode='a', newline='', encoding='utf-8-sig') as f: + fieldnames = ['timestamp', 'query'] + writer = csv.DictWriter(f, fieldnames=fieldnames) + if not file_exists: + writer.writeheader() + + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + writer.writerow({ + 'timestamp': timestamp, + 'query': selected_q + }) + + short_title = title if len(title) <= 20 else title[:20] + "..." + return f"《{short_title}》生成并保存成功:\n{selected_q}", False + + except requests.exceptions.RequestException as e: + raw_text = e.response.text if getattr(e, 'response', None) is not None else "" + return f"《{title}》请求失败: {str(e)} | 原文: {raw_text}", True + except Exception as e: + return f"《{title}》发生系统异常: {str(e)}", True + diff --git a/src/crux/evaluation/datasets/querygen/static/css/style.css b/src/crux/evaluation/datasets/querygen/static/css/style.css new file mode 100644 index 0000000..89feb72 --- /dev/null +++ b/src/crux/evaluation/datasets/querygen/static/css/style.css @@ -0,0 +1,39 @@ +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background-color: #f4f4f9; margin: 0; padding: 20px; color: #333; } + .container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); } + h1 { color: #2c3e50; text-align: center; } + .card { border: 1px solid #eee; padding: 20px; border-radius: 8px; margin-top: 20px; line-height: 1.6; } + .label { font-weight: bold; color: #7f8c8d; margin-top: 15px; display: block; } + .content { margin-bottom: 10px; } + .abstract { background: #fdf6e3; padding: 15px; border-left: 4px solid #b58900; margin-top: 10px; } + .btn-container { text-align: center; margin-top: 30px; } + button { background-color: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 6px; cursor: pointer; font-size: 16px; transition: background 0.3s; } + button:hover { background-color: #2980b9; } + button:disabled { cursor: not-allowed; } + #loading { display: none; color: #3498db; font-weight: bold; margin-bottom: 15px; text-align: center;} + @keyframes spin { to { transform: rotate(360deg); } } + /* 初始页面加载时的全屏遮罩特效 */ + #initial-loader { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.95); z-index: 9999; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; } + .global-spinner { width: 50px; height: 50px; border: 5px solid #e0e0e0; border-top-color: #3498db; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 20px; } + /* Toast 通知样式 */ + #toast { visibility: hidden; min-width: 300px; background-color: #333; color: #fff; text-align: center; border-radius: 8px; padding: 16px; position: fixed; z-index: 1000; left: 50%; bottom: 30px; transform: translateX(-50%); font-size: 15px; opacity: 0; transition: opacity 0.5s, bottom 0.5s; box-shadow: 0 4px 12px rgba(0,0,0,0.15); line-height: 1.5; white-space: pre-wrap; } + #toast.show { visibility: visible; opacity: 1; bottom: 50px; } + #toast.error { background-color: #e74c3c; } + #toast.success { background-color: #2ecc71; } + + /* 队列状态悬浮窗 */ + #queue-status { position: fixed; top: 20px; right: 20px; background: white; padding: 10px 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border-left: 4px solid #3498db; z-index: 1000; transition: all 0.3s; max-width: 300px; } + #queue-status.active { border-left-color: #e67e22; background: #fffdf5;} + #queue-header { font-weight: bold; } + #queue-list { display: none; list-style: none; padding: 0; margin: 10px 0 0 0; font-size: 12px; max-height: 300px; overflow-y: auto; border-top: 1px dashed #ccc; padding-top: 10px;} + #queue-list li { margin-bottom: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #555;} + + /* 滑动开关样式 */ + .mode-toggle { display: flex; justify-content: center; margin-bottom: 25px; } + .toggle-bg { position: relative; width: 240px; height: 40px; background: #e0e0e0; border-radius: 20px; display: flex; cursor: pointer; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); } + .toggle-slider { position: absolute; top: 2px; left: 2px; width: 116px; height: 36px; background: #fff; border-radius: 18px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); } + .toggle-option { flex: 1; z-index: 1; display: flex; justify-content: center; align-items: center; font-weight: bold; color: #7f8c8d; transition: color 0.3s; user-select: none; font-size: 14px; } + .toggle-bg.auto .toggle-slider { transform: translateX(120px); } + .toggle-bg.auto .opt-manual { color: #7f8c8d; } + .toggle-bg.auto .opt-auto { color: #2c3e50; } + .toggle-bg:not(.auto) .opt-manual { color: #2c3e50; } + .toggle-bg:not(.auto) .opt-auto { color: #7f8c8d; } \ No newline at end of file diff --git a/src/crux/evaluation/datasets/querygen/static/js/main.js b/src/crux/evaluation/datasets/querygen/static/js/main.js new file mode 100644 index 0000000..cf8ad72 --- /dev/null +++ b/src/crux/evaluation/datasets/querygen/static/js/main.js @@ -0,0 +1,431 @@ +let isAutoRunning = false; // 标记自动模式是否正在执行 + + // 模式切换 + function toggleMode() { + const bg = document.getElementById('modeToggleBg'); + const manualSec = document.getElementById('manual-mode-section'); + const autoSec = document.getElementById('auto-mode-section'); + + if (bg.classList.contains('auto')) { + // 如果在自动模式且正在运行,阻止切换并弹窗提示 + if (isAutoRunning) { + alert(`⚠️ 警告:当前正有自动生成任务在进行中!\n请先点击【⏹ 停止并结算】终止任务,然后再切换回手动模式。`); + return; + } + + // 回到手动模式 + bg.classList.remove('auto'); + manualSec.style.display = 'block'; + autoSec.style.display = 'none'; + } else { + // 切换到自动模式 + bg.classList.add('auto'); + manualSec.style.display = 'none'; + autoSec.style.display = 'block'; + } + } + + let toastTimeout = null; + function showToast(message, isError = false) { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.className = isError ? 'show error' : 'show success'; + + if (toastTimeout) clearTimeout(toastTimeout); + toastTimeout = setTimeout(() => { + toast.className = toast.className.replace(/show\s?(error|success)?/, ''); + }, 3500); + } + + function copyPlainText() { + const textArea = document.getElementById('plain_text_area'); + textArea.select(); + document.execCommand('copy'); + showToast('✅ 提示词已复制到剪贴板!'); + } + + // 定时轮询后端查看是否有后台任务完成 + let currentQueueSize = 0; + let isProcessing = false; + + function pollResults() { + fetch('/api/poll_results') + .then(res => res.json()) + .then(resData => { + currentQueueSize = resData.queue_size; + isProcessing = !!resData.current_processing; + + // 更新右上角悬浮窗的队列数量和列表 + const queueElem = document.getElementById('queue-status'); + const listElem = document.getElementById('queue-list'); + + const processingCount = resData.current_processing ? 1 : 0; + const totalTasks = resData.queue_size + processingCount; + + if (totalTasks > 0) { + document.getElementById('queue-header').textContent = '📊 任务总数: ' + totalTasks + (resData.queue_size > 0 ? ' (排队中: ' + resData.queue_size + ')' : ''); + queueElem.classList.add('active'); + listElem.style.display = 'block'; + } else { + document.getElementById('queue-header').textContent = '📊 队列等待数: 0'; + queueElem.classList.remove('active'); + listElem.style.display = 'none'; + } + + listElem.innerHTML = ''; + + // 显示正在处理的任务 + if (resData.current_processing) { + const li = document.createElement('li'); + li.style.color = '#27ae60'; + li.style.fontWeight = 'bold'; + li.style.fontSize = '13px'; + li.textContent = '⚡ 处理中: ' + resData.current_processing; + li.title = resData.current_processing; + listElem.appendChild(li); + } + + // 显示还没开始排队等待的任务 + resData.queued_titles.forEach(t => { + const li = document.createElement('li'); + li.textContent = '⏳ 排队中: ' + t; + li.title = t; // hover 能看到全名 + listElem.appendChild(li); + }); + + // 弹出气泡通知 + resData.results.forEach(result => { + const icon = result.isError ? '❌ ' : '✅ '; + showToast(icon + result.message, result.isError); + }); + }) + .catch(err => console.error("Poll Error:", err)); + } + + // 每 2 秒拉取一次后台结果 + setInterval(pollResults, 2000); + + async function evaluatePaper() { + const btn = document.getElementById('evalBtn'); + const evalContainer = document.getElementById('eval_result_container'); + const evalResult = document.getElementById('eval_result'); + + btn.textContent = '⏳ 正在评估...'; + btn.disabled = true; + + const paperData = { + title: document.getElementById('title').textContent, + update_date: document.getElementById('update_date').textContent, + categories: document.getElementById('categories').textContent, + abstract: document.getElementById('abstract').textContent + }; + + try { + const response = await fetch('/api/evaluate_paper', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(paperData) + }); + const resData = await response.json(); + + evalContainer.style.display = 'block'; + if (resData.suitable) { + evalContainer.style.borderLeftColor = '#27ae60'; + evalContainer.style.backgroundColor = '#eafaf1'; + evalResult.innerHTML = `✅ 适合作为灵感源
原因: ${resData.reason}`; + } else { + evalContainer.style.borderLeftColor = '#e74c3c'; + evalContainer.style.backgroundColor = '#fdedec'; + evalResult.innerHTML = `❌ 不推荐使用
原因: ${resData.reason}`; + } + } catch (err) { + showToast('❌ 评估异常:' + err, true); + } finally { + btn.textContent = '🔍 自动评估适用性'; + btn.disabled = false; + } + } + + let autoStopFlag = false; + + function stopAutoMode() { + autoStopFlag = true; + const consoleEl = document.getElementById('autoConsole'); + consoleEl.innerHTML += `
[${new Date().toLocaleTimeString('en-US', {hour12: false})}] 🛑 正在停止自动任务,等待当前操作结束后终止...
`; + consoleEl.scrollTop = consoleEl.scrollHeight; + } + + async function startAutoMode() { + // 参数读取与保护 + const targetCount = parseInt(document.getElementById('autoTargetCount').value) || 50; + const maxMultiplier = parseFloat(document.getElementById('autoMaxMultiplier').value) || 2; + const maxRetries = parseInt(document.getElementById('autoMaxRetries').value) || 3; + const maxAttempts = Math.ceil(targetCount * maxMultiplier); + + if (targetCount <= 0 || maxMultiplier < 1) { + alert('请填写合理的生成目标的数量与倍率阈值'); + return; + } + + if (currentQueueSize > 0 || isProcessing) { + alert('请等待当前后台队列中的残留任务彻底结束,再启动全新自动流水线'); + return; + } + + isAutoRunning = true; + autoStopFlag = false; + + // 切换按钮状态 + document.getElementById('autoStartBtn').style.display = 'none'; + document.getElementById('autoStopBtn').style.display = 'inline-block'; + + // 初始化计量器 + let generatedCount = 0; + let totalAttempts = 0; + let discardCount = 0; + + document.getElementById('autoTargetText').textContent = targetCount; + document.getElementById('autoProgressText').textContent = generatedCount; + document.getElementById('autoAttemptsText').textContent = totalAttempts; + document.getElementById('autoDiscardText').textContent = discardCount; + document.getElementById('autoFilterRate').textContent = '0%'; + document.getElementById('autoProgressBar').style.width = '0%'; + + const consoleEl = document.getElementById('autoConsole'); + consoleEl.innerHTML = `> 🚀 自动生成流水线启动!目标: ${targetCount} 组 (最长允许摸排 ${maxAttempts} 篇)
`; + + const log = (msg) => { + const time = new Date().toLocaleTimeString('en-US', {hour12: false}); + consoleEl.innerHTML += `[${time}] ${msg}
`; + consoleEl.scrollTop = consoleEl.scrollHeight; + }; + + let retryCount = 0; + + // 核心循环 + while (!autoStopFlag && generatedCount < targetCount && totalAttempts < maxAttempts) { + try { + log(`🎲 正在抽取随机论文...`); + const fetchRes = await fetch('/api/random'); + if (!fetchRes.ok) throw new Error(`抽样请求失败 (HTTP ${fetchRes.status})`); + const paperData = await fetchRes.json(); + + if (autoStopFlag) break; + + totalAttempts++; + const shortTitle = paperData.title.length > 30 ? paperData.title.substring(0, 30) + '...' : paperData.title; + log(`🤖 正在评估适用性: 《${shortTitle}》`); + + const evalRes = await fetch('/api/evaluate_paper', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(paperData) + }); + if (!evalRes.ok) throw new Error(`评估请求失败 (HTTP ${evalRes.status})`); + const evalData = await evalRes.json(); + + if (autoStopFlag) break; + + // 重置报错计数 + retryCount = 0; + + if (evalData.suitable) { + log(`✅ 适用性评估通过!投递到大语言模型进行 Query 生成...`); + const plainText = `${PROMPT_PREFIX}标题:${paperData.title}\n日期:${paperData.update_date}\n作者:${paperData.authors}\n摘要:${paperData.abstract}`; + + const genRes = await fetch('/api/generate_and_save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: plainText, title: paperData.title }) + }); + if (!genRes.ok) throw new Error(`提交生成任务失败 (HTTP ${genRes.status})`); + + // 串行执行逻辑:每投递一篇,必须耐心等待其从后台队列完全消化掉才继续 + log(`⏳ 任务已入队,[串行安全模式] 正等待大模型生成并写入CSV...`); + let hasStartedProcessing = false; + while (!autoStopFlag) { + await new Promise(r => setTimeout(r, 1500)); // 给心跳时间 + if (currentQueueSize > 0 || isProcessing) { + hasStartedProcessing = true; + } else if (hasStartedProcessing && currentQueueSize === 0 && !isProcessing) { + // 已经开始过且目前队列和执行都清空,说明执行完毕 + break; + } else if (currentQueueSize === 0 && !isProcessing) { + // 可能刚好 pollInterval 没拉到最新状态或者后台瞬间执行完毕(概率极小) + // 容错重试多等一秒判断 + await new Promise(r => setTimeout(r, 1000)); + if (currentQueueSize === 0 && !isProcessing) break; + } + } + + if (autoStopFlag) break; + + generatedCount++; + log(`✨ 当前论文生成并落库完毕!当前进度: ${generatedCount} / ${targetCount}`); + + } else { + discardCount++; + log(`❌ 论文不适用,已丢弃。(原因: ${evalData.reason})`); + } + + // 刷新状态盘数据 + document.getElementById('autoProgressText').textContent = generatedCount; + document.getElementById('autoAttemptsText').textContent = totalAttempts; + document.getElementById('autoDiscardText').textContent = discardCount; + document.getElementById('autoFilterRate').textContent = Math.round((discardCount / totalAttempts) * 100) + '%'; + document.getElementById('autoProgressBar').style.width = Math.round((generatedCount / targetCount) * 100) + '%'; + + } catch (err) { + retryCount++; + log(`⚠️ 发生意外错误: ${err.message} (第 ${retryCount}/${maxRetries} 次重试)`); + if (retryCount > maxRetries) { + log(`🚨 连续网络崩溃或大对象拦截超限,流水线强行熔断!`); + break; + } + await new Promise(r => setTimeout(r, 2000)); + } + } // end while + + // 结算语 + if (generatedCount >= targetCount) { + log(`🎉 达成目标容量!成功生产并通过筛选 ${targetCount} 组问答指令。`); + } else if (totalAttempts >= maxAttempts) { + log(`🛑 达到尝试倍率极值上限 (${maxAttempts}抽样),可能当前科研方向可选取样本过少,已自动停机。`); + } else { + log(`🛑 流水线已人工停止或熔断退出,最终生产 ${generatedCount} 条结果。`); + } + + // 状态还原 + document.getElementById('autoStartBtn').style.display = 'inline-block'; + document.getElementById('autoStopBtn').style.display = 'none'; + isAutoRunning = false; + } + + async function autoGenerateAndSave() { + const promptStr = document.getElementById('plain_text_area').value; + const paperTitle = document.getElementById('title').textContent || '未知论文'; + + if (!promptStr) { + showToast('❌ 提示词为空,无法生成', true); + return; + } + + const btn = document.getElementById('autoGenTopBtn'); + const originalText = '💻 自动生成并存入CSV'; + btn.textContent = '⏳ 请求已加入队列...'; + btn.disabled = true; + btn.style.backgroundColor = '#95a5a6'; + + try { + const response = await fetch('/api/generate_and_save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: promptStr, title: paperTitle }) + }); + + const resData = await response.json(); + + if (response.ok) { + showToast('✅ ' + resData.message); + } else { + let errMsg = '❌ 添加队列失败:' + (resData.error || '未知错误'); + showToast(errMsg, true); + } + } catch (err) { + showToast('❌ 请求异常:' + err, true); + } finally { + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + btn.style.backgroundColor = '#27ae60'; + }, 800); // .8秒后恢复按钮,方便立即继续处理下一个 + } + } + + const PROMPT_PREFIX = `请根据我提供的文章元数据(如标题、摘要、关键词、研究问题、方法、数据集等),**生成三个**适合作为**论文检索查询**的**中文**问题。 + +**核心要求:** +- 这些问题用于**找到相似主题、方法、数据集或领域的其他文章**,而不是对当前文章内容提出一个可被直接回答的事实性问题(例如“本文的准确率是多少?”或“作者提出了什么模型?”)。 +- 问题应像用户向 AI 学术助手或 Agentic RAG 系统提问时使用的自然语言查询。 +- 问题应具备一定通用性,能够召回一批同主题、同任务或同技术路线的论文;避免使用本文特有的专有名词(如自创方法名、特定实验代号等),除非该名词已广泛代表一个研究类别,要避免检索范围过窄(如限制条件太多太具体)。 +- **生成候选问题时,可以灵活考虑是否加入时间限制(如“近三年”、“2024年以来”等),尤其对于新兴且高速迭代的领域。时间限制不作为评选优劣的依据,即带时间限制的问题与不带时间限制的问题在最终选择时具有同等竞争资格,选择最优问题完全基于其通用性、代表性以及评测检索系统的适合度。** +- 示例(好的检索问题): + - “哪些论文使用了 HotpotQA 数据集?”(数据集通用) + - “有哪些脑电大模型相关的工作?”(领域通用) + - “泡泡玛特产品的研究策略有哪些?”(主题通用) + - “最新的信息检索(IR)领域论文有哪些?”(可接受) +- 反例(不合适的检索问题,因为过于具体或偏向答案): + - “如何利用部分配对的多模态数据识别仅由单一模态采样的神经元类型?”(过于具体) + - “本文得出的结论是什么?”(答案型问题) + - “哪些论文使用了本文提出的 Q-flip 协议?”(特有名词,无法召回其他论文) + +**输出格式:** 请直接输出这三个问题,你可以使用无序列表或逐行输出,但不要添加额外解释: +\n\n`; + + let isFirstLoad = true; + + async function fetchRandomRecord() { + const loading = document.getElementById('loading'); + const resultDiv = document.getElementById('result'); + const initialLoader = document.getElementById('initial-loader'); + + if (!isFirstLoad) { + loading.style.display = 'block'; + } + + try { + const response = await fetch('/api/random'); + const data = await response.json(); + + document.getElementById('title').textContent = data.title; + document.getElementById('id').textContent = data.id; + document.getElementById('categories').textContent = data.categories; + document.getElementById('update_date').textContent = data.update_date; + document.getElementById('authors').textContent = data.authors; + document.getElementById('abstract').textContent = data.abstract; + + // 设置纯文本形态的内容 (包含 Prompt 前缀) + const plainText = `${PROMPT_PREFIX}标题:${data.title}\n日期:${data.update_date}\n作者:${data.authors}\n摘要:${data.abstract}`; + document.getElementById('plain_text_area').value = plainText; + + // 处理发表信息 (Journal-ref 和 DOI) + const publishContainer = document.getElementById('publish_info_container'); + const publishInfo = document.getElementById('publish_info'); + let infoParts = []; + if (data['journal-ref']) infoParts.push(`📰 ${data['journal-ref']}`); + if (data['doi']) infoParts.push(`🔗 DOI: ${data['doi']}`); + + if (infoParts.length > 0) { + publishInfo.textContent = infoParts.join(" | "); + publishContainer.style.display = 'block'; + } else { + publishContainer.style.display = 'none'; + } + + // 处理版本记录显示 + let versionsText = ""; + if (data.versions && Array.isArray(data.versions)) { + versionsText = data.versions.map(v => `${v.version}: ${v.created}`).join(" | "); + } + document.getElementById('versions').textContent = versionsText; + + resultDiv.style.display = 'block'; + document.getElementById('eval_result_container').style.display = 'none'; + document.getElementById('eval_result').innerHTML = ''; + + if (isFirstLoad) { + showToast('✅ 数据集加载成功,现在可以开始光速挑选了!'); + } + } catch (error) { + alert('数据获取或解析失败,请检查后端运行状态。'); + } finally { + loading.style.display = 'none'; + if (isFirstLoad) { + initialLoader.style.display = 'none'; + isFirstLoad = false; + } + } + } + + // 页面加载时自动获取一次 + window.onload = fetchRandomRecord; \ No newline at end of file diff --git a/src/crux/evaluation/datasets/querygen/task_worker.py b/src/crux/evaluation/datasets/querygen/task_worker.py new file mode 100644 index 0000000..a378535 --- /dev/null +++ b/src/crux/evaluation/datasets/querygen/task_worker.py @@ -0,0 +1,56 @@ +import queue +import threading +from llm_service import generate_and_select_query_for_paper + +generation_queue = queue.Queue() +completion_results = [] +results_lock = threading.Lock() +current_processing_title = None + +def generation_worker(): + global current_processing_title + while True: + task = generation_queue.get() + if task is None: + break + + prompt = task.get('prompt') + title = task.get('title', '未知论文') + + current_processing_title = title + + short_title = title if len(title) <= 20 else title[:20] + "..." + print(f"\n[后台队列] 开始处理新任务: 《{short_title}》 (剩余等待: {generation_queue.qsize()})") + + result_msg, is_err = generate_and_select_query_for_paper(prompt, title) + + with results_lock: + completion_results.append({ + "title": title, + "message": result_msg, + "isError": is_err + }) + current_processing_title = None + generation_queue.task_done() + +def start_worker(): + threading.Thread(target=generation_worker, daemon=True).start() + +def add_task(prompt, title): + generation_queue.put({'prompt': prompt, 'title': title}) + return generation_queue.qsize() + +def get_status(): + with results_lock: + data = list(completion_results) + completion_results.clear() + + q_items = list(generation_queue.queue) + queued_titles = [i.get('title', '未知论文') for i in q_items] + + return { + "results": data, + "queue_size": len(q_items), + "queued_titles": queued_titles, + "current_processing": current_processing_title + } diff --git a/src/crux/evaluation/datasets/querygen/templates/index.html b/src/crux/evaluation/datasets/querygen/templates/index.html new file mode 100644 index 0000000..29c1efe --- /dev/null +++ b/src/crux/evaluation/datasets/querygen/templates/index.html @@ -0,0 +1,132 @@ + + + + + + arXiv QueryGen - 智能论文检索问题生成器 + + + + +
+
+

程序刚启动:正在将海量 Parquet 数据载入内存...

+

可能需要数十秒,请耐心等待(后续抽取将瞬间完成)⏳

+
+ +
+
📊 队列等待数: 0
+ +
+
+

📚 arXiv QueryGen

+

学术检索 RAG 测试集自动化构建工具

+ + +
+
+
+
⌨️ 手动模式
+
💻 自动模式
+
+
+ + + + + +
+
+
正在加载数据...
+ + + +
+ + + + + +
+
+ 📋 纯文本模式 (包含提示词) +
+ +
+
+ +
+
+
+ +
+ + + + \ No newline at end of file