Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/crux/evaluation/datasets/querygen/README.md
Original file line number Diff line number Diff line change
@@ -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)

无论是手动还是自动模式,页面下方都有队列提示悬浮窗,如果成功、报错或者网络出现状况,都会有小气泡提醒。
78 changes: 78 additions & 0 deletions src/crux/evaluation/datasets/querygen/arxiv_querygen.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions src/crux/evaluation/datasets/querygen/config.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions src/crux/evaluation/datasets/querygen/data_manager.py
Original file line number Diff line number Diff line change
@@ -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
Loading