Files
2026-08-03 18:42:47 +08:00

364 lines
17 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
'''Build an interactive HTML review page for PlantGeneBench-100 questions.'''
from __future__ import annotations
import argparse
import json
from collections import Counter
from datetime import datetime
from html import escape
from pathlib import Path
from typing import Any
def load_jsonl(path: Path) -> list[dict[str, Any]]:
rows = []
with path.open('r', encoding='utf-8') as handle:
for lineno, line in enumerate(handle, start=1):
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError as exc:
raise SystemExit(f'{path}:{lineno}: invalid JSON: {exc}') from exc
return rows
def pretty(value: Any) -> str:
if value in (None, '', [], {}):
return ''
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
def inline_list(value: Any) -> str:
if not value:
return ''
if isinstance(value, list):
return ', '.join(str(v) for v in value)
return str(value)
def item_html(task: dict[str, Any], gold_row: dict[str, Any], index: int) -> str:
gold = gold_row.get('gold') or {}
task_id = task.get('task_id', '')
layer = task.get('layer') or task.get('category') or 'unknown'
difficulty = task.get('difficulty', '')
stages = gold.get('target_stages') or gold.get('expected_stages') or []
plan = gold.get('execution_plan') or gold.get('expected_execution_plan') or []
entities = gold.get('entities') or {}
params = gold.get('parameters') or gold.get('required_params') or {}
artifacts = gold.get('artifacts') or {}
must_not = gold.get('must_not_do') or []
must_include = gold.get('must_include') or []
can_execute = gold.get('can_execute')
support_status = gold.get('support_status')
boundary_action = gold.get('boundary_action')
scoring_profile = gold_row.get('scoring_profile') or task.get('scoring_profile') or []
full_task = pretty(task)
full_gold = pretty(gold)
must_not_html = ''.join(f'<li>{escape(str(x))}</li>' for x in must_not) or '<li class="muted">无</li>'
must_include_html = ''.join(f'<li>{escape(str(x))}</li>' for x in must_include) or '<li class="muted">无</li>'
return f'''
<section class="card" data-task-id="{escape(task_id)}" data-layer="{escape(layer)}" data-difficulty="{escape(difficulty)}">
<div class="card-head">
<div>
<h2><span class="idx">{index:03d}</span> {escape(task_id)}</h2>
<p class="meta"><span>{escape(layer)}</span><span>{escape(difficulty)}</span><span>scoring: {escape(inline_list(scoring_profile))}</span></p>
</div>
<a class="anchor" href="#{escape(task_id)}" id="{escape(task_id)}">#</a>
</div>
<div class="question-block">
<h3>题目正文</h3>
<p class="question">{escape(task.get('user_query', ''))}</p>
</div>
<div class="grid">
<div class="panel">
<h3>Gold 摘要</h3>
<dl>
<dt>Intent</dt><dd><code>{escape(str(gold.get('intent') or ''))}</code></dd>
<dt>Analysis type</dt><dd><code>{escape(str(gold.get('analysis_type') or ''))}</code></dd>
<dt>Entities</dt><dd><pre>{escape(pretty(entities))}</pre></dd>
<dt>Target stages</dt><dd><code>{escape(inline_list(stages))}</code></dd>
<dt>Execution plan</dt><dd><code>{escape(inline_list(plan))}</code></dd>
<dt>Parameters</dt><dd><pre>{escape(pretty(params))}</pre></dd>
<dt>Artifacts</dt><dd><pre>{escape(pretty(artifacts))}</pre></dd>
<dt>Can execute</dt><dd><code>{escape(str(can_execute))}</code></dd>
<dt>Support status</dt><dd><code>{escape(str(support_status))}</code></dd>
<dt>Boundary action</dt><dd><code>{escape(str(boundary_action))}</code></dd>
</dl>
</div>
<div class="panel review-panel">
<h3>修订意见</h3>
<label>题目是否需要修订
<select data-field="status" data-task="{escape(task_id)}">
<option value="">未选择</option>
<option value="keep">保留</option>
<option value="revise_minor">小修</option>
<option value="revise_major">大修</option>
<option value="replace">替换</option>
<option value="remove">删除</option>
</select>
</label>
<label>题目修订意见
<textarea data-field="question_note" data-task="{escape(task_id)}" placeholder="例如:题面缺少输入文件;物种/家族不清楚;难度不合理;更适合作为边界控制题……"></textarea>
</label>
<label>建议改写后的题目
<textarea data-field="revised_question" data-task="{escape(task_id)}" placeholder="可直接写新的题目正文。"></textarea>
</label>
<label>Gold / 评分标准修订意见
<textarea data-field="gold_note" data-task="{escape(task_id)}" placeholder="例如:target_stages 应改为 identify,motif;must-not-do 不合理;can_execute 应为 false……"></textarea>
</label>
<label>优先级
<select data-field="priority" data-task="{escape(task_id)}">
<option value="">未选择</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
</select>
</label>
</div>
</div>
<div class="notes">
<div>
<h3>Must-not-do</h3>
<ul>{must_not_html}</ul>
</div>
<div>
<h3>Must-include</h3>
<ul>{must_include_html}</ul>
</div>
</div>
<details>
<summary>查看 rationale / input_context / 完整 JSON</summary>
<h4>Rationale</h4>
<pre>{escape(str(gold.get('rationale') or ''))}</pre>
<h4>Input context</h4>
<pre>{escape(pretty(task.get('input_context') or {}))}</pre>
<h4>Full task JSON</h4>
<pre>{escape(full_task)}</pre>
<h4>Full gold JSON</h4>
<pre>{escape(full_gold)}</pre>
</details>
</section>
'''
def build_html(tasks: list[dict[str, Any]], gold_rows: list[dict[str, Any]], task_path: Path, gold_path: Path) -> str:
gold_by_id = {row['task_id']: row for row in gold_rows}
layers = Counter(task.get('layer') or task.get('category') or 'unknown' for task in tasks)
difficulties = Counter(task.get('difficulty', 'unknown') for task in tasks)
cards = []
for index, task in enumerate(tasks, start=1):
task_id = task.get('task_id')
gold_row = gold_by_id.get(task_id) or {'task_id': task_id, 'gold': {}, 'layer': task.get('layer')}
cards.append(item_html(task, gold_row, index))
data_json = json.dumps([task.get('task_id') for task in tasks], ensure_ascii=False)
generated = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
layer_options = ''.join(f'<option value="{escape(k)}">{escape(k)} ({v})</option>' for k, v in sorted(layers.items()))
diff_options = ''.join(f'<option value="{escape(k)}">{escape(k)} ({v})</option>' for k, v in sorted(difficulties.items()))
layer_summary = escape(', '.join(f'{k}={v}' for k, v in sorted(layers.items())))
diff_summary = escape(', '.join(f'{k}={v}' for k, v in sorted(difficulties.items())))
cards_html = ''.join(cards)
return f'''<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PlantGeneBench-100 题库修订审阅</title>
<style>
:root {{ color-scheme: light; --bg:#f5f6f8; --panel:#ffffff; --ink:#1e2329; --muted:#667085; --line:#d9dee7; --accent:#1f6feb; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--ink); font-family:Arial,"Microsoft YaHei",sans-serif; line-height:1.55; }}
header {{ position:sticky; top:0; z-index:10; background:#fff; border-bottom:1px solid var(--line); padding:14px 22px; box-shadow:0 2px 10px rgba(16,24,40,.05); }}
h1 {{ margin:0 0 6px; font-size:22px; }}
.meta, .muted {{ color:var(--muted); font-size:13px; }}
.toolbar {{ display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-top:10px; }}
.toolbar input, .toolbar select, button {{ height:34px; border:1px solid var(--line); border-radius:6px; background:#fff; padding:0 10px; }}
.toolbar input {{ min-width:260px; }}
button {{ cursor:pointer; color:#111827; }}
button.primary {{ background:var(--accent); color:#fff; border-color:var(--accent); }}
main {{ max-width:1280px; margin:20px auto 64px; padding:0 18px; }}
.summary {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:16px; }}
.summary div {{ background:#fff; border:1px solid var(--line); border-radius:8px; padding:10px 12px; }}
.card {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; margin:14px 0; padding:16px; }}
.card-head {{ display:flex; justify-content:space-between; gap:12px; border-bottom:1px solid #edf0f5; padding-bottom:10px; margin-bottom:12px; }}
h2 {{ margin:0; font-size:20px; }}
h3 {{ margin:0 0 8px; font-size:15px; }}
h4 {{ margin:14px 0 6px; }}
.idx {{ color:var(--muted); font-weight:400; }}
.anchor {{ color:var(--muted); text-decoration:none; font-size:18px; }}
.meta span {{ display:inline-block; margin-right:8px; padding:2px 7px; background:#f2f4f7; border-radius:999px; }}
.question-block {{ background:#f8fafc; border:1px solid #e6eaf0; border-radius:8px; padding:12px; margin-bottom:12px; }}
.question {{ margin:0; font-size:16px; white-space:pre-wrap; }}
.grid {{ display:grid; grid-template-columns:minmax(0,1.08fr) minmax(360px,.92fr); gap:14px; align-items:start; }}
.panel {{ border:1px solid #e6eaf0; border-radius:8px; padding:12px; background:#fff; }}
dl {{ display:grid; grid-template-columns:140px minmax(0,1fr); gap:6px 10px; margin:0; }}
dt {{ color:var(--muted); }}
dd {{ margin:0; min-width:0; overflow-wrap:anywhere; }}
code {{ background:#eef4ff; padding:1px 5px; border-radius:4px; }}
pre {{ white-space:pre-wrap; overflow:auto; background:#f8fafc; border:1px solid #e6eaf0; border-radius:6px; padding:8px; margin:4px 0; max-height:360px; }}
.review-panel label {{ display:block; font-size:13px; color:#344054; margin:10px 0; }}
.review-panel textarea, .review-panel select {{ display:block; width:100%; margin-top:4px; border:1px solid var(--line); border-radius:6px; padding:8px; font:inherit; background:#fff; }}
.review-panel textarea {{ min-height:82px; resize:vertical; }}
.notes {{ display:grid; grid-template-columns:1fr 1fr; gap:14px; margin:12px 0; }}
.notes > div {{ border:1px solid #e6eaf0; border-radius:8px; padding:12px; background:#fff; }}
ul {{ margin:0; padding-left:20px; }}
details {{ margin-top:12px; }}
summary {{ cursor:pointer; color:var(--accent); }}
.hidden {{ display:none !important; }}
.dirty {{ outline:2px solid rgba(31,111,235,.18); }}
@media (max-width:900px) {{ .summary, .grid, .notes {{ grid-template-columns:1fr; }} header {{ position:static; }} .toolbar input {{ min-width:0; width:100%; }} }}
</style>
</head>
<body>
<header>
<h1>PlantGeneBench-100 题库修订审阅</h1>
<div class="meta">Generated: {escape(generated)} | tasks=<code>{escape(str(task_path))}</code> | gold=<code>{escape(str(gold_path))}</code></div>
<div class="toolbar">
<input id="search" type="search" placeholder="搜索 ID、题面、stage、gold...">
<select id="layer"><option value="">全部 layer</option>{layer_options}</select>
<select id="difficulty"><option value="">全部难度</option>{diff_options}</select>
<select id="reviewStatus"><option value="">全部修订状态</option><option value="filled">已填写意见</option><option value="empty">未填写意见</option></select>
<button id="save" class="primary">保存到浏览器</button>
<button id="exportJson">导出 JSON</button>
<button id="exportTsv">导出 TSV</button>
<button id="clear">清空意见</button>
</div>
</header>
<main>
<section class="summary">
<div><b>题目数</b><br>{len(tasks)}</div>
<div><b>Layer</b><br>{layer_summary}</div>
<div><b>难度</b><br>{diff_summary}</div>
<div><b>提示</b><br>输入会自动保存在浏览器 localStorage;正式提交请导出 JSON 或 TSV。</div>
</section>
{cards_html}
</main>
<script>
const TASK_IDS = {data_json};
const STORAGE_KEY = 'plantgenebench_100_v1_review_notes';
function loadNotes() {{
try {{ return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{{}}'); }} catch(e) {{ return {{}}; }}
}}
function collectNotes() {{
const notes = loadNotes();
document.querySelectorAll('[data-task][data-field]').forEach(el => {{
const id = el.dataset.task;
const field = el.dataset.field;
notes[id] = notes[id] || {{ task_id:id }};
notes[id][field] = el.value || '';
}});
return notes;
}}
function saveNotes() {{
const notes = collectNotes();
localStorage.setItem(STORAGE_KEY, JSON.stringify(notes));
updateSummaryState();
}}
function restoreNotes() {{
const notes = loadNotes();
document.querySelectorAll('[data-task][data-field]').forEach(el => {{
const row = notes[el.dataset.task] || {{}};
el.value = row[el.dataset.field] || '';
}});
updateSummaryState();
}}
function hasNote(id) {{
const n = collectNotes()[id] || {{}};
return ['status','question_note','revised_question','gold_note','priority'].some(k => (n[k] || '').trim());
}}
function updateSummaryState() {{
document.querySelectorAll('.card').forEach(card => {{
card.classList.toggle('dirty', hasNote(card.dataset.taskId));
}});
}}
function applyFilters() {{
const q = document.getElementById('search').value.trim().toLowerCase();
const layer = document.getElementById('layer').value;
const diff = document.getElementById('difficulty').value;
const status = document.getElementById('reviewStatus').value;
document.querySelectorAll('.card').forEach(card => {{
const text = card.innerText.toLowerCase();
let ok = true;
if (q && !text.includes(q)) ok = false;
if (layer && card.dataset.layer !== layer) ok = false;
if (diff && card.dataset.difficulty !== diff) ok = false;
if (status === 'filled' && !hasNote(card.dataset.taskId)) ok = false;
if (status === 'empty' && hasNote(card.dataset.taskId)) ok = false;
card.classList.toggle('hidden', !ok);
}});
}}
function download(filename, content, mime) {{
const blob = new Blob([content], {{ type:mime }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}}
function exportJson() {{
const notes = collectNotes();
const rows = TASK_IDS.map(id => notes[id] || {{task_id:id}}).filter(row => Object.entries(row).some(([k,v]) => k !== 'task_id' && String(v || '').trim()));
download('plantgenebench_100_v1_revision_notes.json', JSON.stringify(rows, null, 2), 'application/json;charset=utf-8');
}}
function tsvEscape(v) {{ return String(v || '').replace(/ /g, ' ').replace(/
?
/g, ' '); }}
function exportTsv() {{
const notes = collectNotes();
const fields = ['task_id','status','priority','question_note','revised_question','gold_note'];
const rows = [fields.join(' ')];
TASK_IDS.forEach(id => {{
const row = notes[id] || {{task_id:id}};
if (fields.some(f => f !== 'task_id' && String(row[f] || '').trim())) rows.push(fields.map(f => tsvEscape(row[f] || (f === 'task_id' ? id : ''))).join(' '));
}});
download('plantgenebench_100_v1_revision_notes.tsv', rows.join('
') + '
', 'text/tab-separated-values;charset=utf-8');
}}
function clearNotes() {{
if (!confirm('确认清空当前浏览器中保存的所有修订意见?')) return;
localStorage.removeItem(STORAGE_KEY);
restoreNotes();
applyFilters();
}}
document.querySelectorAll('[data-task][data-field]').forEach(el => {{
el.addEventListener('input', () => {{ saveNotes(); applyFilters(); }});
el.addEventListener('change', () => {{ saveNotes(); applyFilters(); }});
}});
['search','layer','difficulty','reviewStatus'].forEach(id => document.getElementById(id).addEventListener('input', applyFilters));
document.getElementById('save').addEventListener('click', saveNotes);
document.getElementById('exportJson').addEventListener('click', exportJson);
document.getElementById('exportTsv').addEventListener('click', exportTsv);
document.getElementById('clear').addEventListener('click', clearNotes);
restoreNotes();
applyFilters();
</script>
</body>
</html>
'''
def run() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--tasks', type=Path, default=Path('benchmarks/plantgenebench/tasks/plantgenebench_100_v1.full.jsonl'))
parser.add_argument('--gold', type=Path, default=Path('benchmarks/plantgenebench/gold/plantgenebench_100_v1.full.gold.jsonl'))
parser.add_argument('--out', type=Path, default=Path('benchmarks/plantgenebench/plantgenebench_100_v1_review.html'))
args = parser.parse_args()
tasks = load_jsonl(args.tasks)
gold_rows = load_jsonl(args.gold)
html = build_html(tasks, gold_rows, args.tasks, args.gold)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(html, encoding='utf-8')
print(f'Wrote {len(tasks)} review items to {args.out}')
return 0
if __name__ == '__main__':
raise SystemExit(run())