#!/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 ]
默认:--score 评测/ai模型纬度得分/kimi-k3/with-skill/得分.json,
输出与得分.json 同目录的 得分.html。
"""
from __future__ import annotations
import argparse
import base64
import json
import re
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_BARS"}
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''
ours += (f''
if isinstance(b, (int, float)) else "")
val = (f'{s["accuracy"]}%({s["correct"]}/{s["total"]})'
+ (f' 基线 {b}%'
if isinstance(b, (int, float)) else ""))
rows.append(f'{name}
'
f'
{ours}
'
f'
{val}
')
return "\n ".join(rows)
def contest_bars(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)
label = escape(re.sub(r"^contest\d+_", "", c) + " 届")
ours = f''
ours += (f''
if isinstance(b, (int, float)) else "")
val = f'{s["accuracy"]}%({s["correct"]}/{s["total"]})'
if isinstance(b, (int, float)):
diff = round(s["accuracy"] - b, 1)
cls = "up" if diff >= 0 else "down"
val += (f' 基线 {b}%'
f' '
f'{"+" if diff >= 0 else ""}{diff}pp')
rows.append(f'{label}
'
f'
{ours}
'
f'
{val}
')
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_BARS": contest_bars(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())