#!/usr/bin/env python3 """ 記分板:把一次(或多次)考試的資料判成 ✅/◐/❌ 表。 方法論鐵律(`kbdb-動作清單考卷.md` §七點五):**每格至少跑三次**, 記成 `n/3`,不寫單一結果——一次的差異一律當雜訊。 兩種來源: 本機乾跑 report.py --sqlite a.db b.db c.db 正式考試 report.py --d1 --account-id … --token-env … --runs A,B,C 🔴 **正式考試三次跑在同一台實例上**,所以每一次有自己的 run tag: sheet 叫 `xq<題號>_…`,L3 的那個人也每次換一個名字 ——否則 A 跑留下的東西會被算進 B 跑的分數。 """ import argparse import os import sqlite3 import sys from collections import defaultdict sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from grade import Pool, signals, verdict, RUBRIC, LOAD_SQL, load_d1 # noqa: E402 QUESTIONS = ["L1", "L2", "L3", "L4", "L5", "L6"] # 每一次考試自己的人名(L3 的 entity_once 是**整池**判定,不換名字會跨 run 互相污染) RUN_PERSON = {"": "王小明", "A": "王小明", "B": "陳大文", "C": "林志明"} def load_rows(source, dbname="", account_id="", token=""): if source.endswith(".db"): con = sqlite3.connect(source) con.row_factory = sqlite3.Row rows = [dict(r) for r in con.execute(LOAD_SQL)] con.close() return rows return load_d1(dbname, account_id, token) def grade_rows(rows, qid, tag=""): pool = Pool(rows) prefix = f"xq{tag}{qid}" scope = [sid for sid, name in pool.sheets().items() if (name or "").startswith(prefix)] sig = signals(pool, scope) rule = dict(RUBRIC[qid]) if "entity_once" in rule: rule = {**rule, "entity_once": RUN_PERSON.get(tag, "王小明")} # verdict 讀的是 RUBRIC,這裡暫時換掉那一格再換回來(不改共用狀態的語意) saved = RUBRIC[qid] RUBRIC[qid] = rule try: mark, notes = verdict(qid, sig, None) finally: RUBRIC[qid] = saved return mark, notes, sig def main(): ap = argparse.ArgumentParser() ap.add_argument("--sqlite", nargs="*", default=[]) ap.add_argument("--d1", default="") ap.add_argument("--account-id", default="") ap.add_argument("--token-env", default="CLOUDFLARE_API_TOKEN_YOULIN_CC_USE") ap.add_argument("--runs", default="", help="正式考試的 run tag,逗號分隔,如 A,B,C") a = ap.parse_args() jobs = [] # (label, rows, tag) if a.sqlite: for p in a.sqlite: jobs.append((os.path.basename(os.path.dirname(p)) or os.path.basename(p), load_rows(p), "")) if a.d1: rows = load_rows("d1", a.d1, a.account_id, os.environ.get(a.token_env, "")) print(f"(從 D1 讀到 {len(rows)} 顆 entry —— 判分只看落地資料,不看受測者自述)") for tag in [t.strip() for t in a.runs.split(",") if t.strip()]: jobs.append((f"run {tag}", rows, tag)) tally = defaultdict(list) for label, rows, tag in jobs: print(f"\n════ {label} ════") # 🔴 生死檢查,**必須在計分之前**(2026-08-16 實撞): # L3/L6 的正確答案是「什麼都不寫」——所以一個**完全沒動手**的受測者 # 會在那兩題拿到 ✅。那不是答對,那是沒考。 # 實例:run A 自述六題全做完、還報出每一筆內容,而 D1 裡一筆都沒有。 # ⇒ 先問「這次到底有沒有寫進任何東西」,沒有就整場作廢,不給任何 ✅。 wrote = sum(grade_rows(rows, q, tag)[2]["records"] for q in ("L1", "L2", "L4", "L5")) if wrote == 0: print(" 🔴 這一場作廢:四個「該寫東西」的題目加起來一筆都沒落地。") print(" (L3/L6 的正解是不寫 ⇒ 沒動手的人會假性通過那兩題,不予計分)") for q in QUESTIONS: tally[q].append("作廢") continue for q in QUESTIONS: mark, notes, sig = grade_rows(rows, q, tag) tally[q].append(mark) print(f" {q} {mark} {RUBRIC[q]['title']}") for n in notes: print(f" └ {n}") n = len(jobs) print(f"\n════ 記分板(n={n},只有跨全部樣本一致的差異才算訊號)════") print(f"{'題':<5}{'✅':<5}{'◐':<5}{'❌':<5}{'作廢':<5} 標題") for q in QUESTIONS: m = tally[q] print(f"{q:<5}{m.count(chr(9989)):<5}{m.count(chr(9680)):<5}{m.count(chr(10060)):<5}{m.count('作廢'):<5} {RUBRIC[q]['title']}") if __name__ == "__main__": main()