Skip to content

Latest commit

 

History

History
131 lines (101 loc) · 8.21 KB

File metadata and controls

131 lines (101 loc) · 8.21 KB

坑档案:本框架已预解决的问题(PITFALLS)

框架的每一个机制背后,都是一个真实发生过的失败模式。这份档案按 坑 → 后果 → 框架内的解法 记录,既解释「为什么要这个机制」,也方便你评估 这套方案能否搬到自己的项目里。全部机制都可在源码中对应到具体实现。


1. 学习者代码死循环,服务挂死

  • 坑:判题直接在服务进程里执行学习者代码,一个 while True: pass 就把整个服务拖死。
  • 后果:一个初学者的一次手滑,杀死所有在线学习者的会话。
  • 解法:判题一律走子进程——每次判题在独立临时目录起一个子进程跑, timeout=10 秒强杀(server.py: grade / adapters/*_adapter.py: TIMEOUT_S)。 超时不算崩溃,算一次正常的「判红」,并把报错写成教学话术: "超时:10 秒没跑完(八成是死循环,检查 while 的退出条件)"。

2. AI 写的测试太松,错误解也能全绿

  • 坑:让 AI 出题时,它写的测试只查一个正常值——assert add(2,3) == 5。 错误解(漏负数、漏空输入、off-by-one)照样全绿。
  • 后果:题目形同虚设:学习者写错也能过,还获得「我学会了」的错误反馈。 这比没有题更害人。
  • 解法:每道题必须附 2–3 条 mutant(典型错误解),验证器逐条真跑—— 每条 mutant 必须至少挂一条测试,任何 mutant 全绿即整体拒绝,报错直说 「测试有洞,必补」(tools/validate.py,规则见 CONTENT_SPEC.md 第 6 节)。 mutant 是测试的测试。

3. AI 把参考解抄进题面,学习者照抄过关

  • 坑:AI 出题时图省事,spec 里给出答案级代码;或 starter 直接写了一半答案。
  • 后果:学习者复制粘贴 → 全绿 → 「学会了」的假象;题目失去区分度。
  • 解法:三道闸:
    1. 测试对学习者隐藏:判题用的断言/用例不随题面下发,抄题面抄不到答案 (server.py: /api/item 只回 n_tests 数量,测试代码只在判题时进入沙箱)。
    2. 变体机制:模块门与遗忘回访抽的都是变体题(同技能、不同数据与场景), 背下原题答案无效(server.py: start_gate 随机抽 2 个变体)。
    3. 验证器查泄漏:spec 中出现参考解整行即拒(CONTENT_SPEC.md 验证项 7)。

4. 教材里的示例代码根本跑不通

  • 坑:AI 写课文时「凭想象」写示例和输出——代码有笔误、输出是编的、报错是假的。
  • 后果:零基础学习者没有辨别能力,照着错代码敲,第一次接触的概念就是错的, 且会以为是自己错了。
  • 解法:验证器对课文代码块逐块真跑:普通代码块必须无错执行,块后输出块 必须与真跑输出逐字一致;标记为反例的块(```<语言>-bad 围栏)必须失败, 附带的报错文本同样真跑生成(tools/validate.py,规则见 CONTENT_SPEC.md 第 4.3 节)。

5. 学过就忘:没有复习,全部白学

  • 坑:刷题式学习一次通过就永久标记「完成」,两周后忘得干干净净。
  • 后果:进度条 100%,能力 0%——遗忘曲线不吃「当年通过」这一套。
  • 解法:每道题冷启动通过后,自动按 1 / 3 / 7 / 14 / 30 天 五档排期 变体回访(server.py: RETENTION_STAGES + retention 表):到期换一道同技能 变体题重新闭卷通过才算完成本轮复习(/api/retention/done 校验变体确有 冷启动通过记录)。复习的不是原题,背答案无效。

6. 自欺:「感觉学会了」和「学会了」分不清

  • 坑:学习者看一眼提示、瞄一眼参考解、抄一下 AI 输出,然后标记自己「完成」。
  • 后果:进度数据全是噪声;学习者带着幻觉进入下一模块,在更难处崩盘。
  • 解法:进度只由证据解锁,学生无法自标完成:
    • 冷启动门槛:零提示、未看过参考解、自己写出的代码跑绿,三条全满足才记「冷启动通过」 (server.py: submit 中 cold 判定;允许失败重试,但求助过就不再算); 看过参考解的题永远不算冷启动通过。
    • 渐进披露:第 1 次失败只告知哪条挂;满 4 次失败才可解锁提示(共 3 条); 满 6 次或用完提示才可看参考解——想走捷径,先付出「自证不会」的代价。
    • 模块门:模块全部题目冷启动通过后,随机抽 2 个变体限时闭卷重考, 全过才解锁下一模块(server.py: start_gate / gates 表)。

7. 并发写内容,一个坏 JSON 拖垮整个服务

  • 坑:AI 按「逐模块交付」写课程文件,写文件的瞬间(半个 JSON)恰好赶上服务重启加载。
  • 后果:一个未写完的文件让整个课程加载崩掉,服务起不来。
  • 解法:加载器逐文件容错:course.json / module.json / c*.json 任何一个解析失败, 只跳过该文件并打日志,其余照常加载(server.py: load_content)。 代价是「坏文件被静默跳过、题量变少」,所以配套终验全量:上线前 tools/validate.py 全量跑一遍,不允许「跳过的文件」混过交付。

8. 服务暴露在局域网,任何人可写你的进度库

  • 坑:HTTP 服务默认绑 0.0.0.0,同网段任何人都能访问 API、污染你的学习记录。
  • 后果:进度库被改、口令被爆破。
  • 解法:口令门:在 config.json 写 {"code": "..."} 后,所有 /api 请求 必须带 X-Code 头,比对用恒时比较 hmac.compare_digest(防时序侧信道); 连续错 5 次锁 120 秒(429),防在线爆破(server.py: _gate_check)。 config.json 已列入 .gitignore,口令不进仓库。

9. 路径穿越:请求 URL 伸进文件系统

  • 坑:静态服务器随手实现成「按请求路径拼目录读文件」, GET /../server.py、GET /etc/passwd 就把任意文件读走了。
  • 后果:源码、配置、进度库全量泄露。
  • 解法:白名单静态路由:引擎只服务两个固定路径(/ 与 /index.html, 映射到 web/index.html),其余一律 404——请求里的用户输入从不参与文件路径拼接 (server.py: _static)。课文接口 /api/lesson/<模块>/<课号> 只接受已加载模块的 id 和课号索引,文件名来自内容侧的 module.json,同样不触用户输入。

10. 改了前端,浏览器还在跑旧缓存

  • 坑:单文件前端迭代频繁,浏览器拿缓存的旧 index.html 调新接口, 或旧 JS 配旧 API 字段。
  • 后果:「我明明改好了,页面上还是老样子」的经典鬼故事,浪费排查时间。
  • 解法:静态响应一律带 Cache-Control: no-cache (server.py: _static),每次都协商最新版本;API 响应带 Content-Length 与 UTF-8 charset,杜绝转码缓存歧义。

附:三个判题沙箱的次级决策

11. 学生代码在判题机里调 exit()

  • 坑:func 模式要求提交「定义」,有学习者顺手写了 exit(),判题脚本跟着退出, 输出为空、报错丢失。
  • 解法:加载学生代码时单独捕获 SystemExit,翻译成教学话术 "代码里调用了 exit/quit——提交的应是定义,不该直接退出"(adapters/*_adapter.py 判题脚本)。

12. 输出比对被尾随空格误伤

  • 坑:io 模式逐字比对 stdout,编辑器自动加的尾随空格、行尾差异造成 「逻辑全对却判红」。
  • 解法:比对前做行级归一化:每行去尾随空白、整体去首尾空白 (adapters/*_adapter.py: _norm)。宽容度仅止于此——内容本身逐字负责。

13. 多线程 HTTP 服务共用一个 SQLite 连接

  • 坑:Python 的 SQLite 连接绑定创建它的线程,并发请求共用连接直接抛异常。
  • 解法:threading.local() 给每个线程独立连接(server.py: db), 建表幂等(CREATE TABLE IF NOT EXISTS),重启不丢进度。