- 规则库 3.6→3.8.1(542→548 条):剪枝上锁、R-杂-38 从格临界双轨、 R-事-10~13 冲刑方向判别、R-禁-23 自造必要条件之禁 - 排盘修复随 3.6.2 已发:港澳时区夏令时双向误拨、晚子时双重换日 - 新增 评测/ 目录(BaziQA 题答分离、判分工具、记分卡、with/without-skill 对照) 与 scripts/query_rules.py 条款检索;ZIPEC_EVAL 评测额度豁免 - manifest 重建并重新签名;VERSION 保持 3.6.2,不发版不打 tag
148 lines
6.3 KiB
Python
148 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
||
"""得分.json → 单文件 HTML 记分卡(模板 scorecard_template.html)。
|
||
|
||
沿用技能盘面图的品牌风格:logo 与品牌字体从母版 scripts/render/assets/
|
||
以 base64 内嵌(单文件便携),亮色卡片风。数据全部由 得分.json 注入,
|
||
模板不含任何硬编码分数。
|
||
|
||
得分.json 字段 schema(收录新模型时按此填写):
|
||
model, skill_mode(with-skill / without-skill), skill_version, eval_date,
|
||
benchmark,
|
||
overall: {total, correct, accuracy, no_answer}
|
||
baseline: {name, overall: {total, correct, accuracy},
|
||
per_contest: {contest: accuracy}, per_dimension: {维度: accuracy}}
|
||
per_contest: {contest: {total, correct, accuracy, no_answer}}
|
||
per_dimension:{维度: {total, correct, accuracy, no_answer}}
|
||
|
||
用法:
|
||
python render_scorecard.py [--score <得分.json>] [--out <输出.html>]
|
||
[--template <模板>] [--assets <render/assets>]
|
||
默认:--score 评测/ai模型纬度得分/kimi-k3/with-skill/得分.json,
|
||
输出与得分.json 同目录的 得分.html。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import base64
|
||
import json
|
||
import sys
|
||
from html import escape
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1] # 评测/
|
||
SKILL = Path(__file__).resolve().parents[2] # 技能母版根
|
||
DEFAULT_SCORE = ROOT / "ai模型纬度得分" / "kimi-k3" / "with-skill" / "得分.json"
|
||
DIM_ORDER = ["流年运势", "感情", "六亲", "事业", "性格", "学业", "财富", "健康", "其他"]
|
||
|
||
# 结构性占位符:值是装配层渲染的 HTML 片段或 base64 资源,填充时原样注入;
|
||
# 其余占位符均为文本字段,一律 HTML 转义防注入(与盘面图装配层同口径)。
|
||
STRUCTURAL = {"LOGO_B64", "FONT_B64", "DIM_BARS", "CONTEST_ROWS"}
|
||
|
||
|
||
def asset_b64(assets: Path, name: str) -> str:
|
||
f = assets / name
|
||
return base64.b64encode(f.read_bytes()).decode("ascii") if f.is_file() else ""
|
||
|
||
|
||
def dim_bars(score: dict) -> str:
|
||
rows = []
|
||
dims = score["per_dimension"]
|
||
base = score["baseline"].get("per_dimension", {})
|
||
for d in DIM_ORDER + sorted(set(dims) - set(DIM_ORDER)):
|
||
if d not in dims:
|
||
continue
|
||
s = dims[d]
|
||
b = base.get(d)
|
||
name = escape(d)
|
||
ours = f'<div class="bar ours" style="width:{s["accuracy"]}%"></div>'
|
||
ours += (f'<div class="bar base" style="width:{b}%"></div>'
|
||
if isinstance(b, (int, float)) else "")
|
||
val = (f'<b>{s["accuracy"]}%</b>({s["correct"]}/{s["total"]})'
|
||
+ (f' <span style="color:var(--muted)">基线 {b}%</span>'
|
||
if isinstance(b, (int, float)) else ""))
|
||
rows.append(f'<div class="dimrow"><div class="dimname">{name}</div>'
|
||
f'<div class="dimtrack">{ours}</div>'
|
||
f'<div class="dimval">{val}</div></div>')
|
||
return "\n ".join(rows)
|
||
|
||
|
||
def contest_rows(score: dict) -> str:
|
||
rows = []
|
||
base = score["baseline"].get("per_contest", {})
|
||
for c in sorted(score["per_contest"]):
|
||
s = score["per_contest"][c]
|
||
b = base.get(c)
|
||
if isinstance(b, (int, float)):
|
||
diff = round(s["accuracy"] - b, 1)
|
||
cls = "up" if diff >= 0 else "down"
|
||
delta = (f'<span class="delta {cls}">{"+" if diff >= 0 else ""}'
|
||
f'{diff}pp</span>')
|
||
btxt = f"{b}%"
|
||
else:
|
||
delta, btxt = "—", "—"
|
||
rows.append(
|
||
f'<tr><td class="dim">{escape(c)}</td>'
|
||
f'<td><b>{s["accuracy"]}%</b>({s["correct"]}/{s["total"]},'
|
||
f'未答 {s.get("no_answer", 0)})</td>'
|
||
f'<td>{btxt}</td><td>{delta}</td></tr>')
|
||
return "\n ".join(rows)
|
||
|
||
|
||
def render(score: dict, template: str, assets: Path, score_json_name: str) -> str:
|
||
ov = score["overall"]
|
||
bo = score["baseline"]["overall"]
|
||
delta = round(ov["accuracy"] - bo["accuracy"], 1)
|
||
values = {
|
||
"TITLE": f"评测记分卡:{score['model']}({score['skill_mode']})",
|
||
"LOGO_B64": asset_b64(assets, "lingyao-logo.png"),
|
||
"FONT_B64": asset_b64(assets, "logo-font.ttf"),
|
||
"MODEL": score["model"],
|
||
"SKILL_MODE_LABEL": "加载技能" if score["skill_mode"] == "with-skill"
|
||
else "裸答对照",
|
||
"BENCHMARK": score["benchmark"],
|
||
"SKILL_VERSION": score.get("skill_version", "—"),
|
||
"EVAL_DATE": score.get("eval_date", "—"),
|
||
"OVERALL_ACC": f'{ov["accuracy"]}',
|
||
"OVERALL_CORRECT": f'{ov["correct"]}',
|
||
"OVERALL_TOTAL": f'{ov["total"]}',
|
||
"NO_ANSWER": f'{ov.get("no_answer", 0)}',
|
||
"BASELINE_NAME": score["baseline"].get("name", "官方基线"),
|
||
"BASELINE_ACC": f'{bo["accuracy"]}',
|
||
"DELTA_CLASS": "up" if delta >= 0 else "down",
|
||
"DELTA_TEXT": f'({"+" if delta >= 0 else ""}{delta}pp)',
|
||
"DIM_BARS": dim_bars(score),
|
||
"CONTEST_ROWS": contest_rows(score),
|
||
"SCORE_JSON": score_json_name,
|
||
}
|
||
out = template
|
||
for k, v in values.items():
|
||
out = out.replace("{{" + k + "}}", v if k in STRUCTURAL else escape(str(v)))
|
||
leftover = [p.split("}}")[0] for p in out.split("{{")[1:]]
|
||
assert not leftover, f"模板存在未填充占位符: {leftover}"
|
||
return out
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="得分.json → 单文件 HTML 记分卡")
|
||
ap.add_argument("--score", type=Path, default=DEFAULT_SCORE)
|
||
ap.add_argument("--out", type=Path, default=None,
|
||
help="默认与得分.json 同目录的 得分.html")
|
||
ap.add_argument("--template", type=Path,
|
||
default=Path(__file__).resolve().parent / "scorecard_template.html")
|
||
ap.add_argument("--assets", type=Path,
|
||
default=SKILL / "scripts" / "render" / "assets")
|
||
a = ap.parse_args()
|
||
|
||
score = json.loads(a.score.read_text(encoding="utf-8"))
|
||
template = a.template.read_text(encoding="utf-8")
|
||
out = a.out or a.score.with_name("得分.html")
|
||
html = render(score, template, a.assets, a.score.name)
|
||
out.write_text(html, encoding="utf-8")
|
||
print(f"OK: {out}({len(html)} 字符,logo/字体"
|
||
f"{'已内嵌' if asset_b64(a.assets, 'lingyao-logo.png') else '未找到,已留空'})")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|