first commit
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
11
app/__main__.py
Normal file
11
app/__main__.py
Normal 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
14
app/config.py
Normal 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
83
app/db.py
Normal 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
253
app/excel_parse.py
Normal 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
83
app/export_xlsx.py
Normal 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
282
app/indicators.py
Normal 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
205
app/kalman.py
Normal 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
283
app/main.py
Normal 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
149
app/static/css/app.css
Normal 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
112
app/static/js/app.js
Normal 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
263
app/templates/analysis.html
Normal 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
55
app/templates/home.html
Normal 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
25
app/templates/login.html
Normal 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>
|
||||
Reference in New Issue
Block a user