first commit

This commit is contained in:
2026-09-22 10:48:18 +08:00
commit 2006f7b4a4
20 changed files with 1877 additions and 0 deletions

0
app/__init__.py Normal file
View File

11
app/__main__.py Normal file
View File

@@ -0,0 +1,11 @@
import os
import uvicorn
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host="127.0.0.1",
port=int(os.environ.get("PORT", "8765")),
reload=os.environ.get("RELOAD", "1") != "0",
)

14
app/config.py Normal file
View File

@@ -0,0 +1,14 @@
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
UPLOAD_DIR = DATA_DIR / "uploads"
DB_PATH = DATA_DIR / "hangbiao.db"
SAMPLE_XLSX = BASE_DIR / "2024级指标分析成绩基础数据_5次考试.xlsx"
SECRET_KEY = "hangbiao-dev-secret-change-me"
ADMIN_USER = "admin"
ADMIN_PASSWORD = "hangbiao2026"
SESSION_MAX_AGE = 60 * 60 * 12
MAX_UPLOAD_MB = 15

83
app/db.py Normal file
View File

@@ -0,0 +1,83 @@
from __future__ import annotations
import json
import sqlite3
from datetime import datetime
from pathlib import Path
from .config import DB_PATH
def connect() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
conn = connect()
conn.execute(
"""
CREATE TABLE IF NOT EXISTS analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
filename TEXT,
created_at TEXT,
step INTEGER DEFAULT 1,
parsed_path TEXT,
settings_json TEXT,
result_path TEXT
)
"""
)
conn.commit()
conn.close()
def create_analysis(title: str, filename: str, parsed_path: str) -> int:
conn = connect()
cur = conn.execute(
"""
INSERT INTO analyses (title, filename, created_at, step, parsed_path)
VALUES (?, ?, ?, 1, ?)
""",
(title, filename, datetime.now().strftime("%Y-%m-%d %H:%M:%S"), parsed_path),
)
conn.commit()
aid = cur.lastrowid
conn.close()
return int(aid)
def list_analyses() -> list[dict]:
conn = connect()
rows = conn.execute("SELECT * FROM analyses ORDER BY id DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_analysis(aid: int) -> dict | None:
conn = connect()
row = conn.execute("SELECT * FROM analyses WHERE id=?", (aid,)).fetchone()
conn.close()
return dict(row) if row else None
def update_analysis(aid: int, **fields) -> None:
if not fields:
return
keys = ", ".join(f"{k}=?" for k in fields)
conn = connect()
conn.execute(f"UPDATE analyses SET {keys} WHERE id=?", (*fields.values(), aid))
conn.commit()
conn.close()
def load_json(path: str):
return json.loads(Path(path).read_text(encoding="utf-8"))
def save_json(path: str, obj) -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8")

253
app/excel_parse.py Normal file
View File

@@ -0,0 +1,253 @@
from __future__ import annotations
import re
from io import BytesIO
from pathlib import Path
from openpyxl import load_workbook
META_ALIASES = {
"sid": ("学号", "考号", "id", "学生id"),
"name": ("姓名", "学生姓名", "名字"),
"class_name": ("班级", "班", "行政班"),
"track": ("方向", "科类", "首选", "首选科目"),
"subjects": ("选科", "再选", "科目组合"),
"status": ("学籍", "学籍类型", "身份"),
"move": ("异动", "备注", "说明"),
}
EXAM_HINT = re.compile(
r"(总分|期末|联考|月考|模考|开学|学考|诊断|调研|高考)",
re.I,
)
SKIP_SHEETS = ("说明", "readme", "目录")
ART_RE = re.compile(r"艺术|体育|艺体")
LEAVE_RE = re.compile(r"请假|出国|休学|国际|不在校|港澳|港籍.*假")
def _norm(v) -> str:
if v is None:
return ""
if isinstance(v, float) and v.is_integer():
return str(int(v))
return str(v).strip()
def _is_exam_header(text: str) -> bool:
t = text.strip()
if not t or t in {"考试次数"}:
return False
if t.endswith("-总分") or t.endswith("总分"):
return True
return bool(EXAM_HINT.search(t)) and "次数" not in t
def _map_meta(header: str) -> str | None:
h = header.strip().lower()
for key, aliases in META_ALIASES.items():
for a in aliases:
if h == a.lower() or h.replace(" ", "") == a.lower():
return key
return None
def _sheet_kind(name: str) -> str:
n = name.lower()
if any(x in n for x in ("艺体", "艺术", "体育")):
return "art"
if "异动" in n:
return "leave"
if "历史" in n:
return "history"
if "物理" in n:
return "physics"
if n in ("全体", "全部", "汇总"):
return "all"
return "other"
def parse_workbook(path: Path | BytesIO) -> dict:
wb = load_workbook(path, data_only=True, read_only=True)
sheets = []
students: list[dict] = []
exam_order: list[str] = []
warnings: list[str] = []
for ws in wb.worksheets:
kind = _sheet_kind(ws.title)
rows = ws.iter_rows(values_only=True)
try:
header_row = next(rows)
except StopIteration:
continue
headers = [_norm(h) for h in header_row]
if not any(headers):
continue
meta_idx = {}
exam_idx = {}
for i, h in enumerate(headers):
if not h:
continue
mk = _map_meta(h)
if mk:
meta_idx[mk] = i
continue
if _is_exam_header(h):
exam_idx[h] = i
if h not in exam_order:
exam_order.append(h)
if "sid" not in meta_idx and "name" not in meta_idx:
warnings.append(f"工作表「{ws.title}」未识别到学号/姓名,已跳过")
continue
count = 0
for raw in rows:
if raw is None or all(v is None or str(v).strip() == "" for v in raw):
continue
rec = {
"sid": _norm(raw[meta_idx["sid"]]) if "sid" in meta_idx else "",
"name": _norm(raw[meta_idx["name"]]) if "name" in meta_idx else "",
"class_name": _norm(raw[meta_idx["class_name"]]) if "class_name" in meta_idx else "未分班",
"track": _norm(raw[meta_idx["track"]]) if "track" in meta_idx else "",
"subjects": _norm(raw[meta_idx["subjects"]]) if "subjects" in meta_idx else "",
"status": _norm(raw[meta_idx["status"]]) if "status" in meta_idx else "",
"move": _norm(raw[meta_idx["move"]]) if "move" in meta_idx else "",
"sheet": ws.title,
"sheet_kind": kind,
"scores": {},
}
if not rec["sid"] and not rec["name"]:
continue
if not rec["sid"]:
rec["sid"] = f"{ws.title}-{rec['name']}-{count}"
if kind == "physics" and not rec["track"]:
rec["track"] = "物理方向"
if kind == "history" and not rec["track"]:
rec["track"] = "历史方向"
if not rec["track"]:
if "物理" in rec["subjects"]:
rec["track"] = "物理方向"
elif "历史" in rec["subjects"]:
rec["track"] = "历史方向"
for exam, idx in exam_idx.items():
if idx >= len(raw):
continue
val = raw[idx]
if val is None or str(val).strip() in ("", "缺考", "—", "-", "/"):
rec["scores"][exam] = None
continue
try:
rec["scores"][exam] = float(val)
except (TypeError, ValueError):
rec["scores"][exam] = None
rec["art"] = bool(ART_RE.search(rec["move"]) or ART_RE.search(ws.title))
rec["leave"] = bool(LEAVE_RE.search(rec["move"]))
students.append(rec)
count += 1
sheets.append(
{
"name": ws.title,
"kind": kind,
"rows": count,
"exams": list(exam_idx.keys()),
}
)
wb.close()
# Prefer specialized sheets over 全体 to avoid duplicates
has_split = any(s["kind"] in ("physics", "history") for s in sheets)
if has_split:
kept = []
skipped_all = 0
for s in students:
if s["sheet_kind"] == "all":
skipped_all += 1
continue
kept.append(s)
students = kept
if skipped_all:
warnings.append(f"已忽略「全体」表中的 {skipped_all} 行,避免与物理/历史表重复")
# de-duplicate by sid, keep first non-all
seen = {}
uniq = []
dups = 0
for s in students:
if s["sid"] in seen:
dups += 1
continue
seen[s["sid"]] = True
uniq.append(s)
students = uniq
if dups:
warnings.append(f"按学号去重 {dups} 条")
if not exam_order:
warnings.append("未识别到考试成绩列,请确认表头含「总分」或「期末/联考/月考」")
return {
"sheets": sheets,
"students": students,
"exams": exam_order,
"warnings": warnings,
}
def summarize_parse(parsed: dict) -> dict:
students = parsed["students"]
exams = parsed["exams"]
tracks = {}
classes = {}
status = {}
art = leave = 0
exam_present = {e: 0 for e in exams}
for s in students:
tracks[s["track"] or "未分方向"] = tracks.get(s["track"] or "未分方向", 0) + 1
classes[s["class_name"]] = classes.get(s["class_name"], 0) + 1
status[s["status"] or "未填"] = status.get(s["status"] or "未填", 0) + 1
if s["art"]:
art += 1
if s["leave"]:
leave += 1
for e in exams:
if s["scores"].get(e) is not None:
exam_present[e] += 1
score_scale = []
for e in exams:
vals = [s["scores"][e] for s in students if s["scores"].get(e) is not None]
if not vals:
continue
score_scale.append(
{
"exam": e,
"n": len(vals),
"min": round(min(vals), 1),
"max": round(max(vals), 1),
"mean": round(sum(vals) / len(vals), 1),
}
)
return {
"n": len(students),
"n_class": len(classes),
"tracks": tracks,
"classes": dict(sorted(classes.items(), key=lambda kv: _class_key(kv[0]))),
"status": status,
"art": art,
"leave": leave,
"exam_present": exam_present,
"score_scale": score_scale,
"sheets": parsed["sheets"],
"exams": exams,
"warnings": parsed["warnings"],
}
def _class_key(name: str):
m = re.search(r"\d+", str(name))
return (int(m.group()) if m else 999, str(name))
def class_sort_key(name: str):
return _class_key(name)

83
app/export_xlsx.py Normal file
View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
def build_export(result: dict) -> bytes:
wb = Workbook()
thin = Border(
left=Side(style="thin", color="D9D1C3"),
right=Side(style="thin", color="D9D1C3"),
top=Side(style="thin", color="D9D1C3"),
bottom=Side(style="thin", color="D9D1C3"),
)
head = PatternFill("solid", fgColor="12233D")
head_font = Font(color="F4EFE6", bold=True)
wrap = Alignment(wrap_text=True, vertical="center")
first = True
for g in result["groups"]:
for t in g["targets"]:
title = f"{g['group']}-{t['name']}"[:31]
ws = wb.active if first else wb.create_sheet(title)
if first:
ws.title = title
first = False
ws.append(
["班级", "测算人数", "最近一次在线", "滤波期望", "下达指标", "保底下界", "冲刺上界", "相对最近一次"]
)
for cell in ws[1]:
cell.fill = head
cell.font = head_font
for row in t["classes"]:
ws.append(
[
row["class_name"],
row["n"],
row["current"],
row["expected"],
row["assigned"],
row["floor"],
row["stretch"],
row["delta"],
]
)
ws.append([])
ws.append(["说明:下达指标为预估中位水平,班级应努力超越该人数。保底/冲刺来自模拟分布的约20%/80%分位。"])
ws = wb.create_sheet("学生明细")
exams = result["exams"]
headers = ["学号", "姓名", "班级", "方向", "学籍", "选科"]
headers += [f"{e}-排名" for e in exams]
# collect target names
tcols = []
for g in result["groups"]:
for t in g["targets"]:
tcols.append((g["group"], t["id"], f"{g['group']}-{t['name']}概率"))
headers += [c[2] for c in tcols]
headers += ["滤波水平", "预测方差"]
ws.append(headers)
for cell in ws[1]:
cell.fill = head
cell.font = head_font
cell.alignment = wrap
for s in result["students"]:
row = [s["sid"], s["name"], s["class_name"], s["group"], s.get("status") or "", s.get("subjects") or ""]
ranks = s.get("ranks") or []
for i, _e in enumerate(exams):
row.append(ranks[i] if i < len(ranks) else None)
for gname, tid, _ in tcols:
if s["group"] == gname:
row.append((s.get("probs") or {}).get(tid))
else:
row.append(None)
row.append(s.get("pred_mean"))
row.append(s.get("pred_var"))
ws.append(row)
bio = BytesIO()
wb.save(bio)
return bio.getvalue()

282
app/indicators.py Normal file
View File

@@ -0,0 +1,282 @@
from __future__ import annotations
from collections import defaultdict
from .excel_parse import class_sort_key
from .kalman import (
fit_qr,
hamilton,
holdout_hits,
kalman_filter,
monte_carlo,
ranks_desc,
zscores,
)
TRACK_DEFAULTS = {
"物理": [{"id": "t88", "name": "特控线上", "k": 88}],
"历史": [
{"id": "top5", "name": "尖子生", "k": 5},
{"id": "top12", "name": "600分以上", "k": 12},
{"id": "top52", "name": "特控线上", "k": 52},
],
}
def infer_track_group(track: str) -> str:
t = track or ""
if "历史" in t:
return "历史"
if "物理" in t:
return "物理"
return t or "未分方向"
def default_targets(groups: list[str]) -> dict[str, list[dict]]:
out = {}
for g in groups:
if g in TRACK_DEFAULTS:
out[g] = [dict(x) for x in TRACK_DEFAULTS[g]]
else:
out[g] = [{"id": "top20", "name": "年级前20%", "k": 20}]
return out
def select_students(parsed: dict, settings: dict) -> list[dict]:
include_art = settings.get("include_art", False)
include_leave = settings.get("include_leave", False)
include_mingyuan = settings.get("include_mingyuan", True)
include_other = settings.get("include_other", True)
exams = settings.get("exams") or parsed["exams"]
selected = []
for s in parsed["students"]:
if s["art"] and not include_art:
continue
if s["leave"] and not include_leave:
continue
st = s["status"] or "在籍"
if st == "明远" and not include_mingyuan:
continue
if st not in ("在籍", "明远", "") and not include_other:
continue
scores = {e: s["scores"].get(e) for e in exams}
if all(v is None for v in scores.values()):
continue
row = dict(s)
row["scores"] = scores
row["group"] = infer_track_group(s["track"])
selected.append(row)
return selected
def _forecast_horizon(q: float, p: float, horizon: int) -> float:
return p + max(1, horizon) * q
def analyze(parsed: dict, settings: dict) -> dict:
exams = settings.get("exams") or parsed["exams"]
if not exams:
raise ValueError("没有可用的考试列")
horizon = int(settings.get("horizon", 1))
n_sim = int(settings.get("n_sim", 600))
selected = select_students(parsed, settings)
groups = sorted({s["group"] for s in selected})
targets = settings.get("targets") or default_targets(groups)
# fit Q,R per group on z-scores
group_models = {}
for g in groups:
members = [s for s in selected if s["group"] == g]
z_by_exam = {}
for e in exams:
z_by_exam[e] = zscores([s["scores"].get(e) for s in members])
for i, s in enumerate(members):
s["z"] = [z_by_exam[e][i] for e in exams]
s["ranks"] = []
for e in exams:
rk = ranks_desc([s["scores"].get(e) for s in members])
for i, s in enumerate(members):
s["ranks"].append(rk[i])
series = [s["z"] for s in members]
q, r, rmse = fit_qr(series)
group_models[g] = {"q": q, "r": r, "rmse": rmse, "n": len(members)}
for s in members:
fr = kalman_filter(s["z"], q, r)
s["kf_x"] = fr.x
s["kf_p"] = fr.p
s["kf_xs"] = fr.xs
s["kf_ps"] = fr.ps
if fr.x is None:
s["pred_mean"] = None
s["pred_var"] = None
else:
s["pred_mean"] = fr.x
s["pred_var"] = _forecast_horizon(q, fr.p if fr.p is not None else r, horizon)
results = []
for g in groups:
members = [s for s in selected if s["group"] == g]
g_targets = targets.get(g) or default_targets([g])[g]
model = group_models[g]
eligible = [s for s in members if s["pred_mean"] is not None]
last_ranks = [s["ranks"][-1] if s["ranks"] else None for s in members]
holdouts = []
if len(exams) >= 3:
series = [s["z"] for s in members]
true_ranks = [s["ranks"][-1] if s["ranks"] else None for s in members]
last_train_ranks = [s["ranks"][-2] if len(s["ranks"]) >= 2 else None for s in members]
for t in g_targets:
k = min(int(t["k"]), max(1, sum(1 for r in true_ranks if r is not None)))
h = holdout_hits(
series,
last_train_ranks,
true_ranks,
model["q"],
model["r"],
k,
)
h["target_id"] = t["id"]
h["target_name"] = t["name"]
h["k"] = k
holdouts.append(h)
target_blocks = []
for t in g_targets:
k = int(t["k"])
k = max(1, min(k, len(eligible)))
means = [s["pred_mean"] for s in eligible]
variances = [s["pred_var"] for s in eligible]
class_ids = [s["class_name"] for s in eligible]
probs, class_q = monte_carlo(means, variances, class_ids, k, n_sim=n_sim)
for s, p in zip(eligible, probs):
s.setdefault("probs", {})[t["id"]] = p
# current on-line: last exam rank <= k
class_now = defaultdict(int)
class_n = defaultdict(int)
class_exp = defaultdict(float)
class_mingyuan_exp = defaultdict(float)
for s in members:
class_n[s["class_name"]] += 1
for s in eligible:
class_exp[s["class_name"]] += s["probs"][t["id"]]
if s.get("status") == "明远":
class_mingyuan_exp[s["class_name"]] += s["probs"][t["id"]]
if s["ranks"] and s["ranks"][-1] is not None and s["ranks"][-1] <= k:
class_now[s["class_name"]] += 1
class_names = sorted(class_n.keys(), key=class_sort_key)
expected = [class_exp[c] for c in class_names]
assigned = hamilton(expected, k)
rows = []
for c, a in zip(class_names, assigned):
qtl = class_q.get(c, {"p20": 0, "p50": 0, "p80": 0, "mean": 0})
rows.append(
{
"class_name": c,
"n": class_n[c],
"current": class_now[c],
"expected": round(class_exp[c], 2),
"assigned": a,
"floor": int(qtl["p20"]),
"median": int(qtl["p50"]),
"stretch": int(qtl["p80"]),
"delta": a - class_now[c],
"mingyuan_expected": round(class_mingyuan_exp[c], 2),
}
)
critical = []
for s in eligible:
p = s["probs"][t["id"]]
if 0.2 <= p <= 0.8:
critical.append(
{
"sid": s["sid"],
"name": s["name"],
"class_name": s["class_name"],
"p": round(p, 3),
"last_rank": s["ranks"][-1] if s["ranks"] else None,
"status": s.get("status") or "",
}
)
critical.sort(key=lambda x: -x["p"])
target_blocks.append(
{
"id": t["id"],
"name": t["name"],
"k": k,
"classes": rows,
"critical_n": len(critical),
"critical": critical[:80],
"sum_expected": round(sum(expected), 2),
}
)
results.append(
{
"group": g,
"n": len(members),
"model": {
"q": model["q"],
"r": model["r"],
"rmse": round(model["rmse"], 3) if model["rmse"] is not None else None,
"horizon": horizon,
"n_sim": n_sim,
},
"holdout": holdouts,
"targets": target_blocks,
}
)
student_views = []
for s in selected:
student_views.append(
{
"sid": s["sid"],
"name": s["name"],
"class_name": s["class_name"],
"group": s["group"],
"track": s["track"],
"subjects": s["subjects"],
"status": s.get("status") or "",
"move": s.get("move") or "",
"art": s["art"],
"leave": s["leave"],
"scores": s["scores"],
"z": s.get("z"),
"ranks": s.get("ranks"),
"kf_xs": s.get("kf_xs"),
"kf_ps": s.get("kf_ps"),
"pred_mean": s.get("pred_mean"),
"pred_var": s.get("pred_var"),
"probs": {k: round(v, 4) for k, v in (s.get("probs") or {}).items()},
}
)
return {
"exams": exams,
"horizon": horizon,
"n_selected": len(selected),
"n_total": len(parsed["students"]),
"groups": results,
"students": student_views,
}
def class_comment(row: dict, target_name: str) -> str:
assigned = row["assigned"]
current = row["current"]
expected = row["expected"]
floor = row["floor"]
stretch = row["stretch"]
name = row["class_name"]
bits = [
f"{name}班{target_name}下达 {assigned} 人(滤波期望 {expected:.1f})。"
]
if assigned > current:
bits.append(f"比最近一次在线人数多 {assigned - current} 人,班级里有上升轨迹的临界生,需要把潜力兑现。")
elif assigned < current:
bits.append(f"比最近一次在线人数少 {current - assigned} 人,说明部分当前在线学生轨迹不稳,重点是保住临界生。")
else:
bits.append("与最近一次在线人数持平,按现有轨迹属于维持性目标。")
bits.append(f"模拟分布大约在 {floor}–{stretch} 人之间,超越下达人数才算跑赢预估。")
return "".join(bits)

205
app/kalman.py Normal file
View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import math
import random
from dataclasses import dataclass
def mean_std(values: list[float]) -> tuple[float, float]:
n = len(values)
if n == 0:
return 0.0, 1.0
mean = sum(values) / n
if n == 1:
return mean, 1.0
var = sum((x - mean) ** 2 for x in values) / (n - 1)
return mean, math.sqrt(var) if var > 0 else 1.0
def zscores(values: list[float | None]) -> list[float | None]:
present = [v for v in values if v is not None]
mean, std = mean_std(present)
out: list[float | None] = []
for v in values:
if v is None:
out.append(None)
else:
out.append((v - mean) / std)
return out
def ranks_desc(values: list[float | None]) -> list[int | None]:
indexed = [(i, v) for i, v in enumerate(values) if v is not None]
indexed.sort(key=lambda iv: -iv[1])
out: list[int | None] = [None] * len(values)
for rank, (i, _) in enumerate(indexed, 1):
out[i] = rank
return out
@dataclass
class FilterResult:
x: float | None
p: float | None
xs: list[float | None]
ps: list[float | None]
def kalman_filter(zs: list[float | None], q: float, r: float) -> FilterResult:
x = None
p = None
xs: list[float | None] = []
ps: list[float | None] = []
for z in zs:
if x is None:
if z is None:
xs.append(None)
ps.append(None)
continue
x, p = z, r
xs.append(x)
ps.append(p)
continue
x_pred = x
p_pred = p + q
if z is None:
x, p = x_pred, p_pred
xs.append(x)
ps.append(p)
continue
k = p_pred / (p_pred + r)
x = x_pred + k * (z - x_pred)
p = (1 - k) * p_pred
xs.append(x)
ps.append(p)
return FilterResult(x=x, p=p, xs=xs, ps=ps)
def one_step_rmse(series_list: list[list[float | None]], q: float, r: float) -> float | None:
se = 0.0
n = 0
for zs in series_list:
x = None
p = None
for z in zs:
if x is None:
if z is None:
continue
x, p = z, r
continue
x_pred = x
p_pred = p + q
if z is None:
x, p = x_pred, p_pred
continue
se += (x_pred - z) ** 2
n += 1
k = p_pred / (p_pred + r)
x = x_pred + k * (z - x_pred)
p = (1 - k) * p_pred
if n == 0:
return None
return math.sqrt(se / n)
def fit_qr(series_list: list[list[float | None]]) -> tuple[float, float, float]:
usable = [s for s in series_list if sum(1 for z in s if z is not None) >= 2]
best = (1e9, 0.08, 0.25)
for q in (0.03, 0.05, 0.08, 0.10, 0.15, 0.22, 0.30):
for r in (0.12, 0.18, 0.25, 0.32, 0.40, 0.55):
rmse = one_step_rmse(usable, q, r)
if rmse is not None and rmse < best[0]:
best = (rmse, q, r)
return best[1], best[2], best[0]
def holdout_hits(
series_list: list[list[float | None]],
last_ranks: list[int | None],
true_ranks: list[int | None],
q: float,
r: float,
target_k: int,
) -> dict:
preds: list[tuple[int, float]] = []
last_pred: list[tuple[int, int]] = []
for i, zs in enumerate(series_list):
if true_ranks[i] is None:
continue
train = zs[:-1]
result = kalman_filter(train, q, r)
if result.x is None:
continue
preds.append((i, result.x))
if last_ranks[i] is not None:
last_pred.append((i, last_ranks[i]))
preds.sort(key=lambda t: -t[1])
kf_set = {i for i, _ in preds[:target_k]}
last_pred.sort(key=lambda t: t[1])
last_set = {i for i, _ in last_pred[:target_k]}
actual = {i for i, rk in enumerate(true_ranks) if rk is not None and rk <= target_k}
kf_hit = len(kf_set & actual)
last_hit = len(last_set & actual)
return {
"n_actual": len(actual),
"kf_hit": kf_hit,
"last_hit": last_hit,
"kf_precision": kf_hit / target_k if target_k else 0,
"last_precision": last_hit / target_k if target_k else 0,
}
def monte_carlo(
means: list[float],
variances: list[float],
class_ids: list[str],
target_k: int,
n_sim: int = 600,
seed: int = 42,
) -> tuple[list[float], dict[str, dict]]:
rng = random.Random(seed)
n = len(means)
hits = [0] * n
sigmas = [math.sqrt(max(v, 1e-8)) for v in variances]
unique = list(dict.fromkeys(class_ids))
buckets = {c: [] for c in unique}
for _ in range(n_sim):
zs = [means[i] + sigmas[i] * rng.gauss(0, 1) for i in range(n)]
order = sorted(range(n), key=lambda i: zs[i], reverse=True)
counts = {c: 0 for c in unique}
for i in order[:target_k]:
hits[i] += 1
counts[class_ids[i]] += 1
for c, v in counts.items():
buckets[c].append(v)
out = {}
for c, arr in buckets.items():
arr.sort()
m = n_sim
out[c] = {
"p20": arr[max(0, int(0.20 * m) - 1)],
"p50": arr[max(0, int(0.50 * m) - 1)],
"p80": arr[max(0, int(0.80 * m) - 1)],
"mean": sum(arr) / m,
}
return [h / n_sim for h in hits], out
def hamilton(values: list[float], total: int) -> list[int]:
if total <= 0 or not values:
return [0] * len(values)
s = sum(values)
if s <= 0:
out = [0] * len(values)
return out
scaled = [v * total / s for v in values]
floors = [int(math.floor(v + 1e-9)) for v in scaled]
remain = total - sum(floors)
order = sorted(
range(len(scaled)),
key=lambda i: (scaled[i] - floors[i], scaled[i]),
reverse=True,
)
for i in order[: max(0, remain)]:
floors[i] += 1
return floors

283
app/main.py Normal file
View File

@@ -0,0 +1,283 @@
from __future__ import annotations
import json
import shutil
import uuid
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from .config import (
ADMIN_PASSWORD,
ADMIN_USER,
DATA_DIR,
MAX_UPLOAD_MB,
SAMPLE_XLSX,
SECRET_KEY,
UPLOAD_DIR,
)
from .db import (
create_analysis,
get_analysis,
init_db,
list_analyses,
load_json,
save_json,
update_analysis,
)
from .excel_parse import parse_workbook, summarize_parse
from .export_xlsx import build_export
from .indicators import analyze, class_comment, default_targets, infer_track_group, select_students
app = FastAPI(title="航标 · 班级达线指标预估")
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, max_age=60 * 60 * 12)
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
templates.env.globals["class_comment"] = class_comment
def render(request: Request, name: str, **ctx):
ctx["request"] = request
ctx.setdefault("user", current_user(request))
return templates.TemplateResponse(request, name, ctx)
@app.on_event("startup")
def startup():
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
init_db()
def current_user(request: Request) -> str | None:
return request.session.get("user")
def require_login(request: Request):
if not current_user(request):
return RedirectResponse("/login", status_code=303)
return None
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
if current_user(request):
return RedirectResponse("/", status_code=303)
return render(request, "login.html", error=None)
@app.post("/login", response_class=HTMLResponse)
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
if username.strip() == ADMIN_USER and password == ADMIN_PASSWORD:
request.session["user"] = username.strip()
return RedirectResponse("/", status_code=303)
return render(request, "login.html", error="账号或密码不正确")
@app.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
@app.get("/", response_class=HTMLResponse)
def home(request: Request):
if (redir := require_login(request)):
return redir
items = list_analyses()
return render(request, "home.html", items=items, has_sample=SAMPLE_XLSX.exists())
def _store_parsed(title: str, filename: str, source: Path) -> int:
parsed = parse_workbook(source)
summary = summarize_parse(parsed)
token = uuid.uuid4().hex[:10]
parsed_path = str(DATA_DIR / f"parsed_{token}.json")
# keep a lighter parsed blob
save_json(parsed_path, parsed)
aid = create_analysis(title, filename, parsed_path)
update_analysis(aid, settings_json=json.dumps({"summary": summary}, ensure_ascii=False))
return aid
@app.post("/upload")
async def upload(request: Request, file: UploadFile = File(...)):
if (redir := require_login(request)):
return redir
raw = await file.read()
if len(raw) > MAX_UPLOAD_MB * 1024 * 1024:
return HTMLResponse("文件过大,请压缩到 15MB 以内。", status_code=400)
suffix = Path(file.filename or "scores.xlsx").suffix.lower()
if suffix not in (".xlsx", ".xlsm"):
return HTMLResponse("请上传 .xlsx 成绩表。", status_code=400)
dest = UPLOAD_DIR / f"{uuid.uuid4().hex}{suffix}"
dest.write_bytes(raw)
aid = _store_parsed(Path(file.filename).stem, file.filename or dest.name, dest)
return RedirectResponse(f"/analysis/{aid}?step=1", status_code=303)
@app.post("/demo")
def demo(request: Request):
if (redir := require_login(request)):
return redir
if not SAMPLE_XLSX.exists():
return HTMLResponse("未找到示例成绩表。", status_code=404)
dest = UPLOAD_DIR / f"{uuid.uuid4().hex}.xlsx"
shutil.copy2(SAMPLE_XLSX, dest)
aid = _store_parsed("2024级示例数据", SAMPLE_XLSX.name, dest)
return RedirectResponse(f"/analysis/{aid}?step=1", status_code=303)
def _settings_from(parsed: dict, stored: dict | None) -> dict:
summary = summarize_parse(parsed)
groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]})
base = {
"exams": parsed["exams"],
"include_art": False,
"include_leave": False,
"include_mingyuan": True,
"include_other": True,
"horizon": 1,
"targets": default_targets(groups),
}
if stored:
for k in ("exams", "include_art", "include_leave", "include_mingyuan", "include_other", "horizon", "targets"):
if k in stored:
base[k] = stored[k]
base["summary"] = summary
base["groups"] = groups
return base
@app.get("/analysis/{aid}", response_class=HTMLResponse)
def analysis_page(request: Request, aid: int, step: int = 1):
if (redir := require_login(request)):
return redir
item = get_analysis(aid)
if not item:
return HTMLResponse("分析不存在", status_code=404)
parsed = load_json(item["parsed_path"])
stored_settings = json.loads(item["settings_json"] or "{}")
settings = _settings_from(parsed, stored_settings)
result = load_json(item["result_path"]) if item.get("result_path") else None
preview_n = None
if step == 2:
preview_n = len(select_students(parsed, settings))
return render(
request,
"analysis.html",
item=item,
step=step,
settings=settings,
summary=settings["summary"],
result=result,
preview_n=preview_n,
)
@app.post("/analysis/{aid}/settings")
async def save_settings(request: Request, aid: int):
if (redir := require_login(request)):
return redir
item = get_analysis(aid)
if not item:
return HTMLResponse("分析不存在", status_code=404)
parsed = load_json(item["parsed_path"])
form = await request.form()
exams = form.getlist("exams")
if not exams:
exams = parsed["exams"]
groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]})
targets = {}
for g in groups:
names = form.getlist(f"t_name_{g}")
ks = form.getlist(f"t_k_{g}")
ids = form.getlist(f"t_id_{g}")
rows = []
for i, name in enumerate(names):
k_raw = ks[i] if i < len(ks) else "0"
try:
k = int(float(k_raw))
except ValueError:
k = 0
tid = ids[i] if i < len(ids) and ids[i] else f"t{i+1}"
if name.strip() and k > 0:
rows.append({"id": tid, "name": name.strip(), "k": k})
targets[g] = rows or default_targets([g])[g]
settings = {
"exams": list(exams),
"include_art": form.get("include_art") == "on",
"include_leave": form.get("include_leave") == "on",
"include_mingyuan": form.get("include_mingyuan") == "on",
"include_other": form.get("include_other") == "on",
"horizon": int(form.get("horizon") or 1),
"targets": targets,
}
update_analysis(aid, settings_json=json.dumps(settings, ensure_ascii=False), step=2)
action = form.get("action") or "next"
if action == "compute":
result = analyze(parsed, settings)
token = uuid.uuid4().hex[:10]
result_path = str(DATA_DIR / f"result_{token}.json")
save_json(result_path, result)
update_analysis(aid, result_path=result_path, step=3)
return RedirectResponse(f"/analysis/{aid}?step=3", status_code=303)
return RedirectResponse(f"/analysis/{aid}?step=2", status_code=303)
@app.get("/analysis/{aid}/api/student/{sid}")
def student_api(request: Request, aid: int, sid: str):
if not current_user(request):
return Response("unauthorized", status_code=401)
item = get_analysis(aid)
if not item or not item.get("result_path"):
return Response("not found", status_code=404)
result = load_json(item["result_path"])
for s in result["students"]:
if str(s["sid"]) == str(sid):
return s
return Response("not found", status_code=404)
@app.get("/analysis/{aid}/api/class")
def class_api(request: Request, aid: int, group: str, target: str, class_name: str):
if not current_user(request):
return Response("unauthorized", status_code=401)
item = get_analysis(aid)
if not item or not item.get("result_path"):
return Response("not found", status_code=404)
result = load_json(item["result_path"])
rows = [
s
for s in result["students"]
if s["group"] == group and s["class_name"] == class_name
]
rows.sort(key=lambda s: -((s.get("probs") or {}).get(target) or 0))
return {"exams": result["exams"], "students": rows[:80], "n": len(rows)}
@app.get("/analysis/{aid}/export")
def export_analysis(request: Request, aid: int):
if (redir := require_login(request)):
return redir
item = get_analysis(aid)
if not item or not item.get("result_path"):
return HTMLResponse("请先完成测算", status_code=400)
result = load_json(item["result_path"])
data = build_export(result)
ascii_name = f"hangbiao-{aid}.xlsx"
star = quote(f"{item['title']}-下达指标.xlsx")
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={ascii_name}; filename*=UTF-8''{star}"},
)
# Jinja 3 / FastAPI 0.115+ TemplateResponse signature is (request, name, context)
# Keep compatibility if older: handled by FastAPI version we pin.

