Skip to content

Commit 0ae96b0

Browse files
committed
sync(main): quiz fixes from golang-version — idempotent reveal, submit reentry lock, A3/A4 answer guard, smd streaming + SW v2.10.0
Frontend (synced from golang-version): - fix(exam): submitExam reentry lock — prevent double-submit race causing 0-score - fix(exam): reveal retry with exponential backoff (3 attempts, 180s grace window) - fix(exam): A3/A4 multi-select must answer before swipe (consistent with btn-next) - fix(ui): CSS containment during slide animation for smoother frame rate - revert(ui): explain panel back to sync render (remove double-RAF lazy render) - feat(ai): smd real-time markdown streaming with per-char water-flow animation - chore(sw): bump cache version to v2.10.0 Backend (quiz.py): - fix(exam): idempotent reveal with 180s grace window (was one-shot pop) - fix(exam): cleanup also purges revealed sessions past grace window - fix: add missing ai_max_tokens/cleanup_days/debug params to start_quiz signature - feat(cli): add --debug flag for /api/debug/exam-sessions endpoint
1 parent 5397a4b commit 0ae96b0

6 files changed

Lines changed: 441 additions & 148 deletions

File tree

src/med_exam_toolkit/cli.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -661,10 +661,11 @@ def edit(ctx, bank, password, port, host, no_browser, no_pin):
661661
@click.option("--asr-model", default="", help="ASR 模型名(默认 qwen3-asr-flash)")
662662
@click.option("--asr-base-url", default="", help="ASR WebSocket URL")
663663
@click.option("--cleanup-days", default=0, type=int, help="不活跃用户数据保留天数(0=默认 7 天)")
664+
@click.option("--debug", is_flag=True, default=False, help="启用调试端点(仅排障用,切勿在生产环境开启)")
664665
@click.pass_context
665666
def quiz(ctx, banks, password, port, host, no_browser, no_record, no_pin, pin,
666667
ai_provider, ai_model, ai_key, ai_base_url, ai_thinking, ai_max_tokens,
667-
asr_key, asr_model, asr_base_url, cleanup_days):
668+
asr_key, asr_model, asr_base_url, cleanup_days, debug):
668669
"""启动医考练习 Web 应用(练习/考试/背题模式,支持多题库)
669670
670671
\b
@@ -697,7 +698,7 @@ def quiz(ctx, banks, password, port, host, no_browser, no_record, no_pin, pin,
697698
ai_api_key=ai_key, ai_base_url=ai_base_url,
698699
ai_thinking=ai_thinking, ai_max_tokens=ai_max_tokens,
699700
asr_api_key=asr_key, asr_model=asr_model, asr_base_url=asr_base_url,
700-
cleanup_days=cleanup_days)
701+
cleanup_days=cleanup_days, debug=debug)
701702

702703

703704
def main():

src/med_exam_toolkit/quiz.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def name(self) -> str:
6363
_asr_base_url: str = ""
6464

6565
_cleanup_days: int = 7 # 不活跃用户数据保留天数
66+
_debug: bool = False # 调试模式(启用 /api/debug 端点,仅限 loopback 访问)
6667

6768
# ── 速率限制 ──
6869
_RATE_LIMIT = 120
@@ -130,6 +131,7 @@ def _is_banned(ip: str) -> bool:
130131
# ── 考试防作弊:sealed 模式答案暂存 ──
131132
_exam_sessions: dict[str, dict] = {} # exam_id -> {"fingerprint:si": {answer, discuss}, ts}
132133
_exam_lock = threading.Lock()
134+
_REVEAL_GRACE_WINDOW = 180 # 秒 — 交卷答案可重复领取的宽限窗口
133135

134136
# ── 试卷分享令牌(内存存储,7天有效,服务重启后失效)──
135137
_share_tokens: dict[str, dict] = {} # token -> {fingerprints, mode, bank_idx, time_limit, ts, expires_at}
@@ -685,12 +687,25 @@ def api_sync_status():
685687

686688
@app.get("/api/exam/reveal")
687689
def api_exam_reveal():
688-
"""考试模式提交后下发答案(一次性,消费后删除)。"""
690+
"""考试模式提交后下发答案(幂等,宽限期内可重复领取)。
691+
692+
第一次调用时记录 revealed_at,之后 _REVEAL_GRACE_WINDOW 秒内
693+
再次调用返回同样的答案。这样手动交卷和定时自动交卷的竞态、响应中
694+
断导致的重试、前端 submitExam 被意外双触发等情况都不会丢答案。
695+
"""
689696
eid = request.args.get("id", "")
690697
if not eid:
691698
return jsonify({"error": "缺少 exam id"}), 400
699+
now_s = int(time.time())
692700
with _exam_lock:
693-
sess = _exam_sessions.pop(eid, None)
701+
sess = _exam_sessions.get(eid)
702+
if sess is not None:
703+
if sess.get("revealed_at", 0) == 0:
704+
sess["revealed_at"] = now_s
705+
elif now_s - sess["revealed_at"] > _REVEAL_GRACE_WINDOW:
706+
# 宽限期已过,视同过期
707+
del _exam_sessions[eid]
708+
sess = None
694709
if sess is None:
695710
return jsonify({"error": "考试会话已过期或不存在"}), 404
696711
return jsonify({"answers": sess["answers"]})
@@ -843,7 +858,9 @@ def api_exam_join():
843858
server_now_ms = int(time.time() * 1000)
844859
started_at_ms = server_now_ms
845860
with _exam_lock:
846-
old = [k for k, v in _exam_sessions.items() if now - v["ts"] > 86400]
861+
old = [k for k, v in _exam_sessions.items()
862+
if now - v["ts"] > 86400
863+
or (v.get("revealed_at", 0) != 0 and now - v["revealed_at"] > _REVEAL_GRACE_WINDOW)]
847864
for k in old:
848865
del _exam_sessions[k]
849866
_exam_sessions[eid] = {
@@ -872,6 +889,40 @@ def api_exam_join():
872889
return jsonify(resp)
873890

874891

892+
# ════════════════════════════════════════════
893+
# API — 调试端点(仅 --debug 启用,且仅限 loopback 访问)
894+
# ════════════════════════════════════════════
895+
896+
def _is_loopback() -> bool:
897+
"""检查请求是否来自 loopback 地址(127.x / ::1)。"""
898+
remote = request.remote_addr or ""
899+
return remote.startswith("127.") or remote == "::1"
900+
901+
@app.get("/api/debug/exam-sessions")
902+
def api_debug_exam_sessions():
903+
if not _debug or not _is_loopback():
904+
return "", 404
905+
now_s = int(time.time())
906+
with _exam_lock:
907+
sessions = []
908+
for eid, v in _exam_sessions.items():
909+
sessions.append({
910+
"id": eid,
911+
"ts": v.get("ts", 0),
912+
"started_at": v.get("started_at", 0),
913+
"time_limit": v.get("time_limit", 0),
914+
"revealed_at": v.get("revealed_at", 0),
915+
"answer_count": len(v.get("answers", {})),
916+
"age_sec": now_s - v.get("ts", now_s),
917+
})
918+
return jsonify({
919+
"now": now_s,
920+
"reveal_grace_window": _REVEAL_GRACE_WINDOW,
921+
"exam_session_count": len(sessions),
922+
"exam_sessions": sessions,
923+
})
924+
925+
875926
# ════════════════════════════════════════════
876927
# API — 题库信息
877928
# ════════════════════════════════════════════
@@ -2032,9 +2083,12 @@ def start_quiz(
20322083
ai_api_key: str = "",
20332084
ai_base_url: str = "",
20342085
ai_thinking: bool | None = None,
2086+
ai_max_tokens: int = 0,
20352087
asr_api_key: str = "",
20362088
asr_model: str = "",
20372089
asr_base_url: str = "",
2090+
cleanup_days: int = 0,
2091+
debug: bool = False,
20382092
) -> None:
20392093
"""启动医考练习 Web 应用(支持多题库)。
20402094
@@ -2049,6 +2103,7 @@ def start_quiz(
20492103
_server_port, _server_host, \
20502104
_access_code, _cookie_secret, _pin_enabled, _pin_len, \
20512105
_ai_client, _ai_model, _ai_provider, _ai_enable_thinking, \
2106+
_ai_max_tokens, _cleanup_days, _debug, \
20522107
_asr_api_key, _asr_model, _asr_base_url
20532108

20542109
# 兼容旧的单路径传参
@@ -2068,6 +2123,7 @@ def start_quiz(
20682123
_ai_enable_thinking = ai_thinking
20692124
_ai_max_tokens = ai_max_tokens if ai_max_tokens > 0 else 2048
20702125
_cleanup_days = cleanup_days if cleanup_days > 0 else 7
2126+
_debug = debug
20712127
if ai_provider and ai_api_key:
20722128
try:
20732129
from med_exam_toolkit.ai.client import make_client, default_model
@@ -2161,6 +2217,8 @@ def start_quiz(
21612217
else:
21622218
print("\n ⚠️ 访问码验证已关闭(--no-pin),任何人可直接访问\n")
21632219

2220+
if _debug:
2221+
print(" ⚠ 调试模式已启用:/api/debug/exam-sessions 端点可访问(请勿在公网环境使用)")
21642222
print("[INFO] Ctrl+C 退出")
21652223

21662224
if not no_browser:

src/med_exam_toolkit/static/quiz.css

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2748,27 +2748,88 @@ button,a,[role=button],label{touch-action:manipulation}
27482748
to{opacity:1;transform:none}
27492749
}
27502750
.ai-thinking-fadein{animation:aiFadeIn .2s cubic-bezier(0,0,.2,1) both}
2751-
/* 每一段渲染完成时的淡入动画 */
2752-
.ai-para-in{
2753-
animation:aiParaIn .4s cubic-bezier(0,0,.2,1) both;
2751+
2752+
/* ── 流式渲染(自适应缓冲 + smd 实时 markdown + 逐字符水流动画) ─── */
2753+
/* 结构:
2754+
* .ai-stream-live 流式阶段的容器(marked 重渲后换成 .ai-stream-final)
2755+
* ├─ .ai-stream-body smd 向此节点 append;每个字符单独包成 .ai-ch
2756+
* └─ .ai-typing-dots 尾部三点"正在生成"指示
2757+
*/
2758+
.ai-stream-live{
2759+
display:block;
2760+
line-height:1.65;
2761+
word-break:break-word;
27542762
}
2755-
@keyframes aiParaIn{
2756-
from{opacity:0;transform:translateY(6px)}
2757-
to{opacity:1;transform:none}
2763+
.ai-stream-body > *:first-child{margin-top:0}
2764+
.ai-stream-body > *:last-child{margin-bottom:0}
2765+
2766+
/* 每字符水流动画:smd 的 add_text 回调被包装成产生 <span class="ai-ch">,
2767+
* CSS 自动给每个新字符 span 播放 aiChFlow 入场动画(下方上浮 + 淡入 + 轻微模糊到清晰)。
2768+
* 注意:内嵌在 smd 的 <p><strong><em> 等结构里也依然生效,因为动画作用于 span 本身。 */
2769+
.ai-ch{
2770+
display:inline-block;
2771+
animation:aiChFlow .36s cubic-bezier(.22,1,.36,1) both;
2772+
will-change:opacity,transform,filter;
2773+
}
2774+
/* CJK 字符(汉字、假名、韩文、全宽标点):一字一意,动画更舒展 */
2775+
.ai-ch-cjk{
2776+
animation-duration:.46s;
2777+
animation-timing-function:cubic-bezier(.16,1,.3,1);
2778+
}
2779+
@keyframes aiChFlow{
2780+
0% {opacity:0; transform:translate(1px,5px); filter:blur(1.2px)}
2781+
55% {opacity:.85; filter:blur(.2px)}
2782+
100% {opacity:1; transform:none; filter:none}
2783+
}
2784+
2785+
/* flush 之后:marked 整体重渲一次,不再对任何子节点做入场动画 */
2786+
.ai-stream-final *{animation:none !important}
2787+
2788+
/* smd 对 LaTeX 的占位元素(流式阶段显示;flush 后 marked + KaTeX 会接管) */
2789+
.ai-stream-body equation-inline,
2790+
.ai-stream-body equation-block{
2791+
font-family:"Cambria Math","STIX Two Math","Latin Modern Math",serif;
2792+
background:color-mix(in srgb,var(--accent) 8%,transparent);
2793+
color:var(--muted);
2794+
padding:0 4px;
2795+
border-radius:4px;
2796+
}
2797+
.ai-stream-body equation-block{
2798+
display:block;
2799+
padding:8px 12px;
2800+
margin:8px 0;
2801+
}
2802+
2803+
/* smd 降级(未加载时)使用的 <pre> —— 等宽显示原始 markdown,不丢内容 */
2804+
.ai-stream-fallback{
2805+
margin:0;
2806+
font-family:inherit;
2807+
white-space:pre-wrap;
2808+
}
2809+
2810+
@media (prefers-reduced-motion:reduce){
2811+
.ai-ch,.ai-ch-cjk{animation:none; opacity:1}
2812+
.ai-stream-body *{animation:none !important}
27582813
}
2759-
/* 段落之间的生成中动画:三点跳动 */
2814+
2815+
/* 尾部三点:紧贴文本末尾,不再占一整行 */
27602816
.ai-typing-dots{
2761-
display:flex;gap:5px;align-items:center;padding:6px 2px;
2817+
display:inline-flex;
2818+
gap:4px;
2819+
align-items:center;
2820+
vertical-align:middle;
2821+
margin-left:6px;
2822+
padding:0 2px;
27622823
}
27632824
.ai-typing-dots span{
2764-
width:6px;height:6px;border-radius:50%;
2825+
width:5px;height:5px;border-radius:50%;
27652826
background:var(--accent);
27662827
animation:aiDotPulse 1.2s ease-in-out infinite;
27672828
}
27682829
.ai-typing-dots span:nth-child(2){animation-delay:.2s}
27692830
.ai-typing-dots span:nth-child(3){animation-delay:.4s}
27702831
@keyframes aiDotPulse{
2771-
0%,80%,100%{opacity:.25;transform:scale(.75)}
2832+
0%,80%,100%{opacity:.25;transform:scale(.7)}
27722833
40%{opacity:1;transform:scale(1)}
27732834
}
27742835

0 commit comments

Comments
 (0)