149
app/static/css/app.css Normal file
View File

@@ -0,0 +1,149 @@
:root {
--ink: #12233d;
--ink-soft: #3a4a63;
--paper: #f3eee4;
--card: #fffdf8;
--line: #d8cfbf;
--teal: #1c6f63;
--teal-2: #2a9a86;
--amber: #c47b17;
--rose: #b44a4a;
--good: #2f7d4a;
--shadow: 0 18px 40px rgba(18, 35, 61, 0.08);
--font: "Avenir Next", "Segoe UI", "PingFang SC", "Hiragino Sans GB", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font);
color: var(--ink);
background:
radial-gradient(1200px 500px at -10% -20%, #e7f3ee 0%, transparent 50%),
radial-gradient(900px 400px at 110% 0%, #f8e9d2 0%, transparent 46%),
var(--paper);
min-height: 100vh;
}
a { color: var(--teal); }
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 28px;
border-bottom: 1px solid var(--line);
background: rgba(255,253,248,0.86);
backdrop-filter: blur(10px);
position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; gap: 12px; align-items: center; text-decoration: none; color: var(--ink); }
.mark {
width: 36px; height: 36px; border-radius: 50%;
border: 2px solid var(--ink);
display: grid; place-items: center;
font-size: 11px; letter-spacing: 0.08em;
}
.brand b { display: block; font-size: 16px; }
.brand span { display: block; font-size: 12px; color: var(--ink-soft); font-weight: 400; }
.topbar form { margin: 0; }
.ghost, .btn, .primary {
border: 1px solid var(--ink); background: transparent; color: var(--ink);
padding: 8px 14px; border-radius: 999px; cursor: pointer; font: inherit;
text-decoration: none; display: inline-flex; align-items: center; gap: 8px;
}
.primary { background: var(--ink); color: var(--paper); }
.btn.teal { background: var(--teal); border-color: var(--teal); color: white; }
.wrap { width: min(1180px, calc(100% - 40px)); margin: 28px auto 64px; }
.hero, .card, .login-card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: var(--shadow);
}
.hero { padding: 28px; display: grid; grid-template-columns: 1.4fr 1fr; gap: 24px; }
.hero h1 { margin: 0 0 8px; font-size: 30px; letter-spacing: 0.02em; }
.hero p { margin: 0; color: var(--ink-soft); line-height: 1.7; }
.drop {
border: 1.5px dashed var(--teal);
border-radius: 18px; padding: 22px; background: #f4fbf8;
}
.drop h3 { margin: 0 0 8px; }
.muted { color: var(--ink-soft); font-size: 13px; line-height: 1.6; }
.list { margin-top: 22px; display: grid; gap: 10px; }
.item {
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
padding: 14px 16px; display: flex; justify-content: space-between; align-items: center;
}
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.login-card { width: min(440px, 100%); padding: 32px; }
.login-card h1 { margin: 8px 0 4px; }
label { display: block; font-size: 13px; margin: 14px 0 6px; color: var(--ink-soft); }
input[type=text], input[type=password], input[type=number], select {
width: 100%; padding: 10px 12px; border-radius: 10px;
border: 1px solid var(--line); font: inherit; background: white;
}
.err { color: var(--rose); font-size: 13px; margin-top: 8px; }
.layout { display: grid; grid-template-columns: 250px 1fr; gap: 22px; }
.rail {
background: var(--ink); color: #e8e2d6; border-radius: 22px; padding: 20px 16px;
height: fit-content; position: sticky; top: 88px;
}
.rail h2 { font-size: 14px; margin: 0 0 16px; font-weight: 600; }
.step {
display: grid; grid-template-columns: 28px 1fr; gap: 10px; padding: 10px 8px;
border-radius: 12px; color: #c9c1b3; text-decoration: none;
}
.step b { display: block; color: inherit; font-size: 14px; }
.step span { display: block; font-size: 12px; opacity: 0.8; }
.dot {
width: 28px; height: 28px; border-radius: 50%; border: 1px solid #7d8796;
display: grid; place-items: center; font-size: 12px;
}
.step.on { background: rgba(255,255,255,0.06); color: white; }
.step.on .dot { background: var(--teal-2); border-color: var(--teal-2); color: white; }
.panel { padding: 24px 26px 30px; }
.panel h1 { margin: 0 0 6px; font-size: 26px; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 18px 0; }
.kpi { border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; background: #fbfaf6; }
.kpi em { display: block; font-style: normal; font-size: 12px; color: var(--ink-soft); }
.kpi strong { font-size: 22px; }
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th { text-align: left; font-size: 12px; color: var(--ink-soft); font-weight: 600; padding: 8px 6px; border-bottom: 1px solid var(--line); }
.table td { padding: 9px 6px; border-bottom: 1px solid #eee6d8; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 4px; }
.chip { background: #efe8da; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
.warn { background: #fff4e6; border: 1px solid #f1d2a3; color: #8a5a12; padding: 10px 12px; border-radius: 12px; margin: 8px 0; font-size: 13px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.check { display: flex; gap: 8px; align-items: flex-start; margin: 8px 0; font-size: 14px; }
.check input { margin-top: 3px; }
.actions { display: flex; gap: 10px; margin-top: 22px; }
.explain {
background: linear-gradient(180deg, #f7fbf9, #fff);
border: 1px solid #cfe3dc; border-radius: 16px; padding: 16px 18px; line-height: 1.7;
}
.route { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0 4px; }
.route i {
font-style: normal; background: var(--ink); color: var(--paper);
border-radius: 999px; padding: 6px 10px; font-size: 12px;
}
.route i.alt { background: var(--teal); }
.bar-wrap { height: 10px; background: #efe8da; border-radius: 99px; overflow: hidden; }
.bar { height: 100%; background: var(--teal); }
.delta-up { color: var(--good); }
.delta-down { color: var(--rose); }
.tag { font-size: 11px; padding: 2px 7px; border-radius: 99px; background: #efe8da; }
.tag.mid { background: #f8e3bf; color: #8a5a12; }
.tag.high { background: #d8f0e5; color: #1c6f63; }
.drawer-bg {
position: fixed; inset: 0; background: rgba(18,35,61,0.35); display: none;
align-items: stretch; justify-content: flex-end; z-index: 20;
}
.drawer-bg.show { display: flex; }
.drawer {
width: min(560px, 100%); background: var(--card); padding: 22px;
overflow: auto; box-shadow: -20px 0 40px rgba(18,35,61,0.15);
}
.spark { width: 100%; height: 220px; }
.tiny { font-size: 12px; color: var(--ink-soft); }
.clickable { cursor: pointer; }
.clickable:hover { background: #f7f3ea; }
@media (max-width: 900px) {
.hero, .layout, .grid-2, .kpis { grid-template-columns: 1fr; }
.rail { position: static; }
}

112
app/static/js/app.js Normal file
View File

@@ -0,0 +1,112 @@
function closeDrawer() {
const el = document.getElementById("drawer");
if (el) el.classList.remove("show");
}
function sparkline(svg, exams, zs, xs, lastRank) {
const w = 520, h = 220, pad = 28;
const vals = [];
zs.forEach((z, i) => { if (z !== null && z !== undefined) vals.push(z); });
xs.forEach((z) => { if (z !== null && z !== undefined) vals.push(z); });
if (!vals.length) return;
const min = Math.min(...vals, -0.5) - 0.3;
const max = Math.max(...vals, 0.5) + 0.3;
const xAt = (i) => pad + (i * (w - pad * 2)) / Math.max(exams.length - 1, 1);
const yAt = (v) => pad + (max - v) * (h - pad * 2) / (max - min);
const pts = (arr) => arr.map((v, i) => v == null ? null : `${xAt(i)},${yAt(v)}`).filter(Boolean).join(" ");
let raw = "";
zs.forEach((v, i) => {
if (v == null) return;
raw += `<circle cx="${xAt(i)}" cy="${yAt(v)}" r="4.5" fill="#c47b17"></circle>`;
});
const pathXs = xs.map((v, i) => (v == null ? null : [xAt(i), yAt(v)])).filter(Boolean);
let d = "";
pathXs.forEach((p, i) => { d += (i === 0 ? `M${p[0]} ${p[1]}` : ` L${p[0]} ${p[1]}`); });
const labels = exams.map((e, i) => {
const short = String(e).replace("-总分", "").replace("高", "");
return `<text x="${xAt(i)}" y="${h - 6}" text-anchor="middle" font-size="10" fill="#3a4a63">${short}</text>`;
}).join("");
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
svg.innerHTML = `
<line x1="${pad}" y1="${yAt(0)}" x2="${w - pad}" y2="${yAt(0)}" stroke="#d8cfbf" stroke-dasharray="4 4"></line>
<path d="${d}" fill="none" stroke="#1c6f63" stroke-width="2.4"></path>
${raw}
${labels}
<text x="${pad}" y="16" font-size="11" fill="#3a4a63">标准分轨迹 · 橙点考试,绿线滤波估计</text>
`;
}
async function openStudent(aid, sid) {
const res = await fetch(`/analysis/${aid}/api/student/${encodeURIComponent(sid)}`);
if (!res.ok) return;
const s = await res.json();
const box = document.getElementById("drawer");
const body = document.getElementById("drawer-body");
const probs = Object.entries(s.probs || {}).map(([k, v]) => `${k}: ${(v * 100).toFixed(1)}%`).join(" ");
body.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:start">
<div>
<h2 style="margin:0">${s.name} <span class="tiny">${s.sid}</span></h2>
<div class="tiny">${s.class_name}班 · ${s.group} · ${s.status || "在籍"} · ${s.subjects || ""}</div>
</div>
<button class="ghost" onclick="closeDrawer()">关闭</button>
</div>
<p class="explain" style="margin-top:14px">
绿线是卡尔曼估计的稳定水平。如果橙点突然跳开绿线,系统会当成发挥波动,不会立刻改判断。
达线概率 ${probs || "—"}。
</p>
<svg class="spark" id="spark"></svg>
<table class="table">
<thead><tr><th>考试</th><th>分数</th><th>方向内名次</th><th>标准分</th><th>滤波</th></tr></thead>
<tbody id="spark-rows"></tbody>
</table>
`;
const rows = document.getElementById("spark-rows");
(s.z || []).forEach((z, i) => {
const exam = (window.HB_EXAMS || [])[i] || `第${i + 1}次`;
const score = s.scores ? s.scores[exam] : "";
const rank = (s.ranks || [])[i];
const kf = (s.kf_xs || [])[i];
rows.insertAdjacentHTML("beforeend", `<tr>
<td>${exam}</td><td>${score ?? "缺考"}</td><td>${rank ?? "—"}</td>
<td>${z == null ? "—" : z.toFixed(2)}</td>
<td>${kf == null ? "—" : kf.toFixed(2)}</td>
</tr>`);
});
sparkline(document.getElementById("spark"), window.HB_EXAMS || [], s.z || [], s.kf_xs || []);
box.classList.add("show");
}
async function openClass(aid, group, target, className) {
const url = `/analysis/${aid}/api/class?group=${encodeURIComponent(group)}&target=${encodeURIComponent(target)}&class_name=${encodeURIComponent(className)}`;
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const box = document.getElementById("drawer");
const body = document.getElementById("drawer-body");
const rows = data.students.map((s) => {
const p = ((s.probs || {})[target] || 0) * 100;
const tag = p >= 80 ? "high" : p >= 20 ? "mid" : "";
return `<tr class="clickable" onclick="openStudent(${aid}, '${s.sid}')">
<td>${s.name}</td><td>${s.status || ""}</td>
<td>${(s.ranks || []).slice(-1)[0] ?? "—"}</td>
<td><span class="tag ${tag}">${p.toFixed(1)}%</span></td>
</tr>`;
}).join("");
body.innerHTML = `
<div style="display:flex;justify-content:space-between">
<h2 style="margin:0">${className}班学生概率</h2>
<button class="ghost" onclick="closeDrawer()">关闭</button>
</div>
<p class="tiny">点击学生可看成绩轨迹。共 ${data.n} 人,下列按达线概率排序。</p>
<table class="table">
<thead><tr><th>姓名</th><th>学籍</th><th>最近名次</th><th>达线概率</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
box.classList.add("show");
}
document.addEventListener("click", (e) => {
if (e.target.id === "drawer") closeDrawer();
});

263
app/templates/analysis.html Normal file
View File

@@ -0,0 +1,263 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ item.title }} · 航标</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<div class="mark">航</div>
<div><b>航标</b><span>{{ item.title }}</span></div>
</a>
<div style="display:flex;gap:8px">
{% if result %}
<a class="ghost" href="/analysis/{{ item.id }}/export">导出 Excel</a>
{% endif %}
<form action="/logout" method="post"><button class="ghost" type="submit">退出</button></form>
</div>
</header>
<main class="wrap layout">
<aside class="rail">
<h2>分析航线</h2>
{% set steps = [
(1, '数据体检', '看清表格里有谁、有哪些考试'),
(2, '口径与目标', '决定算谁、瞄哪条线'),
(3, '滤波与回测', '理解预估为什么可信'),
(4, '下达指标', '班级目标、临界生、轨迹')
] %}
{% for n, title, desc in steps %}
<a class="step {% if step == n %}on{% endif %}" href="/analysis/{{ item.id }}?step={{ n }}">
<div class="dot">{{ n }}</div>
<div><b>{{ title }}</b><span>{{ desc }}</span></div>
</a>
{% endfor %}
</aside>
<section class="card panel">
{% if step == 1 %}
<h1>第一步:成绩表体检</h1>
<p class="muted">先确认系统读到的是你想算的那批人。分数量纲可以不同,系统稍后会在每次考试内部标准化。</p>
<div class="kpis">
<div class="kpi"><em>识别学生</em><strong>{{ summary.n }}</strong></div>
<div class="kpi"><em>班级</em><strong>{{ summary.n_class }}</strong></div>
<div class="kpi"><em>考试次数</em><strong>{{ summary.exams|length }}</strong></div>
<div class="kpi"><em>艺体 / 请假类</em><strong>{{ summary.art }} / {{ summary.leave }}</strong></div>
</div>
{% for w in summary.warnings %}
<div class="warn">{{ w }}</div>
{% endfor %}
<div class="grid-2">
<div>
<h3>工作表</h3>
<table class="table">
<thead><tr><th>表名</th><th>类型</th><th>行数</th></tr></thead>
<tbody>
{% for sh in summary.sheets %}
<tr><td>{{ sh.name }}</td><td>{{ sh.kind }}</td><td>{{ sh.rows }}</td></tr>
{% endfor %}
</tbody>
</table>
<h3>方向 / 学籍</h3>
<div class="chips">
{% for k,v in summary.tracks.items() %}<span class="chip">{{ k or "未分方向" }} {{ v }}</span>{% endfor %}
</div>
<div class="chips">
{% for k,v in summary.status.items() %}<span class="chip">{{ k }} {{ v }}</span>{% endfor %}
</div>
</div>
<div>
<h3>考试识别与分数尺度</h3>
<table class="table">
<thead><tr><th>考试</th><th>有效人数</th><th>最低</th><th>最高</th><th>平均</th></tr></thead>
<tbody>
{% for e in summary.score_scale %}
<tr>
<td>{{ e.exam }}</td><td>{{ e.n }}</td>
<td>{{ e.min }}</td><td>{{ e.max }}</td><td>{{ e.mean }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="tiny">平均分从高一到高三明显下降是正常的(试卷结构不同),所以不能直接比原始分,只能比同次考试里的相对位置。</p>
</div>
</div>
<h3>各班人数</h3>
<div class="chips">
{% for k,v in summary.classes.items() %}<span class="chip">{{ k }}班 {{ v }}人</span>{% endfor %}
</div>
<div class="actions">
<a class="primary" href="/analysis/{{ item.id }}?step=2">口径看起来对,下一步</a>
</div>
{% endif %}
{% if step == 2 %}
<h1>第二步:口径与目标线</h1>
<p class="muted">下达指标前先把“算谁、瞄哪条线”钉死。默认排除艺体和长期请假,明远生可参与年级排名。</p>
<form method="post" action="/analysis/{{ item.id }}/settings" onsubmit="if(document.activeElement && document.activeElement.value==='compute'){this.querySelector('[value=compute]').textContent='正在滤波测算…'; this.querySelector('[value=compute]').disabled=false;}">
<h3>用于滤波的考试</h3>
<p class="tiny">建议全选。系统会自动让近考权重大、早考权重小;不是把五次考试简单平均。</p>
{% for e in summary.exams %}
<label class="check"><input type="checkbox" name="exams" value="{{ e }}" {% if e in settings.exams %}checked{% endif %}> {{ e }}(有效 {{ summary.exam_present[e] }} 人)</label>
{% endfor %}
<div class="grid-2" style="margin-top:12px">
<div>
<h3>纳入测算</h3>
<label class="check"><input type="checkbox" name="include_mingyuan" {% if settings.include_mingyuan %}checked{% endif %}> 包含明远 / 港籍学籍</label>
<label class="check"><input type="checkbox" name="include_other" {% if settings.include_other %}checked{% endif %}> 包含其他学籍</label>
<label class="check"><input type="checkbox" name="include_art" {% if settings.include_art %}checked{% endif %}> 包含艺体生</label>
<label class="check"><input type="checkbox" name="include_leave" {% if settings.include_leave %}checked{% endif %}> 包含请假 / 出国 / 休学备注</label>
{% if preview_n is not none %}<p class="tiny">按当前口径约 {{ preview_n }} 人进入测算。</p>{% endif %}
</div>
<div>
<h3>预估多远</h3>
<label>预测步长</label>
<select name="horizon">
<option value="1" {% if settings.horizon == 1 %}selected{% endif %}>下一次大考(1 步)</option>
<option value="2" {% if settings.horizon == 2 %}selected{% endif %}>大约高考前再考 2 次的距离</option>
</select>
<p class="tiny">步长越远,不确定性越大,临界生会变多,下达人数仍按期望取整到目标总量。</p>
</div>
</div>
<h3>各方向目标人数</h3>
<p class="tiny">这是年级总盘子。班级指标由个人达线概率加总后再取整,合计等于这里的人数。</p>
{% for g in settings.groups %}
<div class="explain" style="margin-bottom:12px">
<b>{{ g }}</b>
{% for t in settings.targets[g] %}
<div class="grid-2" style="margin-top:8px">
<input type="hidden" name="t_id_{{ g }}" value="{{ t.id }}">
<div><label>指标名称</label><input name="t_name_{{ g }}" value="{{ t.name }}"></div>
<div><label>年级总人数</label><input name="t_k_{{ g }}" type="number" min="1" value="{{ t.k }}"></div>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="actions">
<button class="ghost" name="action" value="save">只保存口径</button>
<button class="primary" name="action" value="compute">开始滤波测算</button>
</div>
</form>
{% endif %}
{% if step == 3 %}
<h1>第三步:滤波在做什么</h1>
{% if not result %}
<p class="muted">还没有测算结果。请回到第二步点击「开始滤波测算」。</p>
<a class="primary" href="/analysis/{{ item.id }}?step=2">去设置口径</a>
{% else %}
<div class="route">
<i>原始分</i><i class="alt">同次标准化</i><i>卡尔曼估计水平</i><i class="alt">模拟下一次排名</i><i>个人概率</i><i class="alt">班级加总取整</i>
</div>
<p class="explain">
把每次考试看成一次带噪声的定位:发挥好坏是测量噪声 R,真实进步是过程噪声 Q。
R 相对大时,系统不会只信最近一次;Q 相对大时,会更快跟上近期趋势。这就是导航里“既看 GPS,也看车自己开的方向”。
</p>
{% for g in result.groups %}
<h3>{{ g.group }} · 模型参数</h3>
<div class="kpis">
<div class="kpi"><em>过程噪声 Q(真实水平会变)</em><strong>{{ g.model.q }}</strong></div>
<div class="kpi"><em>测量噪声 R(发挥波动)</em><strong>{{ g.model.r }}</strong></div>
<div class="kpi"><em>一步预测 RMSE</em><strong>{{ g.model.rmse }}</strong></div>
<div class="kpi"><em>测算人数 / 模拟次数</em><strong>{{ g.n }} / {{ g.model.n_sim }}</strong></div>
</div>
{% if g.holdout %}
<p class="tiny">留出最近一次考试做对照:用更早的成绩预估最近一次,看滤波是否比“只看上一场名次”更准。</p>
<table class="table">
<thead><tr><th>目标</th><th>K</th><th>滤波命中</th><th>只看最近一次</th></tr></thead>
<tbody>
{% for h in g.holdout %}
<tr>
<td>{{ h.target_name }}</td>
<td>{{ h.k }}</td>
<td>{{ h.kf_hit }} / {{ h.k }}({{ '%.0f'|format(h.kf_precision * 100) }}%)</td>
<td>{{ h.last_hit }} / {{ h.k }}({{ '%.0f'|format(h.last_precision * 100) }}%)</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endfor %}
<div class="actions">
<a class="primary" href="/analysis/{{ item.id }}?step=4">看各班下达指标</a>
</div>
{% endif %}
{% endif %}
{% if step == 4 %}
<h1>第四步:下达指标</h1>
{% if not result %}
<p class="muted">还没有测算结果。请先完成第二步测算。</p>
{% else %}
<p class="explain">
「下达指标」是滤波期望取整后的人数,含义是:若学生按现有轨迹走,这个班本来应当到线的人数。
班会目标是超越它,而不是刚好打平。保底 / 冲刺是模拟分布的大约 20% / 80% 分位,用来看把握而不是另下两套考核。
</p>
{% for g in result.groups %}
{% for t in g.targets %}
<h3>{{ g.group }} · {{ t.name }}(年级 {{ t.k }} 人)</h3>
<p class="tiny">期望合计 {{ t.sum_expected }},临界生(概率 20%–80%){{ t.critical_n }} 人。点击班级看学生概率,点击学生看轨迹。</p>
<table class="table">
<thead>
<tr>
<th>班级</th><th>测算人数</th><th>最近在线</th><th>滤波期望</th>
<th>下达指标</th><th>保底</th><th>冲刺</th><th>相对最近</th>
</tr>
</thead>
<tbody>
{% for row in t.classes %}
<tr class="clickable" onclick="openClass({{ item.id }}, '{{ g.group }}', '{{ t.id }}', '{{ row.class_name }}')">
<td>{{ row.class_name }}班</td>
<td>{{ row.n }}</td>
<td>{{ row.current }}</td>
<td>{{ '%.2f'|format(row.expected) }}</td>
<td><b>{{ row.assigned }}</b></td>
<td>{{ row.floor }}</td>
<td>{{ row.stretch }}</td>
<td class="{% if row.delta > 0 %}delta-up{% elif row.delta < 0 %}delta-down{% endif %}">
{% if row.delta > 0 %}+{{ row.delta }}{% else %}{{ row.delta }}{% endif %}
</td>
</tr>
<tr>
<td colspan="8" class="tiny">{{ class_comment(row, t.name) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if t.critical %}
<h4>临界生(优先关注)</h4>
<table class="table">
<thead><tr><th>姓名</th><th>班</th><th>最近名次</th><th>达线概率</th></tr></thead>
<tbody>
{% for s in t.critical[:15] %}
<tr class="clickable" onclick="openStudent({{ item.id }}, '{{ s.sid }}')">
<td>{{ s.name }}</td><td>{{ s.class_name }}</td>
<td>{{ s.last_rank or "—" }}</td>
<td><span class="tag mid">{{ '%.1f'|format(s.p * 100) }}%</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endfor %}
{% endfor %}
<div class="actions">
<a class="primary" href="/analysis/{{ item.id }}/export">导出下达表</a>
<a class="ghost" href="/analysis/{{ item.id }}?step=2">调整口径再算</a>
</div>
{% endif %}
{% endif %}
</section>
</main>
<div class="drawer-bg" id="drawer">
<div class="drawer" id="drawer-body"></div>
</div>
<script>
window.HB_EXAMS = {{ (result.exams if result else summary.exams) | tojson }};
</script>
<script src="/static/js/app.js"></script>
</body>
</html>

55
app/templates/home.html Normal file
View File

@@ -0,0 +1,55 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>航标 · 班级达线指标预估</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<div class="mark">航</div>
<div><b>航标</b><span>班级达线指标预估系统</span></div>
</a>
<form action="/logout" method="post"><button class="ghost" type="submit">退出 {{ user }}</button></form>
</header>
<main class="wrap">
<section class="hero">
<div>
<h1>把历史成绩当成导航轨迹,<br>而不是一张静态排名表。</h1>
<p>
上传各次大考总分后,系统会把分数换成同一次考试内的标准分,用卡尔曼滤波估计每个学生的稳定水平,
再模拟下一次全年级排名,把达线概率加总到班级。下达的是“按现有轨迹本来会到的人数”,班级工作是超越它。
</p>
</div>
<form class="drop" action="/upload" method="post" enctype="multipart/form-data">
<h3>上传成绩表</h3>
<p class="muted">xlsx,需含学号/姓名/班级,以及多次考试总分列。若有「方向」或物理/历史分表,会分开测算。</p>
<input type="file" name="file" accept=".xlsx,.xlsm" required>
<div class="actions">
<button class="primary" type="submit">开始逐步分析</button>
</div>
</form>
</section>
{% if has_sample %}
<form action="/demo" method="post" style="margin-top:12px">
<button class="btn teal" type="submit">用文件夹里的 2024 级示例表走一遍</button>
</form>
{% endif %}
<div class="list">
{% for it in items %}
<a class="item" href="/analysis/{{ it.id }}?step={{ it.step or 1 }}">
<div>
<b>{{ it.title }}</b>
<div class="tiny">{{ it.filename }} · {{ it.created_at }}</div>
</div>
<span class="chip">继续第 {{ it.step or 1 }} 步</span>
</a>
{% else %}
<div class="muted">还没有分析记录。上传成绩表后会在这里列出。</div>
{% endfor %}
</div>
</main>
</body>
</html>

25
app/templates/login.html Normal file
View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · 航标</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div class="login-wrap">
<form class="login-card" method="post" action="/login">
<div class="mark">航</div>
<h1>航标</h1>
<p class="muted">用历史成绩滤波,预估各班后续达线指标。管理员登录后上传成绩表,逐步确认口径,再下达可超越的班级目标。</p>
<label>账号</label>
<input name="username" value="admin" autocomplete="username">
<label>密码</label>
<input name="password" type="password" autocomplete="current-password">
{% if error %}<div class="err">{{ error }}</div>{% endif %}
<div class="actions"><button class="primary" type="submit">进入系统</button></div>
<p class="tiny">默认账号 admin / hangbiao2026,请部署后尽快修改 app/config.py。</p>
</form>
</div>
</body>
</html>