Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
102 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "chloeyouyou/acp-learning", "files": {"backend/run_tests.py": {"6bb8f6bbb21688466a43260d8010fc9f85ede89e": {"t": 1786068243.2854254, "n": 86304, "h": "26b9b3c956d2d6c2", "c": "\"\"\"后端核心逻辑测试(零依赖,直接 `python run_tests.py`)。\n\n锁住对错、防回归——重点覆盖踩过的隐蔽 bug:\n- 选雷\"补弱\"方向(曾经符号反了,推强不推弱)\n- 选雷\"防套路\"(不连续出同一题)\n- 质检双关闸门(雷会响 / 雷能排 / fix_check)\n- judge_fix 真判题、_error_family 解析、timeline 思维默认值聚合、sandbox。\n用真沙箱跑代码,几秒内完成。\n\"\"\"\n\nimport os\nimport re\nimport sys\nimport traceback\n\n# 主测试套件锁定 subprocess 后端:84 项用真沙箱,走 docker 会慢到几分钟。\n# 隔离性由文末「沙箱隔离」测试组单独打 docker 后端验证(docker 不可用则跳过)。\nos.environ.setdefault(\"ACP_SANDBOX\", \"subprocess\")\n# 单测不打真网:结算路径会触发先验蒸馏(真 LLM),此处关掉;\n# 先验层行为由 test_priors.py 用假 LLM 单独锁。\nos.environ.setdefault(\"ACP_PRIORS\", \"off\")\n\nsys.stdout.reconfigure(encoding=\"utf-8\")\n\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.orm import sessionmaker\n\nfrom app import models # noqa: F401 注册表到 Base.metadata\nfrom app.db import Base\nfrom app.models import (\n CodeSnapshot, Event, ExecutionEvent, KnowledgeState, SessionMessage, Student, TutorSession,\n)\nfrom app.services import (\n curriculum, event_engine, learning_path, mine_engine, pattern_validator, process, profile,\n review, sandbox, teacher, timeline,\n)\n\n_engine = create_engine(\"sqlite:///:memory:\", connect_args={\"check_same_thread\": False})\nBase.metadata.create_all(_engine)\nTestSession = sessionmaker(bind=_engine, autoflush=False)\n\n_tests = []\ndef test(fn): _tests.append(fn); return fn\n\n\ndef _good_candidate() -> dict:\n \"\"\"一道合规候选(buggy 真出 IndexError、fixed 跑出 2)。\"\"\"\n buggy = \"def f(a):\\n return a[3]\\nprint(f([1, 2]))\\n\"\n fixed = \"def f(a):\\n return a[-1]\\nprint(f([1, 2]))\\n\"\n return {\n \"id\": \"BP-TEST-001\", \"name\": \"测试题\", \"category\": \"boundary\", \"language\": \"python\",\n \"knowledge_points\": [\"数组\"], \"capability_dims\": [\"Boundary_Awareness\"],\n \"difficulty\": \"L1\", \"symptom\": \"RE\", \"symptom_sample\": \"IndexError: list index out of range\",\n \"buggy_code\": buggy, \"fixed_code\": fixed,\n \"mine_location\": {\"file\": \"main.py\", \"line\": 2}, \"trigger_input\": \"[1, 2]\",\n \"root_cause\": \"下标 3 越界\", \"cognitive_root\": \"以为列表一定有第 4 个元素\",\n \"thinking_pattern\": \"index_confusion\", \"expected_fix\": \"a[-1]\", \"fix_check\": r\"a\\[-1\\]\",\n \"expected_output\": \"2\",\n \"internalize_questions\": [\"为什么越界?\", \"合法下标范围是?\"],\n \"hint_ladder\": {f\"L{i}\": f\"提示{i}\" for i in range(6)},\n }\n\n\n# ---- 选雷:补弱(曾经符号反了,推强不推弱)----\n@test\ndef picker_选薄弱而非最强():\n orig = profile.knowledge_mastery\n profile.knowledge_mastery = lambda db, sid: [\n {\"kp\": \"强项\", \"mastery\": \"熟练\", \"weak\": False},\n {\"kp\": \"弱项\", \"mastery\": \"生疏\", \"weak\": True},\n ]\n try:\n db = TestSession()\n pats = [{\"id\": \"STRONG\", \"knowledge_points\": [\"强项\"], \"difficulty\": \"L1\"},\n {\"id\": \"WEAK\", \"knowledge_points\": [\"弱项\"], \"difficulty\": \"L1\"}]\n got = profile.get_weakest_pattern_id(db, \"s1\", pats)\n assert got == \"WEAK\", f\"应推薄弱题 WEAK,实际 {got}(符号又反了?)\"\n db.close()\n finally:\n profile.knowledge_mastery = orig\n\n\n# ---- 选雷:防套路(不连续出同一题)----\n@test\ndef picker_不连续出同一题():\n orig = profile.knowledge_mastery\n profile.knowledge_mastery = lambda db, sid: [\n {\"kp\": \"弱项\", \"mastery\": \"生疏\", \"weak\": True},\n {\"kp\": \"次弱\", \"mastery\": \"在学\", \"weak\": False},\n ]\n try:\n db = TestSession()\n db.add(TutorSession(id=\"sess_x\", student_id=\"s2\", pattern_id=\"WEAK\", manifest={}, history=[]))\n db.commit()\n pats = [{\"id\": \"WEAK\", \"knowledge_points\": [\"弱项\"], \"difficulty\": \"L1\"},\n {\"id\": \"SECOND\", \"knowledge_points\": [\"次弱\"], \"difficulty\": \"L1\"}]\n got = profile.get_weakest_pattern_id(db, \"s2\", pats)\n assert got != \"WEAK\", \"上次刚做 WEAK,不该又出 WEAK\"\n db.close()\n finally:\n profile.knowledge_mastery = orig\n\n\n# ---- 质检双关 ----\n@test\ndef 质检_合规候选全过():\n problems = pattern_validator.validate_candidate(_good_candidate())\n assert not problems, f\"合规候选不该有问题:{problems}\"\n\n@test\ndef 质检_雷不响要拦():\n c = _good_candidate()\n c[\"buggy_code\"] = \"x = 1\\nprint(2)\\n\" # 两行(line:2 仍合法)、不报错,但声明 RE\n problems = pattern_validator.validate_candidate(c)\n assert any(\"雷没响\" in p for p in problems), f\"雷不响应被拦:{problems}\"\n\n@test\ndef 质检_fixcheck匹配带雷码要拦():\n c = _good_candidate()\n c[\"fix_check\"] = r\"def f\" # 也匹配 buggy_code\n problems = pattern_validator.validate_candidate(c)\n assert any(\"buggy_code\" in p for p in problems), f\"应拦原样可过:{problems}\"\n\n@test\ndef 质检_跑题要拦():\n # 订单要 arithmetic/WA,候选交了 boundary/RE → 对单不通过\n c = _good_candidate() # category=boundary, symptom=RE\n task = {\"category\": \"arithmetic\", \"thinking_pattern\": \"control_semantics\", \"error_target\": \"WA\"}\n problems = pattern_validator.validate_candidate(c, task)\n assert any(\"跑题\" in p for p in problems), f\"对不上订单应被拦:{problems}\"\n # 对得上订单时不因对单报错\n task_ok = {\"category\": \"boundary\", \"thinking_pattern\": \"index_confusion\", \"error_target\": \"RE\"}\n assert not pattern_validator.validate_candidate(c, task_ok), \"对得上订单不该被拦\"\n\n@test\ndef 质检_缺字段要拦():\n c = _good_candidate(); del c[\"cognitive_root\"]\n problems = pattern_validator.validate_candidate(c)\n assert any(\"cognitive_root\" in p for p in problems), problems\n\n\n# ---- judge_fix 真判题 ----\n@test\ndef judge_带雷码不通过_修对通过():\n bad = mine_engine.judge_fix(\"BP-BOUNDARY-001\", mine_engine.get_pattern(\"BP-BOUNDARY-001\")[\"buggy_code\"])\n assert not bad[\"passed\"], \"带雷代码不该通过\"\n fixed = mine_engine.get_pattern(\"BP-BOUNDARY-001\")[\"buggy_code\"].replace(\"range(len(arr) + 1)\", \"range(len(arr))\")\n good = mine_engine.judge_fix(\"BP-BOUNDARY-001\", fixed)\n assert good[\"passed\"], f\"正确修复应通过:{good}\"\n\n\n# ---- 事件:错误族解析 ----\n@test\ndef error_family_解析():\n assert event_engine._error_family(\"RE\", \"Traceback...\\nIndexError: x\") == \"IndexError\"\n assert event_engine._error_family(\"HANG\", \"\") == \"Timeout\"\n assert event_engine._error_family(\"WA\", \"\") == \"WrongAnswer\"\n assert event_engine._error_family(\"OK\", \"\") is None\n\n\n# ---- timeline:观察卡解析 + 思维默认值聚合 ----\n@test\ndef timeline_观察卡解析():\n obs, guess = timeline._parse_observation(\n [{\"role\": \"user\", \"content\": \"【观察记录】\\n我观察到:第一行少了\\n我的猜测:循环错\"}])\n assert obs == \"第一行少了\" and guess == \"循环错\", (obs, guess)\n\n@test\ndef timeline_跨题思维默认值聚合():\n eps = [{\"pattern_id\": \"BP-NULL-002\", \"pattern_name\": \"方法返回None未检查\"},\n {\"pattern_id\": \"BP-NULL-003\", \"pattern_name\": \"字典取值未判空\"}] # 同 assume_valid 簇\n res = timeline.aggregate_thinking_patterns(eps)\n assert res.get(\"enough\") and any(it[\"id\"] == \"assume_valid\" for it in res[\"items\"]), res\n\n\n# ---- 题库统计(曾经只吃 list、传 dict 报错)----\n@test\ndef collect_stats_兼容dict():\n pats = mine_engine.load_patterns() # dict[id->题]\n s = pattern_validator.collect_stats(pats)\n assert s[\"total\"] == len(pats) and sum(s[\"category\"].values()) == s[\"total\"], s\n\n\n# ---- 复习队列(间隔重复)----\nimport uuid as _uuid\nfrom datetime import date as _date, datetime as _dt, timedelta as _td\n\n_REVIEW_PID = \"BP-BOUNDARY-001\" # 用真实题,name/category 能填上\n\ndef _seed_solved(db, student_id, pid, *, state, solved_days_ago: list[int]):\n \"\"\"造一道「已解决/已内化」题 + 若干天前的 submit/OK 执行事实。\"\"\"\n db.add(KnowledgeState(student_id=student_id, pattern_id=pid,\n knowledge_points=[\"数组\"], state=state))\n base = _dt.now()\n for d in solved_days_ago:\n db.add(ExecutionEvent(\n id=f\"ex_{_uuid.uuid4().hex[:12]}\", version=\"v1\", student_id=student_id,\n session_id=f\"sess_{_uuid.uuid4().hex[:8]}\", pattern_id=pid,\n source=\"submit\", kind=\"OK\", knowledge_points=[\"数组\"], meta={},\n timestamp=(base - _td(days=d)).isoformat()))\n db.commit()\n\n@test\ndef review_旧题到期进队列():\n db = TestSession()\n sid = \"rev_due\"\n _seed_solved(db, sid, _REVIEW_PID, state=\"已解决\", solved_days_ago=[10]) # round1 间隔2,10>=2\n due = review.due_queue(db, sid)\n assert any(it[\"pattern_id\"] == _REVIEW_PID for it in due), f\"10天前解决的应到期:{due}\"\n it = next(it for it in due if it[\"pattern_id\"] == _REVIEW_PID)\n assert it[\"due\"] and it[\"name\"] and it[\"category\"], it\n db.close()\n\n@test\ndef review_刚解决不到期():\n db = TestSession()\n sid = \"rev_fresh\"\n _seed_solved(db, sid, _REVIEW_PID, state=\"已解决\", solved_days_ago=[0]) # 今天解决,0<2\n due = review.due_queue(db, sid)\n assert not any(it[\"pattern_id\"] == _REVIEW_PID for it in due), f\"刚解决不该到期:{due}\"\n db.close()\n\n@test\ndef review_间隔随轮次升档():\n # 已解决 round1=2天、round2=4天;已内化整体更长\n assert review._interval_for(\"已解决\", 1) == 2\n assert review._interval_for(\"已解决\", 2) == 4\n assert review._interval_for(\"已内化\", 1) > review._interval_for(\"已解决\", 1)\n # 多次解决(不同天)→ 轮次变高、间隔变长 → 同样 days_since 可能不到期\n db = TestSession()\n sid = \"rev_round\"\n _seed_solved(db, sid, _REVIEW_PID, state=\"已内化\", solved_days_ago=[40, 20, 5]) # round3\n due = review.due_queue(db, sid)\n it = [x for x in due if x[\"pattern_id\"] == _REVIEW_PID]\n # 最近一次 5 天前,已内化 round3 间隔=15,5<15 → 不到期\n assert not it, f\"round3 已内化 5天前不该到期:{due}\"\n db.close()\n\n@test\ndef review_未解决题不进队列():\n db = TestSession()\n sid = \"rev_open\"\n db.add(KnowledgeState(student_id=sid, pattern_id=_REVIEW_PID,\n knowledge_points=[\"数组\"], state=\"已接触\"))\n db.commit()\n assert not review.due_queue(db, sid), \"未解决(已接触)不该进复习队列\"\n db.close()\n\n\n# ---- 知返反幻觉:运行结果注入(截断 + 只留最新一条)----\nfrom app.services import tutor\n\n@test\ndef run_note_截断长输出():\n note = tutor.run_result_note(\"OK\", stdout=\"x\" * 5000)\n assert note.startswith(tutor.RUN_RESULT_PREFIX), note\n assert \"截断\" in note and len(note) < 800, f\"长输出必须截断:{len(note)}\"\n\n@test\ndef run_note_报错只留尾部():\n long_tb = \"Traceback...\\n\" + \"f\\n\" * 2000 + \"ZeroDivisionError: division by zero\"\n note = tutor.run_result_note(\"RE\", stderr=long_tb)\n assert tutor.RUN_RESULT_PREFIX in note and \"ZeroDivisionError\" in note\n assert len(note) < 800, \"报错应只留尾部\"\n\n@test\ndef run_note_超时():\n note = tutor.run_result_note(\"HANG\")\n assert \"超时\" in note or \"死循环\" in note\n\n@test\ndef inject_只保留最新一条运行结果():\n h = [{\"role\": \"user\", \"content\": \"我观察到了\"},\n {\"role\": \"assistant\", \"content\": \"嗯\"}]\n h = tutor.inject_run_note(h, tutor.run_result_note(\"OK\", stdout=\"第一次\"))\n h = tutor.inject_run_note(h, tutor.run_result_note(\"RE\", stderr=\"第二次错误\"))\n notes = [m for m in h if (m.get(\"content\") or \"\").startswith(tutor.RUN_RESULT_PREFIX)]\n assert len(notes) == 1, f\"只该留最新一条运行结果,实际 {len(notes)}\"\n assert \"第二次错误\" in notes[0][\"content\"], \"留下的应是最新那条\"\n # 非运行结果的历史不能被误删\n assert any(m[\"content\"] == \"我观察到了\" for m in h), \"普通历史不该被剔除\"\n\n\n# ---- 知返固定讲解策略:骨架 / 切路 / 断层 / 调用链 ----\ndef _strategy_session(stage=\"①发现\", stalled=0):\n return TutorSession(id=\"strategy\", student_id=\"stu\", pattern_id=\"BP-BOUNDARY-001\",\n manifest={}, stage=stage, stalled_turns=stalled, history=[])\n\n\n@test\ndef teaching_strategy_正常轮先搭骨架且只推当前目标():\n s = tutor.choose_teaching_strategy(_strategy_session(stage=\"②定位\"), \"我看到报错了\")\n assert s.explanation_route == \"skeleton\" and not s.route_changed\n assert \"可疑位置\" in s.core_goal, s\n\n\n@test\ndef teaching_strategy_听不懂时切换解释路径():\n s = tutor.choose_teaching_strategy(_strategy_session(), \"还是听不懂,太抽象了\")\n assert s.route_changed and s.explanation_route != \"skeleton\", s\n prompt = tutor.teaching_strategy_prompt(s)\n assert \"禁止重复上一轮定义和问法\" in prompt\n\n\n@test\ndef teaching_strategy_主动识别知识断层():\n s = tutor.choose_teaching_strategy(_strategy_session(), \"下标是什么意思?\")\n assert s.knowledge_gap == \"下标/索引\", s\n assert \"先补这个最小前置知识\" in tutor.teaching_strategy_prompt(s)\n\n\n@test\ndef teaching_strategy_代码按调用链讲():\n s = tutor.choose_teaching_strategy(_strategy_session(stalled=1), \"这段代码我看不懂\")\n assert s.explanation_route == \"execution_trace\", s\n assert s.code_explanation_order == tutor.CODE_EXPLANATION_ORDER\n prompt = tutor.teaching_strategy_prompt(s)\n assert \"入口与输入→谁调用谁→关键数据怎样变化→返回值或报错落点\" in prompt\n\n\ndef _guard_strategy(route=\"skeleton\", changed=False):\n return tutor.TeachingStrategy(\n core_goal=\"只推进一个小目标\",\n explanation_route=route,\n confusion_detected=changed,\n route_changed=changed,\n )\n\n\n@test\ndef reply_guard_拦直接修法与多个问题():\n leak = tutor.assess_teaching_reply(\n \"直接改成 range(len(arr)) 就行。你明白了吗?\",\n _guard_strategy(), \"range(len(arr))\",\n )\n assert \"direct_fix_leak\" in leak.violations\n many = tutor.assess_teaching_reply(\n \"你先看哪一行?这个值又从哪来?\",\n _guard_strategy(), \"range(len(arr))\",\n )\n assert \"question_count\" in many.violations and many.question_count == 2\n\n one_of_two = tutor.assess_teaching_reply(\n \"可以改成列表推导 [s for s in scores if s >= 60]。你能先说出它避开了什么吗?\",\n _guard_strategy(),\n \"遍历副本 for s in scores[:] 或列表推导 [s for s in scores if s >= 60]\",\n )\n assert \"direct_fix_leak\" in one_of_two.violations\n\n\n@test\ndef reply_guard_换路必须在回复中真实可见():\n missing = tutor.assess_teaching_reply(\n \"我们再想一想。你现在看到什么?\",\n _guard_strategy(\"micro_example\", True), \"range(len(arr))\",\n )\n assert \"route_not_observable\" in missing.violations\n good = tutor.assess_teaching_reply(\n \"比如三个抽屉只编号 0 到 2,我们先只看这个小例子。你觉得编号 3 还能找到抽屉吗?\",\n _guard_strategy(\"micro_example\", True), \"range(len(arr))\",\n )\n assert good.passed, good\n\n\n@test\ndef reply_guard_首次违规会纠错重试():\n original = tutor._call_llm\n calls = []\n try:\n replies = iter([\n \"你先看哪一行?这个值从哪来?\",\n \"比如三个抽屉只编号 0 到 2。你觉得编号 3 存在吗?\",\n ])\n\n def fake_call(system, history):\n calls.append(system)\n return tutor.TutorTurn(\n reply=next(replies), stage_transition=None, hint_level_used=\"L0\",\n student_progressed=False, answer_begging=False, events=[],\n )\n\n tutor._call_llm = fake_call\n turn, meta = tutor._guarded_llm_turn(\n \"system\", [], _guard_strategy(\"micro_example\", True),\n \"range(len(arr))\", \"②定位\",\n )\n assert len(calls) == 2 and meta[\"attempts\"] == 2\n assert meta[\"passed\"] is True and meta[\"fallback_used\"] is False\n assert \"question_count\" in meta[\"violations\"] and \"三个抽屉\" in turn.reply\n finally:\n tutor._call_llm = original\n\n\n@test\ndef reply_guard_连续违规时安全兜底():\n original = tutor._call_llm\n try:\n tutor._call_llm = lambda system, history: tutor.TutorTurn(\n reply=\"直接改成 range(len(arr))。照着改就好。\",\n stage_transition=None, hint_level_used=\"L0\",\n student_progressed=False, answer_begging=False, events=[],\n )\n turn, meta = tutor._guarded_llm_turn(\n \"system\", [], _guard_strategy(), \"range(len(arr))\", \"②定位\",\n )\n assert meta[\"passed\"] is False and meta[\"fallback_used\"] is True\n assert \"range(len(arr))\" not in turn.reply\n assert len(re.findall(r\"[??]\", turn.reply)) == 1\n finally:\n tutor._call_llm = original\n\n\n# ---- Resource Gateway 亮点落地:动作信封 / 用途 / 风险 / Evidence 关联 ----\n@test\ndef action_governance_摘要稳定_call唯一且不泄露代码():\n import json\n from app.services import action_governance as ag\n kwargs = dict(actor_id=\"ag_u1\", session_id=\"ag_s1\", action=\"sandbox.run\",\n purpose=\"debug_observation\", initiated_by=\"student\",\n payload={\"code\": \"print('secret-source')\", \"mode\": \"debug\"})\n a = ag.authorize_action(**kwargs)\n b = ag.authorize_action(**kwargs)\n assert a[\"call_id\"] != b[\"call_id\"], (a, b)\n assert a[\"request_digest\"] == b[\"request_digest\"], (a, b)\n assert a[\"risk_level\"] == 1 and a[\"decision\"] == \"allow\" and a[\"execution\"] == \"pending\"\n assert \"secret-source\" not in json.dumps(a), \"动作上下文只存摘要,不得复制代码原文\"\n\n\n@test\ndef action_governance_未知动作与Agent代提交失败关闭():\n from app.services import action_governance as ag\n try:\n ag.authorize_action(actor_id=\"u\", session_id=\"s\", action=\"shell.exec\",\n purpose=\"debug\", initiated_by=\"student\", payload={})\n assert False, \"未知动作必须拒绝\"\n except ag.ActionDenied as exc:\n assert \"未注册动作\" in str(exc)\n try:\n ag.authorize_action(actor_id=\"u\", session_id=\"s\", action=\"submission.judge\",\n purpose=\"judge_submission\", initiated_by=\"agent\", payload={})\n assert False, \"知返不得代替学生提交\"\n except ag.ActionDenied as exc:\n assert \"不能触发\" in str(exc)\n\n\n@test\ndef action_governance_运行用途由服务端状态确定():\n from app.services import action_governance as ag\n assert ag.purpose_for_run(stage=\"⑤验证\", mode=\"debug\") == \"verify_hypothesis\"\n assert ag.purpose_for_run(stage=\"②定位\", mode=\"debug\") == \"debug_observation\"\n assert ag.purpose_for_run(stage=\"⑤验证\", mode=\"coop\") == \"collaborative_debug\"\n\n\n@test\ndef action_governance_提交事实与能力事件共享call_id():\n from app.main import SubmitReq, submit_fix\n from app.services import mine_engine\n db = TestSession()\n sid, ssid, pid = \"ag_submit_u\", \"ag_submit_s\", \"BP-BOUNDARY-001\"\n pat = mine_engine.get_pattern(pid)\n sess = TutorSession(\n id=ssid, student_id=sid, pattern_id=pid,\n manifest=mine_engine.build_manifest(sid, pat), history=[],\n stage=\"④修复\", mine_status=\"found\", status=\"active\",\n )\n db.add(sess); db.commit()\n fixed_code = pat[\"buggy_code\"].replace(\"range(len(arr) + 1)\", \"range(len(arr))\")\n result = submit_fix(ssid, SubmitReq(code=fixed_code), db=db, me=sid, _lock=None)\n assert result[\"passed\"] is True\n ctx = result[\"action_context\"]\n assert ctx[\"action\"] == \"submission.judge\" and ctx[\"risk_level\"] == 2\n assert ctx[\"decision\"] == \"allow\" and ctx[\"execution\"] == \"completed\"\n execution = db.query(ExecutionEvent).filter_by(session_id=ssid, source=\"submit\").one()\n fixed_event = db.query(Event).filter_by(session_id=ssid, capability=\"Independent_Debug\").one()\n assert execution.meta[\"action_context\"][\"call_id\"] == ctx[\"call_id\"]\n assert fixed_event.context[\"action_context\"][\"call_id\"] == ctx[\"call_id\"]\n db.close()\n\n\n# ---- 接着做(未完成关卡续做):cleanup / active-sessions / abandon(设计 08)----\nfrom app.main import _cleanup_abandoned, abandon_session, get_active_sessions\n\n_AS_PID = \"BP-BOUNDARY-001\" # 用真实题,name 能填上\n\ndef _mk_active(db, sid, ssid, *, history=None, mine_status=\"planted\", events_at=None,\n updated_at=None):\n \"\"\"造一个 active 会话 + 可选的若干 ExecutionEvent(timestamp 为 ISO 串)。\n updated_at 给定则设为该值——active-sessions 现按 updated_at 排序(不再回查事件表)。\"\"\"\n sess = TutorSession(id=ssid, student_id=sid, pattern_id=_AS_PID, manifest={},\n mine_status=mine_status, history=history or [], status=\"active\")\n if updated_at:\n sess.updated_at = updated_at\n db.add(sess)\n for ts in (events_at or []):\n db.add(ExecutionEvent(\n id=f\"ex_{_uuid.uuid4().hex[:12]}\", version=\"v1\", student_id=sid,\n session_id=ssid, pattern_id=_AS_PID, source=\"run\", kind=\"OK\",\n knowledge_points=[], meta={}, timestamp=ts))\n db.commit()\n\n@test\ndef cleanup_只清纯空壳():\n db = TestSession()\n sid = \"cl_1\"\n # 系统注入的 user 消息((系统·…)开头)不算真实发言\n sys_only = [{\"role\": \"system\", \"content\": \"运行这段代码,看看它的行为\"},\n {\"role\": \"user\", \"content\": \"(系统·运行结果)2\"}]\n _mk_active(db, sid, \"empty\", history=sys_only) # 纯空壳 → 删\n _mk_active(db, sid, \"has_msg\", history=[{\"role\": \"user\", \"content\": \"我觉得循环错了\"}]) # 有真实发言 → 留\n _mk_active(db, sid, \"has_run\", history=sys_only, events_at=[_dt.now().isoformat()]) # 有运行记录 → 留\n _cleanup_abandoned(db, sid)\n db.commit() # _cleanup_abandoned 只 delete 不 commit(真实调用方 create_session 负责提交)\n left = {s.id for s in db.query(TutorSession).filter_by(student_id=sid).all()}\n assert left == {\"has_msg\", \"has_run\"}, f\"只该清纯空壳,实际剩:{left}\"\n db.close()\n\n@test\ndef cleanup_已定位不误删():\n db = TestSession()\n sid = \"cl_2\"\n _mk_active(db, sid, \"located\", history=[], mine_status=\"found\") # 非 planted → 不在清理范围\n _cleanup_abandoned(db, sid)\n assert db.query(TutorSession).filter_by(id=\"located\").first() is not None, \"已定位会话不该被清\"\n db.close()\n\n@test\ndef active_sessions_按活跃倒序且上限5():\n db = TestSession()\n sid = \"as_1\"\n base = _dt.now()\n for i in range(6): # 6 个;updated_at 各异,i 越大越老\n _mk_active(db, sid, f\"s{i}\", history=[{\"role\": \"user\", \"content\": \"x\"}],\n updated_at=(base - _td(days=i)).isoformat())\n sessions = get_active_sessions(sid, db=db, me=sid)[\"sessions\"]\n assert len(sessions) == 5, f\"上限 5,实际 {len(sessions)}\"\n assert sessions[0][\"session_id\"] == \"s0\", f\"最近活跃应置顶,实际 {sessions[0]['session_id']}\"\n times = [s[\"last_active_at\"] for s in sessions]\n assert times == sorted(times, reverse=True), f\"应按活跃度倒序:{times}\"\n assert \"s5\" not in [s[\"session_id\"] for s in sessions], \"最老的应被挤出前 5\"\n assert sessions[0][\"name\"] and sessions[0][\"stage\"], \"应带 name/stage 供大厅展示\"\n assert sessions[0][\"display_step\"] == \"运行观察\", \"学生界面应只消费四步语言\"\n db.close()\n\n@test\ndef active_sessions_无事件回退created_at():\n db = TestSession()\n sid = \"as_2\"\n _mk_active(db, sid, \"no_ev\", history=[{\"role\": \"user\", \"content\": \"x\"}]) # 无 ExecutionEvent\n sessions = get_active_sessions(sid, db=db, me=sid)[\"sessions\"]\n assert len(sessions) == 1 and sessions[0][\"last_active_at\"], \"无事件应回退 created_at\"\n db.close()\n\n@test\ndef abandon_置abandoned且不删历史与事件():\n db = TestSession()\n sid = \"ab_1\"\n hist = [{\"role\": \"user\", \"content\": \"我修了第 2 行\"}]\n _mk_active(db, sid, \"drop_me\", history=hist, events_at=[_dt.now().isoformat()])\n abandon_session(\"drop_me\", db=db, me=sid)\n s = db.query(TutorSession).filter_by(id=\"drop_me\").first()\n assert s.status == \"abandoned\", f\"应置 abandoned,实际 {s.status}\"\n assert s.history == hist, \"history 不该被删\"\n assert db.query(ExecutionEvent).filter_by(session_id=\"drop_me\").count() == 1, \"事件流不该被删\"\n assert not get_active_sessions(sid, db=db, me=sid)[\"sessions\"], \"放弃后不该再在 active-sessions\"\n db.close()\n\n\n# ---- sandbox ----\n@test\ndef sandbox_基本():\n assert sandbox.run_code(\"print(1+1)\").stdout.strip() == \"2\"\n assert sandbox.run_code(\"raise ValueError()\").has_error\n assert sandbox.normalize_output(\" 6 \") == \"6\"\n\n\n@test\ndef 提问训练_三要素评分单调():\n from app.services import question_training as q\n s0 = q._score_from_factors(False, False, False)\n s1 = q._score_from_factors(True, False, False)\n s2 = q._score_from_factors(True, True, False)\n s3 = q._score_from_factors(True, True, True)\n assert s0 < s1 < s2 < s3, (s0, s1, s2, s3)\n assert 0 <= s0 and s3 <= 100\n\n\n@test\ndef 提问训练_空提问不调LLM得0():\n from app.services import question_training as q\n r = q.diagnose(\"login\", \" \")\n assert r[\"score\"] == 0 and r[\"degraded\"] is False\n assert not r[\"phenomenon\"] and not r[\"context\"] and not r[\"expectation\"]\n\n\n@test\ndef 提问训练_fallback结构完整且不崩():\n from app.services import question_training as q\n r = q._fallback(\"随便写的提问\")\n for k in (\"phenomenon\", \"context\", \"expectation\", \"score\", \"feedback\", \"confidence\", \"degraded\"):\n assert k in r, k\n assert r[\"degraded\"] is True and r[\"confidence\"] == 0.0\n\n\n@test\ndef 提问训练_资产化落ExecutionEvent且命名空间正确():\n from app.services import question_training as q\n db = TestSession()\n r = {\"phenomenon\": True, \"context\": True, \"expectation\": False, \"score\": 66,\n \"feedback\": \"现象上下文已清,缺预期\", \"confidence\": 0.9, \"degraded\": False}\n ev = q.log_diagnosis(db, student_id=\"stu_qt1\", scenario_id=\"login\", prompt=\"登录登不上去\", result=r)\n assert ev is not None\n row = db.query(ExecutionEvent).filter_by(student_id=\"stu_qt1\").one()\n assert row.source == \"qt_diagnose\"\n assert row.pattern_id == \"QUESTION_TRAINING\"\n assert row.kind == \"QT\"\n assert row.knowledge_points == [] # 空 kp → 对画像隐形\n assert row.session_id == \"qtsess_stu_qt1\" # 合成 session → 对轨迹隐形\n assert row.meta[\"scenario_id\"] == \"login\" and row.meta[\"score\"] == 66\n assert row.meta[\"expectation\"] is False\n db.close()\n\n\n@test\ndef 提问训练_资产不进画像不进成长轨迹():\n from app.services import question_training as q\n from app.services import profile, timeline\n db = TestSession()\n # 先给该生建一个真实知识点状态 + 写若干 qt 资产\n profile.update_knowledge_state(db, student_id=\"stu_qt2\", pattern_id=\"BP-X\",\n knowledge_points=[\"循环\"], new_state=\"已接触\")\n for _ in range(4):\n q.log_diagnosis(db, student_id=\"stu_qt2\", scenario_id=\"login\", prompt=\"登录登不上去\",\n result={\"phenomenon\": True, \"context\": True, \"expectation\": False,\n \"score\": 66, \"feedback\": \"缺预期\", \"confidence\": 0.9, \"degraded\": False})\n # 画像掌握度里\"循环\"的 evidence_count 不应被 qt 事件抬高(qt 是空 kp)\n mastery = profile.knowledge_mastery(db, \"stu_qt2\")\n loop = [m for m in mastery if m[\"kp\"] == \"循环\"]\n assert loop and loop[0][\"evidence_count\"] == 0, loop\n # 成长轨迹不应出现 qt 事件\n blob = str(timeline.build_timeline(db, \"stu_qt2\"))\n assert \"qt_diagnose\" not in blob and \"QUESTION_TRAINING\" not in blob\n db.close()\n\n\n@test\ndef 提问训练_短板统计样本不足返回None_够了返回最常漏():\n from app.services import question_training as q\n db = TestSession()\n # 2 条 < 门槛(3) → None\n for _ in range(2):\n q.log_diagnosis(db, student_id=\"stu_qt3\", scenario_id=\"login\", prompt=\"x\",\n result={\"phenomenon\": True, \"context\": True, \"expectation\": False,\n \"score\": 66, \"feedback\": \"f\", \"confidence\": 0.9, \"degraded\": False})\n assert q.weakness_summary(db, \"stu_qt3\") is None\n # 再加 2 条(共 4 条,都缺预期)→ 返回 expectation\n for _ in range(2):\n q.log_diagnosis(db, student_id=\"stu_qt3\", scenario_id=\"login\", prompt=\"x\",\n result={\"phenomenon\": True, \"context\": True, \"expectation\": False,\n \"score\": 66, \"feedback\": \"f\", \"confidence\": 0.9, \"degraded\": False})\n w = q.weakness_summary(db, \"stu_qt3\")\n assert w and w[\"factor\"] == \"expectation\" and w[\"miss_count\"] == 4\n db.close()\n\n\n@test\ndef 提问训练_degraded与低置信不参与短板统计():\n from app.services import question_training as q\n db = TestSession()\n # 3 条都是 degraded 或低置信 → 不参与 → 样本不足 → None\n q.log_diagnosis(db, student_id=\"stu_qt4\", scenario_id=\"login\", prompt=\"x\",\n result={\"phenomenon\": False, \"context\": False, \"expectation\": False,\n \"score\": 12, \"feedback\": \"f\", \"confidence\": 0.0, \"degraded\": True})\n for _ in range(2):\n q.log_diagnosis(db, student_id=\"stu_qt4\", scenario_id=\"login\", prompt=\"x\",\n result={\"phenomenon\": False, \"context\": False, \"expectation\": False,\n \"score\": 12, \"feedback\": \"f\", \"confidence\": 0.5, \"degraded\": False})\n assert q.weakness_summary(db, \"stu_qt4\") is None\n\n\n@test\ndef 提问训练_custom场景注册且无隐藏背景():\n from app.services import question_training as q\n assert \"custom\" in q.SCENARIOS and q.SCENARIOS[\"custom\"][\"brief\"] == \"\"\n\n\n@test\ndef 提问训练_custom资产化与预置共存且短板合并():\n from app.services import question_training as q\n db = TestSession()\n sid = \"stu_custom\"\n # 2 条 custom + 2 条预置,都缺预期 → 短板统计应合并到 4 条、判 expectation\n for _ in range(2):\n q.log_diagnosis(db, student_id=sid, scenario_id=\"custom\", prompt=\"我自己的问题x\",\n result={\"phenomenon\": True, \"context\": True, \"expectation\": False,\n \"score\": 66, \"feedback\": \"f\", \"confidence\": 0.9, \"degraded\": False})\n for _ in range(2):\n q.log_diagnosis(db, student_id=sid, scenario_id=\"login\", prompt=\"预置场景x\",\n result={\"phenomenon\": True, \"context\": True, \"expectation\": False,\n \"score\": 66, \"feedback\": \"f\", \"confidence\": 0.9, \"degraded\": False})\n # 资产里 custom 与预置共存\n rows = db.query(ExecutionEvent).filter_by(student_id=sid, source=\"qt_diagnose\").all()\n sids = {(r.meta or {}).get(\"scenario_id\") for r in rows}\n assert sids == {\"custom\", \"login\"}, sids\n # 短板统计合并 4 条\n w = q.weakness_summary(db, sid)\n assert w and w[\"factor\"] == \"expectation\" and w[\"sample_size\"] == 4\n db.close()\n\n\ndef _qres(p, c, e, conf=0.9, degraded=False):\n return {\"phenomenon\": p, \"context\": c, \"expectation\": e,\n \"score\": 0, \"feedback\": \"f\", \"confidence\": conf, \"degraded\": degraded}\n\n\n@test\ndef 提问训练计分_三要素全_Prompt_Design正分点亮AI协作维度():\n from app.services import question_training as q\n from app.services import profile\n db = TestSession()\n sid = \"stu_sc1\"\n # 计分前:AI协作维度无分\n before = profile.get_profile(db, sid)\n ai_before = [d for d in before[\"dimensions\"] if d[\"name\"] == \"AI协作能力\"][0]\n assert ai_before[\"score\"] is None\n # 三要素全 → +2\n ev = q.score_diagnosis(db, student_id=sid, scenario_id=\"login\",\n prompt=\"登录报NPE在第32行,期望登录成功\", result=_qres(True, True, True))\n assert ev is not None and ev.capability == \"Prompt_Design\" and ev.delta == 2\n after = profile.get_profile(db, sid)\n ai_after = [d for d in after[\"dimensions\"] if d[\"name\"] == \"AI协作能力\"][0]\n assert ai_after[\"score\"] is not None, after\n assert after[\"vector\"][\"Prompt_Design\"][\"events_count\"] == 1\n db.close()\n\n\n@test\ndef 提问训练计分_degraded和单要素不记分():\n from app.services import question_training as q\n db = TestSession()\n assert q.score_diagnosis(db, student_id=\"s\", scenario_id=\"login\", prompt=\"x\",\n result=_qres(True, True, True, degraded=True)) is None\n assert q.score_diagnosis(db, student_id=\"s\", scenario_id=\"login\", prompt=\"只有现象\",\n result=_qres(True, False, False)) is None # 1要素→delta0→不记\n assert db.query(Event).filter_by(student_id=\"s\", capability=\"Prompt_Design\").count() == 0\n db.close()\n\n\n@test\ndef 提问训练计分_纯帮我看看记负分():\n from app.services import question_training as q\n db = TestSession()\n ev = q.score_diagnosis(db, student_id=\"s_neg\", scenario_id=\"login\", prompt=\"代码报错了帮我看看\",\n result=_qres(False, False, False))\n assert ev is not None and ev.delta == -1\n db.close()\n\n\n@test\ndef 提问训练计分_日上限6封顶():\n from app.services import question_training as q\n db = TestSession()\n sid = \"stu_cap\"\n # 不同提问各 +2,3 条到 +6\n for i in range(3):\n ev = q.score_diagnosis(db, student_id=sid, scenario_id=\"login\",\n prompt=f\"完整提问版本{i}\", result=_qres(True, True, True))\n assert ev is not None, i\n # 第 4 条超上限 → 不记\n assert q.score_diagnosis(db, student_id=sid, scenario_id=\"login\",\n prompt=\"完整提问版本4\", result=_qres(True, True, True)) is None\n db.close()\n\n\n@test\ndef 提问训练计分_同句今日不重复记():\n from app.services import question_training as q\n db = TestSession()\n sid = \"stu_dup\"\n p = \"登录报NPE在第32行,期望登录成功\"\n assert q.score_diagnosis(db, student_id=sid, scenario_id=\"login\", prompt=p, result=_qres(True, True, True)) is not None\n assert q.score_diagnosis(db, student_id=sid, scenario_id=\"login\", prompt=p, result=_qres(True, True, True)) is None\n db.close()\n\n\n@test\ndef 提问训练计分_泛泛求代办2要素降档不记():\n from app.services import question_training as q\n db = TestSession()\n # 2要素(现象+上下文) + \"帮我看看\" → C档降为0、不记\n ev = q.score_diagnosis(db, student_id=\"s_vague\", scenario_id=\"login\",\n prompt=\"登录报错了,帮我看看\", result=_qres(True, True, False))\n assert ev is None\n assert db.query(Event).filter_by(student_id=\"s_vague\", capability=\"Prompt_Design\").count() == 0\n db.close()\n\n\n@test\ndef 提问训练计分_泛泛求代办0要素仍负分():\n from app.services import question_training as q\n db = TestSession()\n ev = q.score_diagnosis(db, student_id=\"s_v0\", scenario_id=\"login\",\n prompt=\"帮我看看\", result=_qres(False, False, False))\n assert ev is not None and ev.delta == -1\n db.close()\n\n\n@test\ndef 提问训练计分_明确协作请求不算泛泛():\n from app.services import question_training as q\n # 含\"判断/还是\"等明确协作请求 → 不被当泛泛求代办(即使有\"帮我\")\n assert q._is_vague_handoff(\"可以帮我判断是接口返回结构问题,还是前端取值逻辑问题吗?\") is False\n assert q._is_vague_handoff(\"帮我看看\") is True\n assert q._is_vague_handoff(\"登录报错了,帮我看看\") is True\n db = TestSession()\n # 2要素 + 明确协作请求 → 正常 +1(不降档)\n ev = q.score_diagnosis(db, student_id=\"s_collab\", scenario_id=\"login\",\n prompt=\"登录报错,是接口问题还是前端取值问题?\", result=_qres(True, True, False))\n assert ev is not None and ev.delta == 1\n db.close()\n\n\n@test\ndef 提问训练计分_三要素完整不受泛泛影响():\n from app.services import question_training as q\n db = TestSession()\n # 三要素齐全即便带\"帮我看看\"也照记 +2(已是完整提问)\n ev = q.score_diagnosis(db, student_id=\"s_full3\", scenario_id=\"login\",\n prompt=\"登录报NPE在第32行,期望登录成功,帮我看看\", result=_qres(True, True, True))\n assert ev is not None and ev.delta == 2\n db.close()\n\n\n@test\ndef 提问训练计分_低置信入库但不进画像():\n from app.services import question_training as q\n from app.models import CapabilityScore\n db = TestSession()\n sid = \"stu_lowconf\"\n ev = q.score_diagnosis(db, student_id=sid, scenario_id=\"login\",\n prompt=\"低置信完整提问\", result=_qres(True, True, True, conf=0.5))\n assert ev is not None # 事件入库\n # 但 confidence<0.7 不进画像分\n assert db.query(CapabilityScore).filter_by(student_id=sid, capability=\"Prompt_Design\").first() is None\n db.close()\n\n\n@test\ndef 状态机_修复阶段未提交时LLM不得擅自跃迁():\n \"\"\"硬门回归:离开④修复只能靠 judge_fix 提交通过。LLM 在对话里填 stage_transition=⑤\n 也不放行——否则代码从未判过就能走到⑤⑥甚至被标「已内化」(真实漏洞,2026-06-22 修)。\"\"\"\n from app.services import mine_engine, tutor\n from app.models import TutorSession\n orig_get, orig_load, orig_llm = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n pat = _good_candidate()\n mine_engine.get_pattern = lambda pid: {pat[\"id\"]: pat}[pid]\n mine_engine.load_patterns = lambda: {pat[\"id\"]: pat}\n tutor._call_llm = lambda system, history: tutor.TutorTurn(\n reply=\"继续\", stage_transition=\"⑤验证\", hint_level_used=\"L1\",\n student_progressed=True, answer_begging=False, events=[])\n db = TestSession()\n sess = TutorSession(id=\"sess_gate1\", student_id=\"g1\", pattern_id=pat[\"id\"],\n manifest=mine_engine.build_manifest(\"g1\", pat), history=[],\n stage=\"④修复\", mine_status=\"found\")\n db.add(sess); db.commit()\n tutor.run_turn(db, sess, \"改个边界就行\")\n assert sess.stage == \"④修复\", f\"未提交不该离开④,实际到了 {sess.stage}\"\n assert sess.mine_status == \"found\", sess.mine_status\n saved = (db.query(SessionMessage).filter_by(session_id=sess.id, role=\"tutor\")\n .order_by(SessionMessage.seq.desc()).first())\n assert saved.meta[\"teaching_strategy\"][\"explanation_route\"] == \"skeleton\"\n assert saved.meta[\"stage_before\"] == \"④修复\" and saved.meta[\"stage_after\"] == \"④修复\"\n assert saved.meta[\"reply_guard\"][\"fallback_used\"] is True\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = orig_get, orig_load, orig_llm\n\n\n@test\ndef 状态机_提交通过后可正常进验证():\n \"\"\"对照:mine_status=fixed(提交已判过)时,④→⑤ 的跃迁正常放行,不被新门误伤。\"\"\"\n from app.services import mine_engine, tutor\n from app.models import TutorSession\n orig_get, orig_load, orig_llm = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n pat = _good_candidate()\n mine_engine.get_pattern = lambda pid: {pat[\"id\"]: pat}[pid]\n mine_engine.load_patterns = lambda: {pat[\"id\"]: pat}\n tutor._call_llm = lambda system, history: tutor.TutorTurn(\n reply=\"继续\", stage_transition=\"⑤验证\", hint_level_used=\"L1\",\n student_progressed=True, answer_begging=False, events=[])\n db = TestSession()\n sess = TutorSession(id=\"sess_gate2\", student_id=\"g2\", pattern_id=pat[\"id\"],\n manifest=mine_engine.build_manifest(\"g2\", pat), history=[],\n stage=\"④修复\", mine_status=\"fixed\") # 已提交判过\n db.add(sess); db.commit()\n tutor.run_turn(db, sess, \"我想测空列表\")\n assert sess.stage == \"⑤验证\", f\"已 fixed 应可进⑤,实际 {sess.stage}\"\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = orig_get, orig_load, orig_llm\n\n\n# ════════ 导师纯规则函数(无 LLM、无 DB,最便宜的回归网)════════\n\n@test\ndef 受挫检测_学生本人受挫与程序卡住要分开():\n from app.services import tutor\n # 学生本人受挫 → True\n for s in [\"我不会\", \"完全没思路\", \"太难了\", \"想放弃\", \"给个提示\", \"啊啊啊\", \"我卡住了\"]:\n assert tutor.detect_frustration(s), f\"应判受挫: {s}\"\n # 描述程序卡住(不是本人受挫)→ False\n for s in [\"程序卡住了跑不完\", \"代码一直在循环卡住\", \"运行卡住\"]:\n assert not tutor.detect_frustration(s), f\"程序卡死≠学生受挫: {s}\"\n # 负向断言别误伤:会不会/知不知道 不算受挫\n for s in [\"会不会越界\", \"我知不知道得测一下\", \"这样对不对\"]:\n assert not tutor.detect_frustration(s), f\"不该误判受挫: {s}\"\n\n\n@test\ndef 错误签名识别_命中返回剧本_未命中None():\n from app.services import tutor\n sig, pb = tutor.detect_error_signature(\"我猜会报 IndexError\")\n assert sig == \"IndexError\" and \"meaning\" in pb\n assert tutor.detect_error_signature(\"不知道哪里错\") is None\n\n\n@test\ndef 定位判定_引用雷行或点行号才算():\n from app.services import mine_engine, tutor\n orig = mine_engine.get_pattern\n try:\n pat = _good_candidate() # 雷在第 2 行:return a[3]\n mine_engine.get_pattern = lambda pid: {pat[\"id\"]: pat}[pid]\n assert tutor.message_points_at_mine(\"我觉得 return a[3] 有问题\", pat[\"id\"])\n assert tutor.message_points_at_mine(\"第 2 行不对吧\", pat[\"id\"])\n assert not tutor.message_points_at_mine(\"不知道在哪\", pat[\"id\"])\n finally:\n mine_engine.get_pattern = orig\n\n\n@test\ndef 边界解释判定_要同时有边界输入和预期():\n from app.services import tutor\n assert tutor.message_explains_boundary_test(\"空列表传进去,预期返回 None\")\n assert not tutor.message_explains_boundary_test(\"空列表试试\") # 缺预期\n assert not tutor.message_explains_boundary_test(\"应该返回正确结果\") # 缺具体边界输入\n\n\n@test\ndef 内化实质性_纯表态不算_含机制才算():\n from app.services import tutor\n for s in [\"我懂了\", \"好的\", \"嗯嗯\", \"以后注意\"]:\n assert not tutor.is_substantive(s), f\"纯表态不该算: {s}\"\n assert tutor.is_substantive(\"因为下标从0数,长度3时最大下标是2,访问3就越界了\")\n\n\n# ════════ run_turn 状态机跃迁(stub LLM,覆盖规则跃迁与硬门)════════\n\ndef _stub_turn(tutor, **kw):\n base = dict(reply=\"嗯\", stage_transition=None, hint_level_used=\"L0\",\n student_progressed=True, answer_begging=False, events=[])\n base.update(kw)\n return lambda system, history: tutor.TutorTurn(**base)\n\ndef _arena_session(tutor, mine_engine, *, stage, mine_status=\"planted\"):\n pat = _good_candidate()\n mine_engine.get_pattern = lambda pid: {pat[\"id\"]: pat}[pid]\n mine_engine.load_patterns = lambda: {pat[\"id\"]: pat}\n from app.models import TutorSession\n db = TestSession()\n sess = TutorSession(id=f\"s_{stage}_{__import__('uuid').uuid4().hex[:6]}\", student_id=\"rt1\", pattern_id=pat[\"id\"],\n manifest=mine_engine.build_manifest(\"rt1\", pat), history=[],\n stage=stage, mine_status=mine_status)\n db.add(sess); db.commit()\n return db, sess, pat\n\n\n@test\ndef 跃迁_发现阶段贴出报错签名自动进定位():\n from app.services import mine_engine, tutor\n o1, o2, o3 = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n tutor._call_llm = _stub_turn(tutor) # LLM 不报跃迁\n db, sess, pat = _arena_session(tutor, mine_engine, stage=\"①发现\")\n tutor.run_turn(db, sess, \"它会报 IndexError 吧\")\n assert sess.stage == \"②定位\", sess.stage # 规则层据报错签名自动跃迁\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = o1, o2, o3\n\n\n@test\ndef 跃迁_定位阶段指认雷行自动进归因且雷标found():\n from app.services import mine_engine, tutor\n o1, o2, o3 = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n tutor._call_llm = _stub_turn(tutor)\n db, sess, pat = _arena_session(tutor, mine_engine, stage=\"②定位\")\n tutor.run_turn(db, sess, \"问题在 return a[3] 这行\")\n assert sess.stage == \"③归因\", sess.stage\n assert sess.mine_status == \"found\", sess.mine_status\n assert sess.attribution_step == \"variable_trace\"\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = o1, o2, o3\n\n\n@test\ndef 跃迁_内化三轴达标则升级已内化并结束会话():\n from app.services import mine_engine, profile, tutor\n from app.models import KnowledgeState\n o1, o2, o3 = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n tutor._call_llm = _stub_turn(\n tutor, internalization=tutor.InternalizationScore(cause=True, locate=True, prevent=False))\n db, sess, pat = _arena_session(tutor, mine_engine, stage=\"⑥内化\", mine_status=\"fixed\")\n tutor.run_turn(db, sess, \"因为下标越界,定位靠看报错行和追踪变量,下次先查 range 边界\")\n assert sess.mine_status == \"internalized\", sess.mine_status\n assert sess.status == \"completed\", sess.status\n ks = db.get(KnowledgeState, (\"rt1\", pat[\"id\"]))\n assert ks and ks.state == \"已内化\", ks\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = o1, o2, o3\n\n\n# ════════ 算分 / 画像(profile)════════\n\n@test\ndef 算分_冷启动加倍且封顶100():\n from app.services import event_engine\n from app.models import CapabilityScore\n db = TestSession()\n # INITIAL=30;冷启动×2(events<10);规则事件 conf=1.0 → 每条 +3×3×2 = +18\n for _ in range(4):\n event_engine.emit(db, student_id=\"sc_up\", session_id=\"x\", capability=\"Independent_Debug\",\n delta=3, producer=\"rule\", evidence={\"summary\": \"fix\"})\n r = db.get(CapabilityScore, (\"sc_up\", \"Independent_Debug\"))\n assert r.score == 100.0, r.score # 30→48→66→84→100,4 条即封顶\n assert r.events_count == 4\n db.close()\n\n\n@test\ndef 算分_负向事件下探不破0():\n from app.services import event_engine\n from app.models import CapabilityScore\n db = TestSession()\n for _ in range(2): # 30 -18 -18 → 钳到 0\n event_engine.emit(db, student_id=\"sc_dn\", session_id=\"x\", capability=\"Independent_Debug\",\n delta=-3, producer=\"rule\", evidence={\"summary\": \"neg\"})\n r = db.get(CapabilityScore, (\"sc_dn\", \"Independent_Debug\"))\n assert r.score == 0.0, r.score\n db.close()\n\n\n@test\ndef 算分_低置信LLM事件入库但不进分():\n from app.services import event_engine\n from app.models import CapabilityScore, Event\n db = TestSession()\n event_engine.emit(db, student_id=\"sc_lc\", session_id=\"x\", capability=\"Root_Cause_Reasoning\",\n delta=3, producer=\"llm_judge\", confidence=0.5, evidence={\"summary\": \"弱证据\"})\n assert db.query(Event).filter_by(student_id=\"sc_lc\").count() == 1 # 事件入库\n assert db.get(CapabilityScore, (\"sc_lc\", \"Root_Cause_Reasoning\")) is None # 但不进画像分\n db.close()\n\n\n@test\ndef 掌握度_状态映射与薄弱判定():\n from app.services import profile\n db = TestSession()\n sid = \"km1\"\n profile.update_knowledge_state(db, student_id=sid, pattern_id=\"P_A\",\n knowledge_points=[\"kpA\"], new_state=\"已内化\")\n profile.update_knowledge_state(db, student_id=sid, pattern_id=\"P_B\",\n knowledge_points=[\"kpB\"], new_state=\"已解决\")\n profile.update_knowledge_state(db, student_id=sid, pattern_id=\"P_C\",\n knowledge_points=[\"kpC\"], new_state=\"已接触\")\n m = {x[\"kp\"]: x for x in profile.knowledge_mastery(db, sid)}\n assert m[\"kpA\"][\"mastery\"] == \"掌握\" and m[\"kpA\"][\"weak\"] is False\n assert m[\"kpB\"][\"mastery\"] == \"在学\" and m[\"kpB\"][\"weak\"] is True # 已解决→建议内化\n assert m[\"kpC\"][\"mastery\"] == \"生疏\" and m[\"kpC\"][\"weak\"] is True # 已接触→还没真正解决\n db.close()\n\n\n# ════════ AI 共脑调试 coop(B0,不连 LLM 的部分)════════\n\n@test\ndef coop_建会话_mode为coop且伪pattern_id():\n from app.services import coop\n db = TestSession()\n out = coop.start(db, \"co1\", \"off_by_one\")\n assert out[\"sample_id\"] == \"off_by_one\" and out[\"code\"]\n s = db.get(TutorSession, out[\"session_id\"])\n assert (s.manifest or {}).get(\"mode\") == \"coop\"\n assert s.pattern_id == \"coop_off_by_one\"\n assert s.history and s.history[0][\"role\"] == \"user\" # 首条=样本求助\n db.close()\n\n\n@test\ndef coop_非法样本回退第一个():\n from app.services import coop\n db = TestSession()\n out = coop.start(db, \"co2\", \"不存在的样本\")\n assert out[\"sample_id\"] in coop.SAMPLES\n db.close()\n\n\n@test\ndef coop_run落ExecutionEvent_mode_coop且不查pattern():\n from app.services import coop\n from app.models import ExecutionEvent\n db = TestSession()\n out = coop.start(db, \"co3\", \"off_by_one\")\n # 跑一段真代码(不依赖任何 pattern,伪 id 不会崩)\n r = coop.run(db, out[\"session_id\"], \"print(1+1)\")\n assert r[\"stdout\"].strip() == \"2\"\n ev = db.query(ExecutionEvent).filter_by(session_id=out[\"session_id\"]).one()\n assert ev.knowledge_points == [] # 对画像天然隐形\n assert (ev.meta or {}).get(\"mode\") == \"coop\"\n ctx = (ev.meta or {}).get(\"action_context\") or {}\n assert ctx[\"call_id\"] == r[\"action_context\"][\"call_id\"]\n assert ctx[\"purpose\"] == \"collaborative_debug\" and ctx[\"risk_level\"] == 1\n assert ctx[\"decision\"] == \"allow\" and ctx[\"execution\"] == \"completed\"\n db.close()\n\n\n@test\ndef 续学决策_固定四级优先级且理由可解释():\n active = [{\"session_id\": \"s1\", \"pattern_id\": \"p1\", \"name\": \"未完成题\",\n \"stage\": \"④修复\"}]\n due = [{\"pattern_id\": \"p2\", \"name\": \"复习题\", \"days_since\": 5, \"interval_days\": 2}]\n recs = [{\"id\": \"p3\", \"name\": \"推荐题\", \"reason\": \"补强边界意识\",\n \"knowledge_points\": [\"数组边界\"]}]\n\n resume = learning_path.decide_next_action(active, due, recs)\n assert resume[\"action\"][\"kind\"] == \"resume\"\n assert resume[\"action\"][\"reason_code\"] == \"unfinished_first\"\n assert resume[\"action\"][\"evidence\"][\"ref\"] == \"s1\"\n assert \"修改验证\" in resume[\"action\"][\"detail\"] and \"修复\" not in resume[\"action\"][\"detail\"]\n assert resume[\"pending\"] == {\"active\": 1, \"reviews\": 1, \"recommendations\": 1}\n\n review_action = learning_path.decide_next_action([], due, recs)\n assert review_action[\"action\"][\"kind\"] == \"review\"\n assert review_action[\"action\"][\"target\"][\"mode\"] == \"review\"\n assert \"5 天前\" in review_action[\"action\"][\"detail\"]\n\n recommend = learning_path.decide_next_action([], [], recs)\n assert recommend[\"action\"][\"kind\"] == \"recommend\"\n assert recommend[\"action\"][\"detail\"] == \"补强边界意识\"\n\n start = learning_path.decide_next_action([], [], [])\n assert start[\"action\"][\"kind\"] == \"start\"\n assert start[\"decision_order\"] == [\"resume\", \"review\", \"recommend\", \"start\"]\n\n\n@test\ndef coop_run把真实结果注入对话():\n from app.services import coop, tutor\n db = TestSession()\n out = coop.start(db, \"co4\", \"off_by_one\")\n coop.run(db, out[\"session_id\"], \"print(42)\")\n s = db.get(TutorSession, out[\"session_id\"])\n notes = [m for m in s.history if m[\"content\"].startswith(tutor.RUN_RESULT_PREFIX)]\n assert len(notes) == 1 and \"42\" in notes[0][\"content\"]\n db.close()\n\n\n@test\ndef coop_resolve标completed():\n from app.services import coop\n db = TestSession()\n out = coop.start(db, \"co5\", \"off_by_one\")\n assert coop.resolve(db, out[\"session_id\"])[\"status\"] == \"completed\"\n s = db.get(TutorSession, out[\"session_id\"])\n assert s.status == \"completed\"\n db.close()\n\n\n@test\ndef 开题干预_短TTL缓存命中与清除():\n from app.services import timeline\n db = TestSession()\n timeline.clear_recurring_cache()\n # 首次算并入缓存\n r1 = timeline.recurring_for_student(db, \"cache_u\")\n assert \"cache_u\" in timeline._RECURRING_CACHE\n # TTL 内再取是同一对象(走缓存、没重算)\n r2 = timeline.recurring_for_student(db, \"cache_u\")\n assert r1 is r2, \"TTL 内应返回缓存的同一结果\"\n # 清缓存后重算,得到新对象\n timeline.clear_recurring_cache()\n assert \"cache_u\" not in timeline._RECURRING_CACHE\n r3 = timeline.recurring_for_student(db, \"cache_u\")\n assert r3 is not r1, \"清缓存后应重新计算\"\n timeline.clear_recurring_cache()\n db.close()\n\n\n@test\ndef coop_防污染_不进成长轨迹():\n from app.services import coop, timeline\n db = TestSession()\n out = coop.start(db, \"co6\", \"off_by_one\")\n coop.run(db, out[\"session_id\"], \"print(1)\")\n tl = timeline.build_timeline(db, \"co6\")\n assert tl[\"episodes\"] == [] # coop 会话被 build_timeline 排除\n db.close()\n\n\n@test\ndef coop_防污染_不抬高知识点掌握度():\n from app.services import coop, profile\n db = TestSession()\n out = coop.start(db, \"co7\", \"off_by_one\")\n coop.run(db, out[\"session_id\"], \"print(1)\")\n # coop 的 ExecutionEvent knowledge_points=[] → 不产生任何 kp 掌握度\n assert profile.knowledge_mastery(db, \"co7\") == []\n db.close()\n\n\n@test\ndef coop_错误session_id返回None():\n from app.services import coop\n db = TestSession()\n assert coop.get(db, \"不存在\") is None\n assert coop.run(db, \"不存在\", \"print(1)\") is None\n assert coop.message(db, \"不存在\", \"hi\") is None\n assert coop.resolve(db, \"不存在\") is None\n db.close()\n\n\n@test\ndef coop_自带代码_建custom会话且存code():\n from app.services import coop\n db = TestSession()\n out = coop.start_custom(db, \"cc1\", \"print('hi')\\n\", \"它不输出东西\")\n assert out[\"sample_id\"] is None and out[\"title\"] == \"我的代码\"\n s = db.get(TutorSession, out[\"session_id\"])\n assert (s.manifest or {}).get(\"custom\") is True\n assert s.pattern_id == \"coop_custom\"\n assert s.manifest[\"code\"] == \"print('hi')\" # code 存进 manifest,刷新可恢复\n assert s.history[0][\"content\"] == \"它不输出东西\" # 描述作首条求助\n db.close()\n\n\n@test\ndef coop_自带代码_空代码返回None_超长截断():\n from app.services import coop\n db = TestSession()\n assert coop.start_custom(db, \"cc2\", \" \") is None # 空代码不建会话\n big = \"x = 1\\n\" * 5000 # 远超上限\n out = coop.start_custom(db, \"cc2\", big)\n s = db.get(TutorSession, out[\"session_id\"])\n assert len(s.manifest[\"code\"]) == coop.MAX_CODE_LEN # 截断到上限\n db.close()\n\n\n@test\ndef coop_自带代码_无描述给默认求助():\n from app.services import coop\n db = TestSession()\n out = coop.start_custom(db, \"cc3\", \"print(1)\")\n assert \"看看\" in out[\"ask\"] # 没填描述 → 默认一句求助(导师仍会主动问预期)\n db.close()\n\n\n@test\ndef coop_自带代码_get恢复manifest里的code():\n from app.services import coop\n db = TestSession()\n out = coop.start_custom(db, \"cc4\", \"a = 42\\nprint(a)\")\n got = coop.get(db, out[\"session_id\"])\n assert got[\"code\"] == \"a = 42\\nprint(a)\" and got[\"title\"] == \"我的代码\"\n db.close()\n\n\n@test\ndef coop_自带代码_防污染不进轨迹():\n from app.services import coop, timeline\n db = TestSession()\n out = coop.start_custom(db, \"cc5\", \"print(1)\")\n coop.run(db, out[\"session_id\"], \"print(1)\")\n assert timeline.build_timeline(db, \"cc5\")[\"episodes\"] == []\n db.close()\n\n\n@test\ndef coop_get不能拿到闯关会话():\n \"\"\"coop 的 get 只认 mode=coop 会话,普通闯关 session 一律 None(隔离)。\"\"\"\n from app.services import coop\n db = TestSession()\n s = TutorSession(id=\"normal_sess\", student_id=\"co8\", pattern_id=\"BP-X\",\n manifest={\"mode\": \"debug\", \"mines\": [{}]}, history=[])\n db.add(s); db.commit()\n assert coop.get(db, \"normal_sess\") is None\n db.close()\n\n\n@test\ndef coopB2_埋点_resolve记协作信号且不进画像():\n from app.services import coop\n from app.models import ExecutionEvent, CapabilityScore, Event\n db = TestSession()\n out = coop.start(db, \"b2a\", \"off_by_one\")\n coop.run(db, out[\"session_id\"], \"print(1)\") # 真实动手运行一次\n coop.resolve(db, out[\"session_id\"])\n sig = db.query(ExecutionEvent).filter_by(session_id=out[\"session_id\"], source=\"coop_resolve\").all()\n assert len(sig) == 1 and sig[0].kind == \"COLLAB\"\n assert sig[0].meta[\"collab\"][\"runs\"] == 1\n assert sig[0].knowledge_points == []\n # 埋点不计分:无能力分、无能力事件\n assert db.query(CapabilityScore).filter_by(student_id=\"b2a\").count() == 0\n assert db.query(Event).filter_by(student_id=\"b2a\").count() == 0\n db.close()\n\n\n@test\ndef coopB2_埋点_没动手运行不记():\n from app.services import coop\n from app.models import ExecutionEvent\n db = TestSession()\n out = coop.start(db, \"b2b\", \"off_by_one\")\n coop.resolve(db, out[\"session_id\"]) # 没点过运行 → 不算有效协作\n assert db.query(ExecutionEvent).filter_by(session_id=out[\"session_id\"], source=\"coop_resolve\").count() == 0\n db.close()\n\n\n@test\ndef coopB2_埋点_不进成长轨迹():\n from app.services import coop, timeline\n db = TestSession()\n out = coop.start(db, \"b2c\", \"off_by_one\")\n coop.run(db, out[\"session_id\"], \"print(1)\")\n coop.resolve(db, out[\"session_id\"])\n assert timeline.build_timeline(db, \"b2c\")[\"episodes\"] == []\n db.close()\n\n\n@test\ndef 画像_维度带覆盖度trained_total():\n from app.services import event_engine, profile\n db = TestSession()\n # 只练 1 个 Debug 子能力(共 5 个)→ trained=1 total=5,治 #18 虚高的语境\n for _ in range(3):\n event_engine.emit(db, student_id=\"cov1\", session_id=\"x\", capability=\"Independent_Debug\",\n delta=3, producer=\"rule\", evidence={\"summary\": \"fix\"})\n deb = [d for d in profile.get_profile(db, \"cov1\")[\"dimensions\"] if d[\"name\"] == \"Debug能力\"][0]\n assert deb[\"trained\"] == 1 and deb[\"total\"] == 5, deb\n ai = [d for d in profile.get_profile(db, \"cov1\")[\"dimensions\"] if d[\"name\"] == \"AI协作能力\"][0]\n assert ai[\"trained\"] == 0 and ai[\"total\"] == 3, ai\n\n\n@test\ndef 画像_18虚高修法A_单练不顶满维度():\n from app.services import event_engine, profile\n db = TestSession()\n # 只练独立调试(权重 0.25)刷到封顶,其余 4 个 Debug 子能力零证据\n for _ in range(30):\n event_engine.emit(db, student_id=\"v18\", session_id=\"x\", capability=\"Independent_Debug\",\n delta=3, producer=\"rule\", evidence={\"summary\": \"fix\"})\n deb = [d for d in profile.get_profile(db, \"v18\")[\"dimensions\"] if d[\"name\"] == \"Debug能力\"][0]\n # 修法 A:Independent_Debug=100,全维度权重和=1.0 → 100*0.25/1.0=25,不再虚高到 100\n assert deb[\"score\"] is not None and deb[\"score\"] <= 30, f\"单练一项不该顶满维度,实际 {deb['score']}\"\n # 未练的维度仍是 None(无任何证据不给分)\n ai = [d for d in profile.get_profile(db, \"v18\")[\"dimensions\"] if d[\"name\"] == \"AI协作能力\"][0]\n assert ai[\"score\"] is None, ai\n db.close()\n\n\n@test\ndef 灵犀_新用户无历史_有活动后有天数():\n from app.services import coop, presence\n db = TestSession()\n assert presence.presence_signals(db, \"ling1\") == {\n \"has_history\": False, \"last_active_at\": None, \"days_since\": None}\n # 建个 coop 会话(产生 TutorSession)→ 算\"来过\"\n coop.start(db, \"ling1\", \"off_by_one\")\n p = presence.presence_signals(db, \"ling1\")\n assert p[\"has_history\"] is True and p[\"days_since\"] == 0 # 刚活动 → 0 天\n db.close()\n\n\n# ════════ judge_fix 剩余分支(WA 答案错 / HANG 死循环)════════\n\n@test\ndef judge_跑通但输出不符判WA():\n from app.services import mine_engine\n pat = _good_candidate() # expected_output = \"2\"\n orig = mine_engine.get_pattern\n try:\n mine_engine.get_pattern = lambda pid: {pat[\"id\"]: pat}[pid]\n # 能跑通、不报错,但输出 1 ≠ 期望 2 → WA\n r = mine_engine.judge_fix(pat[\"id\"], \"def f(a):\\n return a[0]\\nprint(f([1, 2]))\\n\")\n assert r[\"kind\"] == \"WA\" and r[\"passed\"] is False, r\n finally:\n mine_engine.get_pattern = orig\n\n\n@test\ndef judge_死循环判HANG():\n from app.services import mine_engine\n pat = _good_candidate()\n orig = mine_engine.get_pattern\n try:\n mine_engine.get_pattern = lambda pid: {pat[\"id\"]: pat}[pid]\n r = mine_engine.judge_fix(pat[\"id\"], \"while True:\\n pass\\n\") # 超时(真跑约 4s)\n assert r[\"kind\"] == \"HANG\" and r[\"passed\"] is False, r\n finally:\n mine_engine.get_pattern = orig\n\n\n# ════════ run_turn 状态机剩余分支(③归因步进 / 止损降档)════════\n\n@test\ndef 跃迁_归因步进_变量追踪到规则对照():\n from app.services import mine_engine, tutor\n o1, o2, o3 = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n # 学生答对当前步(variable_trace)且有进展 → 步进到 rule_compare\n tutor._call_llm = _stub_turn(tutor, attribution_step=\"variable_trace\", student_progressed=True)\n db, sess, pat = _arena_session(tutor, mine_engine, stage=\"③归因\")\n tutor.run_turn(db, sess, \"len 是 3,i 会取到 0,1,2,3\")\n assert sess.attribution_step == \"rule_compare\", sess.attribution_step\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = o1, o2, o3\n\n\n@test\ndef 止损_受挫时提示级别升一格():\n from app.services import mine_engine, tutor\n o1, o2, o3 = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n tutor._call_llm = _stub_turn(tutor, hint_level_used=\"L0\", student_progressed=False)\n db, sess, pat = _arena_session(tutor, mine_engine, stage=\"②定位\")\n assert sess.hint_level == \"L0\"\n tutor.run_turn(db, sess, \"我不会,完全没思路\") # 受挫 → 止损共情层升一格\n assert sess.hint_level == \"L1\", sess.hint_level\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = o1, o2, o3\n\n\n@test\ndef coop_样本库七道且每道可建会话():\n from app.services import coop\n assert len(coop.SAMPLES) == 7, len(coop.SAMPLES)\n assert len(coop.list_samples()) == 7\n db = TestSession()\n for sid in coop.SAMPLES:\n out = coop.start(db, \"samp\", sid)\n assert out[\"sample_id\"] == sid and out[\"code\"] and out[\"title\"] and out[\"ask\"], sid\n db.close()\n\n\n@test\ndef 报错翻译_各错误返回中文_无报错返回None():\n from app.services import tutor\n assert \"死循环\" in (tutor.explain_error(\"HANG\", \"\") or \"\")\n assert tutor.explain_error(\"RE\", \"IndexError: list index out of range\")\n assert \"下一步\" in tutor.explain_error(\"RE\", \"KeyError: 'x'\") # 翻译含\"怎么查\"方向\n assert tutor.explain_error(\"OK\", \"\") is None # 没报错不硬塞\n assert tutor.explain_error(\"RE\", \"完全不认识的乱码\") is None # 识别不了返回 None\n\n\n# ---- 身份 / 越权(M1 D2-3):token 签发可用、坏 token 拒绝、越权访问他人数据 403 ----\n\n@test\ndef token_签发校验round_trip():\n from app.security import sign_token, verify_token\n t = sign_token(\"stu_A\")\n assert verify_token(t) == \"stu_A\" # 正常签发可解回\n assert verify_token(t[:-1] + (\"0\" if t[-1] != \"0\" else \"1\")) is None # 篡改签名 → 拒绝\n assert verify_token(\"garbage\") is None and verify_token(None) is None\n\n\n@test\ndef 越权_看他人画像403():\n from fastapi import HTTPException\n from app.main import get_profile\n db = TestSession()\n try:\n # 我是 stu_A,带自己的 token 去要 stu_B 的画像 → 必须 403\n get_profile(\"stu_B\", db=db, me=\"stu_A\")\n assert False, \"越权访问他人画像未被拦截\"\n except HTTPException as e:\n assert e.status_code == 403\n # 要自己的画像 → 放行(返回结构正常)\n assert get_profile(\"stu_A\", db=db, me=\"stu_A\")[\"student_id\"] == \"stu_A\"\n db.close()\n\n\n@test\ndef 越权_操作他人会话403():\n from fastapi import HTTPException\n from app.main import _get_session\n db = TestSession()\n _mk_active(db, \"owner_X\", \"sess_X\", history=[{\"role\": \"user\", \"content\": \"x\"}])\n try:\n _get_session(db, \"sess_X\", \"intruder_Y\") # 别人的会话\n assert False, \"越权操作他人会话未被拦截\"\n except HTTPException as e:\n assert e.status_code == 403\n assert _get_session(db, \"sess_X\", \"owner_X\").id == \"sess_X\" # 本人放行\n db.close()\n\n\n# ---- 代码快照(M2):diff_stats 计算 + run/submit 双写 ----\n\n@test\ndef 快照_diff计算_首版与增量():\n # 首版(无上一版):非空行全算改动\n d0 = process.compute_diff_stats(None, \"a = 1\\n\\nb = 2\\n\")\n assert d0[\"lines_changed\"] == 2, d0\n # 增量:改 1 行\n d1 = process.compute_diff_stats(\"a = 1\\nb = 2\\n\", \"a = 1\\nb = 3\\n\")\n assert d1[\"lines_changed\"] == 1, d1\n\n\n@test\ndef 快照_touched雷行判定():\n pat = mine_engine.get_pattern(\"BP-BOUNDARY-001\")\n buggy = pat[\"buggy_code\"]\n line = pat[\"mine_location\"][\"line\"]\n # 原样带雷码 → 没动雷行\n d_same = process.compute_diff_stats(None, buggy, buggy, line)\n assert d_same[\"touched_mine_line\"] is False, d_same\n # 把雷行 range(len(arr)+1) 改掉 → 动过雷行\n fixed = buggy.replace(\"range(len(arr) + 1)\", \"range(len(arr))\")\n d_fix = process.compute_diff_stats(None, fixed, buggy, line)\n assert d_fix[\"touched_mine_line\"] is True, d_fix\n # 无 pattern 信息(coop)→ None\n d_none = process.compute_diff_stats(None, \"print(1)\")\n assert d_none[\"touched_mine_line\"] is None, d_none\n\n\n@test\ndef 快照_diff摘要_盲改与定向改():\n # 没动雷行 → 盲改提示\n s_blind = process.diff_summary({\"lines_changed\": 1, \"touched_mine_line\": False})\n assert \"没落在\" in s_blind, s_blind\n # 动了雷行仍不对 → 定向改提示\n s_aim = process.diff_summary({\"lines_changed\": 1, \"touched_mine_line\": True})\n assert \"关键\" in s_aim and \"方向对\" in s_aim, s_aim\n # 大面积改动附注\n assert \"多处试探\" in process.diff_summary({\"lines_changed\": 8, \"touched_mine_line\": False})\n # coop / 无雷信息 → 空串(不打扰)\n assert process.diff_summary({\"lines_changed\": 3, \"touched_mine_line\": None}) == \"\"\n\n\n@test\ndef 快照_record_seq递增且落库():\n db = TestSession()\n s = TutorSession(id=\"snap_s1\", student_id=\"u\", pattern_id=\"BP-BOUNDARY-001\",\n manifest={}, history=[], status=\"active\")\n db.add(s)\n db.commit()\n process.record_snapshot(db, s, \"print(1)\", \"ex_1\")\n process.record_snapshot(db, s, \"print(2)\", \"ex_2\")\n db.commit()\n snaps = (db.query(CodeSnapshot).filter_by(session_id=\"snap_s1\")\n .order_by(CodeSnapshot.seq).all())\n assert [x.seq for x in snaps] == [1, 2], \"seq 应从 1 递增\"\n assert snaps[0].code == \"print(1)\" and snaps[1].code == \"print(2)\"\n assert snaps[1].execution_event_id == \"ex_2\"\n db.close()\n\n\n@test\ndef 教师总览_聚合进度与班级过滤():\n db = TestSession()\n # 两个学生,一个属 A 班、一个属 B 班;进度不同\n db.add(Student(id=\"t_a\", name=\"小A\", class_id=\"classA\"))\n db.add(Student(id=\"t_b\", name=\"小B\", class_id=\"classB\"))\n db.add(KnowledgeState(student_id=\"t_a\", pattern_id=\"BP-BOUNDARY-001\",\n knowledge_points=[], state=\"已内化\"))\n db.add(KnowledgeState(student_id=\"t_a\", pattern_id=\"BP-BOUNDARY-003\",\n knowledge_points=[], state=\"已解决\"))\n db.add(KnowledgeState(student_id=\"t_b\", pattern_id=\"BP-LOOP-001\",\n knowledge_points=[], state=\"已接触\"))\n db.commit()\n allv = teacher.build_class_overview(db)\n assert allv[\"total\"] == 2\n a = next(r for r in allv[\"students\"] if r[\"student_id\"] == \"t_a\")\n assert a[\"solved\"] == 2 and a[\"internalized\"] == 1 and a[\"name\"] == \"小A\"\n b = next(r for r in allv[\"students\"] if r[\"student_id\"] == \"t_b\")\n assert b[\"solved\"] == 0 and b[\"touched\"] == 1\n # 按班过滤\n only_a = teacher.build_class_overview(db, class_id=\"classA\")\n assert only_a[\"total\"] == 1 and only_a[\"students\"][0][\"student_id\"] == \"t_a\"\n db.close()\n\n\n@test\ndef 教师端_共性卡点与学生钻取均为派生():\n db = TestSession()\n for sid in (\"td_a\", \"td_b\"):\n db.add(Student(id=sid, name=sid, class_id=\"C1\"))\n db.add(TutorSession(\n id=f\"sess_{sid}\", student_id=sid, pattern_id=\"BP-BOUNDARY-001\",\n manifest={}, history=[], stage=\"②定位\", status=\"active\",\n ))\n db.commit()\n process.record_message(\n db, \"sess_td_a\", \"tutor\", \"换个例子\",\n meta={\n \"teaching_strategy\": {\"explanation_route\": \"micro_example\", \"route_changed\": True,\n \"knowledge_gap\": \"下标/索引\"},\n \"confusion_detected\": True, \"stage_before\": \"②定位\", \"stage_after\": \"②定位\",\n \"student_progressed\": False,\n },\n )\n db.commit()\n overview = teacher.build_class_overview(db, \"C1\")\n common = overview[\"common_stumbling_blocks\"][0]\n assert common[\"student_count\"] == 2 and common[\"stage\"] == \"②定位\", common\n detail = teacher.build_student_detail(db, \"td_a\")\n assert detail[\"student\"][\"student_id\"] == \"td_a\"\n assert detail[\"sessions\"][0][\"session_id\"] == \"sess_td_a\"\n assert detail[\"teaching_metrics\"][\"route_changed_turns\"] == 1\n assert teacher.build_student_detail(db, \"missing\") is None\n db.close()\n\n\n@test\ndef 课程配置_单元完整且题都真实存在():\n units = curriculum.load_units()\n assert len(units) == 4, f\"应 4 个单元,实际 {len(units)}\"\n lib = mine_engine.load_patterns()\n total = 0\n for u in units:\n assert u[\"patterns\"], f\"{u['id']} 无题\"\n for pid in u[\"patterns\"]:\n assert pid in lib, f\"{u['id']} 配了不存在的题 {pid}\"\n total += 1\n assert total == 21, f\"单元覆盖题数应 21,实际 {total}\"\n\n\n@test\ndef 课程派生_进度反映知识状态():\n db = TestSession()\n sid = \"cur_u\"\n # 一道题标已内化、一道已解决,其余未接触\n db.add(KnowledgeState(student_id=sid, pattern_id=\"BP-BOUNDARY-001\",\n knowledge_points=[], state=\"已内化\"))\n db.add(KnowledgeState(student_id=sid, pattern_id=\"BP-BOUNDARY-003\",\n knowledge_points=[], state=\"已解决\"))\n db.commit()\n cur = curriculum.build_curriculum(db, sid)\n u1 = next(u for u in cur[\"units\"] if u[\"id\"] == \"U1\")\n assert u1[\"internalized\"] == 1 and u1[\"solved\"] == 2, u1 # 已内化也计入 solved\n by_pid = {lv[\"pattern_id\"]: lv for lv in u1[\"levels\"]}\n assert by_pid[\"BP-BOUNDARY-001\"][\"state\"] == \"已内化\"\n assert by_pid[\"BP-BOUNDARY-003\"][\"state\"] == \"已解决\"\n assert by_pid[\"BP-BOUNDARY-002\"][\"state\"] == \"未接触\"\n # 单元顺序按 order\n assert [u[\"id\"] for u in cur[\"units\"]] == [\"U1\", \"U2\", \"U3\", \"U4\"]\n db.close()\n\n\n@test\ndef 回放_合并快照与消息按时间成步():\n import time as _time\n db = TestSession()\n s = TutorSession(id=\"rp_s1\", student_id=\"u\", pattern_id=\"BP-BOUNDARY-001\",\n manifest={}, history=[], status=\"active\")\n db.add(s); db.commit()\n replay_ctx = {\"call_id\": \"call_replay\", \"purpose\": \"debug_observation\",\n \"decision\": \"allow\", \"execution\": \"completed\"}\n ev = event_engine.log_execution(db, student_id=\"u\", session_id=\"rp_s1\",\n pattern_id=\"BP-BOUNDARY-001\", source=\"run\", kind=\"RE\",\n action_context=replay_ctx)\n process.record_snapshot(db, s, \"print(1)\", ev.id)\n process.record_message(db, \"rp_s1\", \"system\", \"(系统·运行结果)报错\", \"run_result\")\n db.commit()\n _time.sleep(0.01)\n process.record_message(db, \"rp_s1\", \"student\", \"我觉得是循环\", \"chat\")\n db.commit()\n rp = process.build_replay(db, \"rp_s1\")\n assert rp[\"code_versions\"] == 1 and rp[\"pattern_name\"]\n kinds = [x[\"kind\"] for x in rp[\"steps\"]]\n assert \"code\" in kinds and kinds.count(\"msg\") == 2, kinds\n code_step = next(x for x in rp[\"steps\"] if x[\"kind\"] == \"code\")\n assert code_step[\"source\"] == \"run\" and code_step[\"result\"] == \"RE\"\n assert code_step[\"action_context\"][\"call_id\"] == \"call_replay\"\n assert rp[\"mainline_steps\"] == 1 and code_step[\"mainline\"] is True\n ats = [x[\"at\"] for x in rp[\"steps\"]]\n assert ats == sorted(ats), \"回放步应按时间升序\"\n assert process.build_replay(db, \"不存在\") is None\n db.close()\n\n\n@test\ndef 回放2_从失败到定向修改再通过标出转折点():\n steps = [\n {\"version\": 1, \"result\": \"RE\", \"result_label\": \"报错\", \"error_family\": \"IndexError\",\n \"diff_stats\": {\"touched_mine_line\": False}},\n {\"version\": 2, \"result\": \"RE\", \"result_label\": \"报错\", \"error_family\": \"TypeError\",\n \"diff_stats\": {\"touched_mine_line\": True}},\n {\"version\": 3, \"result\": \"OK\", \"result_label\": \"通过\", \"error_family\": None,\n \"diff_stats\": {\"touched_mine_line\": True}},\n ]\n points = process.derive_turning_points(steps)\n types = [point[\"type\"] for point in points]\n assert types == [\"first_failure\", \"targeted_change\", \"error_changed\", \"breakthrough\"], types\n assert steps[2][\"annotations\"][0][\"type\"] == \"breakthrough\"\n\n\n@test\ndef 回放主线_只归拢真实动作转折观察总结与换路():\n steps = [\n {\"kind\": \"msg\", \"role\": \"tutor\", \"content\": \"先看整体\", \"meta\": {}},\n {\"kind\": \"code\", \"version\": 1, \"annotations\": []},\n {\"kind\": \"msg\", \"role\": \"student\", \"content\": \"【观察记录】\\n我观察到:报错\", \"meta\": {}},\n {\"kind\": \"msg\", \"role\": \"tutor\", \"content\": \"换个例子\", \"meta\": {\n \"teaching_strategy\": {\"route_changed\": True, \"explanation_route\": \"micro_example\"},\n }},\n {\"kind\": \"code\", \"version\": 2, \"annotations\": [\n {\"type\": \"breakthrough\", \"title\": \"关键突破\", \"detail\": \"通过\"},\n ]},\n {\"kind\": \"msg\", \"role\": \"student\", \"content\": \"【思考总结】\\n我想记住:先验证\", \"meta\": {}},\n {\"kind\": \"msg\", \"role\": \"system\", \"content\": \"完成\", \"meta\": {}},\n ]\n count = process.derive_replay_mainline(steps)\n assert count == 5\n assert not steps[0][\"mainline\"] and not steps[-1][\"mainline\"]\n assert steps[1][\"mainline_reasons\"] == [\"first_execution\"]\n assert steps[2][\"mainline_reasons\"] == [\"student_observation\"]\n assert steps[3][\"mainline_reasons\"] == [\"teaching_route_changed\"]\n assert steps[4][\"mainline_reasons\"] == [\"turning_point\", \"latest_execution\"]\n assert steps[5][\"mainline_reasons\"] == [\"student_summary\"]\n\n\n@test\ndef 消息时间线_record_seq递增且落库():\n db = TestSession()\n s = TutorSession(id=\"msg_s1\", student_id=\"u\", pattern_id=\"BP-BOUNDARY-001\",\n manifest={}, history=[], status=\"active\")\n db.add(s); db.commit()\n process.record_message(db, \"msg_s1\", \"student\", \"我觉得循环错了\", \"chat\")\n process.record_message(db, \"msg_s1\", \"tutor\", \"为什么这么认为?\", \"chat\",\n meta={\"teaching_strategy\": {\"explanation_route\": \"skeleton\"}})\n process.record_message(db, \"msg_s1\", \"system\", \"(系统·运行结果)报错\", \"run_result\")\n db.commit()\n msgs = (db.query(SessionMessage).filter_by(session_id=\"msg_s1\")\n .order_by(SessionMessage.seq).all())\n assert [m.seq for m in msgs] == [1, 2, 3], \"seq 应从 1 递增\"\n assert [m.role for m in msgs] == [\"student\", \"tutor\", \"system\"]\n assert msgs[2].kind == \"run_result\"\n assert msgs[1].meta[\"teaching_strategy\"][\"explanation_route\"] == \"skeleton\"\n db.close()\n\n\n@test\ndef 教学指标_困惑后两轮内恢复可重放计算():\n db = TestSession()\n s = TutorSession(id=\"metric_s1\", student_id=\"u\", pattern_id=\"BP-BOUNDARY-001\",\n manifest={}, history=[], status=\"active\")\n db.add(s); db.commit()\n process.record_message(db, s.id, \"tutor\", \"换个小例子\", meta={\n \"teaching_strategy\": {\"explanation_route\": \"micro_example\", \"route_changed\": True,\n \"knowledge_gap\": \"下标/索引\"},\n \"confusion_detected\": True, \"stage_before\": \"②定位\", \"stage_after\": \"②定位\",\n \"student_progressed\": False,\n })\n process.record_message(db, s.id, \"tutor\", \"沿执行链再走一次\", meta={\n \"teaching_strategy\": {\"explanation_route\": \"execution_trace\", \"route_changed\": False,\n \"knowledge_gap\": None},\n \"confusion_detected\": False, \"stage_before\": \"②定位\", \"stage_after\": \"③归因\",\n \"student_progressed\": True,\n })\n db.commit()\n metrics = process.teaching_metrics(db, s.id)\n assert metrics[\"tutor_turns\"] == 2 and metrics[\"confusion_turns\"] == 1\n assert metrics[\"recovered_within_two_turns\"] == 1 and metrics[\"recovery_rate\"] == 1.0\n assert metrics[\"knowledge_gap_turns\"] == 1 and metrics[\"routes\"][\"micro_example\"] == 1\n db.close()\n\n\n# ---- 生产安全门槛 / 教师认证头 ----\n@test\ndef 生产安全门槛_生产缺隔离或秘密时失败关闭():\n from app import runtime_security\n keys = (\"ACP_ENV\", \"ACP_SANDBOX\", \"ACP_ADMIN_TOKEN\", \"ACP_AUTH_SECRET\", \"DEEPSEEK_API_KEY\")\n old_env = {key: os.environ.get(key) for key in keys}\n old_check = runtime_security.sandbox.docker_available\n try:\n os.environ[\"ACP_ENV\"] = \"production\"\n os.environ[\"ACP_SANDBOX\"] = \"subprocess\"\n for key in keys[2:]:\n os.environ.pop(key, None)\n runtime_security.sandbox.docker_available = lambda: False\n try:\n runtime_security.validate_production_environment()\n assert False, \"生产环境未隔离时必须拒绝启动\"\n except RuntimeError as exc:\n assert \"production_security_gate\" in str(exc) and \"Docker\" in str(exc)\n\n os.environ[\"ACP_SANDBOX\"] = \"docker\"\n for key in keys[2:]:\n os.environ[key] = \"configured-for-test\"\n runtime_security.sandbox.docker_available = lambda: True\n assert runtime_security.validate_production_environment()[\"ready\"] is True\n finally:\n runtime_security.sandbox.docker_available = old_check\n for key, value in old_env.items():\n if value is None:\n os.environ.pop(key, None)\n else:\n os.environ[key] = value\n\n\n@test\ndef 教师认证_优先Bearer且错误口令拒绝():\n from fastapi import HTTPException\n from app.main import _check_admin\n old = os.environ.get(\"ACP_ADMIN_TOKEN\")\n try:\n os.environ[\"ACP_ADMIN_TOKEN\"] = \"teacher-secret\"\n _check_admin(authorization=\"Bearer teacher-secret\")\n try:\n _check_admin(authorization=\"Bearer wrong\")\n assert False, \"错误教师口令必须拒绝\"\n except HTTPException as exc:\n assert exc.status_code == 403\n finally:\n if old is None:\n os.environ.pop(\"ACP_ADMIN_TOKEN\", None)\n else:\n os.environ[\"ACP_ADMIN_TOKEN\"] = old\n\n\n# ---- 会话回合锁(M1 D4-5):串行化同会话并发,防丢写 + 堵 TOCTOU ----\n\n@test\ndef 会话锁_同会话串行无丢写():\n import threading\n import time\n from app.concurrency import session_turn\n # 模拟 history 的「读整块→改→写整块」竞态:无锁时并发会互相覆盖、丢元素\n shared = {\"list\": []}\n\n def worker(val):\n with session_turn(\"sessX\"):\n cur = list(shared[\"list\"]) # 读\n time.sleep(0.02) # 放大竞态窗口\n shared[\"list\"] = cur + [val] # 整块写回\n\n threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]\n for t in threads:\n t.start()\n for t in threads:\n t.join()\n assert len(shared[\"list\"]) == 5, f\"同会话应串行、无丢写,实际 {shared['list']}\"\n\n\n@test\ndef 会话锁_不同会话用不同锁():\n from app.concurrency import _lock_for\n assert _lock_for(\"a\") is _lock_for(\"a\") # 同 id 复用同一把锁\n assert _lock_for(\"a\") is not _lock_for(\"b\") # 不同 id 不互相阻塞\n\n\n# ---- 沙箱隔离测试组(M1 D1):显式打 docker 后端,验证学生代码逃不出容器。\n# docker 不可用时整组跳过——本地无 docker 的开发机照样能跑主套件。----\n\nclass _SkipTest(Exception):\n \"\"\"docker 不可用:跳过该测试(不计失败)。\"\"\"\n\n\ndef _docker_run(code, timeout=4.0):\n \"\"\"强制用 docker 后端跑一段代码;docker 不可用则抛 _SkipTest。\"\"\"\n old = os.environ.get(\"ACP_SANDBOX\")\n os.environ[\"ACP_SANDBOX\"] = \"auto\"\n try:\n if sandbox.active_backend() != \"docker\":\n raise _SkipTest(\"docker 不可用\")\n os.environ[\"ACP_SANDBOX\"] = \"docker\"\n return sandbox.run_code(code, timeout)\n finally:\n if old is None:\n os.environ.pop(\"ACP_SANDBOX\", None)\n else:\n os.environ[\"ACP_SANDBOX\"] = old\n\n\n@test\ndef 沙箱docker_正常代码照跑():\n r = _docker_run(\"print(sum([1,2,3]))\")\n assert r.stdout.strip() == \"6\" and not r.has_error\n\n\n@test\ndef 沙箱docker_读不到宿主密钥():\n r = _docker_run(\"import os; print(os.environ.get('DEEPSEEK_API_KEY'))\")\n assert r.stdout.strip() == \"None\" # 容器环境里没有密钥\n\n\n@test\ndef 沙箱docker_读不到宿主acp_db():\n r = _docker_run(\"import os; print(os.path.exists('/data/acp.db'), os.path.exists('acp.db'))\")\n assert r.stdout.strip() == \"False False\" # 宿主数据库不可见\n\n\n@test\ndef 沙箱docker_无网络():\n r = _docker_run(\"import socket; socket.create_connection(('1.1.1.1',53),2); print('CONNECTED')\")\n assert \"CONNECTED\" not in r.stdout and r.has_error # 连不出去\n\n\n@test\ndef 沙箱docker_根文件系统只读():\n r = _docker_run(\"open('/evil.txt','w').write('x'); print('WROTE')\")\n assert \"WROTE\" not in r.stdout and r.has_error # 根只读,写不进去\n\n\n@test\ndef 沙箱docker_死循环超时():\n r = _docker_run(\"while True: pass\", timeout=3.0)\n assert r.timed_out\n\n\n# ════════ 经历记忆(第二层记忆:结算沉淀 + 相关度召回 + 泄题门控)════════\n\ndef _exp_pattern(pid, name, root_cause=\"下标 3 越界\"):\n p = dict(_good_candidate())\n p.update(id=pid, name=name, root_cause=root_cause)\n return p\n\n\ndef _exp_session(tutor, mine_engine, patterns, *, student_id, pattern_id,\n stage, mine_status=\"planted\", mode=None):\n \"\"\"自定学生/题目的会话(_arena_session 硬编码 rt1,经历测试需隔离学生)。\"\"\"\n mine_engine.get_pattern = lambda pid: patterns[pid]\n mine_engine.load_patterns = lambda: patterns\n from app.models import TutorSession\n db = TestSession()\n manifest = mine_engine.build_manifest(student_id, patterns[pattern_id])\n if mode:\n manifest[\"mode\"] = mode\n sess = TutorSession(id=f\"s_exp_{__import__('uuid').uuid4().hex[:6]}\",\n student_id=student_id, pattern_id=pattern_id,\n manifest=manifest, history=[], stage=stage, mine_status=mine_status)\n db.add(sess); db.commit()\n return db, sess\n\n\n@test\ndef 经历检索_内容词召回且纯虚词不算():\n from app.services import experience\n s = experience.relevance_score(\"数组下标越界 IndexError\", \"他在《测试题》踩了 IndexError,根因是数组下标越界\")\n assert s >= experience.RELEVANCE_FLOOR, f\"内容词高度重叠应过门槛: {s}\"\n s2 = experience.relevance_score(\"我觉得可能是不知道怎么了\", \"他自己就是这个样子的没什么\")\n assert s2 < experience.RELEVANCE_FLOOR, f\"纯虚词撞上不算相关: {s2}\"\n\n\n@test\ndef 经历检索_跑题记忆宁可空手():\n from app.services import experience\n s = experience.relevance_score(\"除以零 ZeroDivisionError 分母 平均数\",\n \"在《测试题》遇到 IndexError,根因是下标 3 越界\")\n assert s < experience.RELEVANCE_FLOOR, f\"不相关经历不应过门槛: {s}\"\n\n\n@test\ndef 经历沉淀_内化结算落库且摘要不含修复代码():\n from app.models import StudentExperience\n from app.services import mine_engine, tutor\n o1, o2, o3 = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n pats = {p[\"id\"]: p for p in [_exp_pattern(\"BP-EXP-001\", \"经历题甲\")]}\n tutor._call_llm = _stub_turn(\n tutor, internalization=tutor.InternalizationScore(cause=True, locate=True, prevent=False))\n db, sess = _exp_session(tutor, mine_engine, pats, student_id=\"exp1\",\n pattern_id=\"BP-EXP-001\", stage=\"⑥内化\", mine_status=\"fixed\")\n tutor.run_turn(db, sess, \"因为下标越界,定位靠追踪变量取值,下次先查边界\")\n assert sess.status == \"completed\"\n exp = db.query(StudentExperience).filter_by(student_id=\"exp1\").one()\n assert \"经历题甲\" in exp.summary and \"下标 3 越界\" in exp.summary, exp.summary\n assert \"成因机制\" in exp.summary and \"定位方法\" in exp.summary, exp.summary\n # 落库端泄题硬门控:摘要与关键词绝不含 expected_fix 片段\n fix = pats[\"BP-EXP-001\"][\"expected_fix\"]\n assert fix not in exp.summary and all(fix not in k for k in exp.keywords), \"摘要泄了修复代码\"\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = o1, o2, o3\n\n\n@test\ndef 经历召回_新题注入过往经历且同题排除():\n from app.services import experience, mine_engine, tutor\n o1, o2, o3 = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n pats = {p[\"id\"]: p for p in [\n _exp_pattern(\"BP-EXP-011\", \"经历题乙\"), _exp_pattern(\"BP-EXP-012\", \"经历题丙\")]}\n # 先内化完成\"经历题乙\"沉淀一条经历\n tutor._call_llm = _stub_turn(\n tutor, internalization=tutor.InternalizationScore(cause=True, locate=True, prevent=True))\n db, sess = _exp_session(tutor, mine_engine, pats, student_id=\"exp2\",\n pattern_id=\"BP-EXP-011\", stage=\"⑥内化\", mine_status=\"fixed\")\n tutor.run_turn(db, sess, \"因为下标越界,定位靠追踪变量,下次先查边界\")\n\n # 换一道同族新题:system 应注入过往经历(带硬规则),召回按相关度过门槛\n captured = {}\n stub = _stub_turn(tutor)\n tutor._call_llm = lambda system, history: (captured.__setitem__(\"sys\", system), stub(system, history))[1]\n db2, sess2 = _exp_session(tutor, mine_engine, pats, student_id=\"exp2\",\n pattern_id=\"BP-EXP-012\", stage=\"①发现\")\n tutor.run_turn(db2, sess2, \"程序好像不太对\")\n assert \"过往共同经历\" in captured[\"sys\"] and \"经历题乙\" in captured[\"sys\"], \"应召回过往经历\"\n assert \"绝不点破\" in captured[\"sys\"], \"注入段应携带泄题硬规则\"\n\n # 同题重开:exclude_pattern_id 应排除,system 无经历段(变式不剧透)\n captured.clear()\n db3, sess3 = _exp_session(tutor, mine_engine, pats, student_id=\"exp2\",\n pattern_id=\"BP-EXP-011\", stage=\"①发现\")\n tutor.run_turn(db3, sess3, \"程序好像不太对\")\n assert \"过往共同经历\" not in captured[\"sys\"], \"同题经历不得召回(防变式剧透)\"\n db.close(); db2.close(); db3.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = o1, o2, o3\n\n\n@test\ndef 经历隔离_coop会话不沉淀():\n from app.services import experience, mine_engine, tutor\n o1, o2 = mine_engine.get_pattern, mine_engine.load_patterns\n try:\n pats = {p[\"id\"]: p for p in [_exp_pattern(\"BP-EXP-021\", \"经历题丁\")]}\n db, sess = _exp_session(tutor, mine_engine, pats, student_id=\"exp3\",\n pattern_id=\"BP-EXP-021\", stage=\"⑥内化\",\n mine_status=\"fixed\", mode=\"coop\")\n assert experience.record_on_completion(db, sess) is None, \"coop 会话不得沉淀经历\"\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns = o1, o2\n\n\n@test\ndef 兜底升级_LLM挂掉时按阶段给确定性引导():\n from app.services import mine_engine, tutor\n o1, o2, o3 = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n def boom(system, history):\n raise RuntimeError(\"api down\")\n tutor._call_llm = boom\n db, sess = _exp_session(tutor, mine_engine,\n {p[\"id\"]: p for p in [_exp_pattern(\"BP-EXP-031\", \"经历题戊\")]},\n student_id=\"exp4\", pattern_id=\"BP-EXP-031\", stage=\"③归因\")\n sess.attribution_step = \"variable_trace\"\n r = tutor.run_turn(db, sess, \"然后呢\")\n assert tutor._SAFE_STAGE_QUESTIONS[\"③归因\"] in r[\"reply\"], r[\"reply\"] # 不再只说\"走神\"\n assert sess.stage == \"③归因\" and r[\"events_emitted\"] == [], \"兜底轮状态不变、不落事件\"\n db.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = o1, o2, o3\n\n\ndef _snap(db, session_id, seq, touched):\n \"\"\"造一条只带 touched_mine_line 判定的快照(过程旁白测试用)。\"\"\"\n db.add(CodeSnapshot(id=f\"cs_strug_{session_id}_{seq}\", session_id=session_id, seq=seq,\n code=\"x\", diff_stats={\"lines_changed\": 1, \"touched_mine_line\": touched},\n timestamp=f\"2026-07-16T00:0{seq}:00+00:00\"))\n\n\n@test\ndef 过程旁白_同题重开与试探开局信号():\n from app.services import process\n from app.models import TutorSession\n db = TestSession()\n # 同题重开:strug1 在 P1 上有 2 局没走完\n for i, stage in enumerate((\"②定位\", \"②定位\")):\n db.add(TutorSession(id=f\"s_strug_p1_{i}\", student_id=\"strug1\", pattern_id=\"P-STRUG-1\",\n manifest={\"mines\": []}, history=[], stage=stage, mine_status=\"planted\"))\n # 试探开局惯性:另两题各有失败后两版才碰关键行的快照链\n for i in range(2):\n sid = f\"s_strug_o{i}\"\n db.add(TutorSession(id=sid, student_id=\"strug1\", pattern_id=f\"P-STRUG-O{i}\",\n manifest={\"mines\": []}, history=[], stage=\"④修复\", mine_status=\"fixed\"))\n for seq, touched in enumerate((False, False, True), start=1):\n _snap(db, sid, seq, touched)\n db.commit()\n text = process.recent_struggle_signal(db, \"strug1\", \"P-STRUG-1\", exclude_session_id=\"cur\")\n assert \"第 3 次\" in text and \"②定位\" in text, f\"应报同题重开与停留位置: {text}\"\n assert \"先读证据\" in text, f\"应报试探开局惯性: {text}\"\n assert process.recent_struggle_signal(db, \"strug_fresh\", \"P-STRUG-1\") == \"\", \"无历史必须零信号\"\n # 只有 1 局没走完 → 不算重复卡点;定向开局的样本不凑惯性\n db.add(TutorSession(id=\"s_strug_p2_0\", student_id=\"strug2\", pattern_id=\"P-STRUG-2\",\n manifest={\"mines\": []}, history=[], stage=\"②定位\", mine_status=\"planted\"))\n sid = \"s_strug2_o0\"\n db.add(TutorSession(id=sid, student_id=\"strug2\", pattern_id=\"P-STRUG-O9\",\n manifest={\"mines\": []}, history=[], stage=\"④修复\", mine_status=\"fixed\"))\n for seq, touched in enumerate((True, False), start=1):\n _snap(db, sid, seq, touched)\n db.commit()\n assert process.recent_struggle_signal(db, \"strug2\", \"P-STRUG-2\") == \"\", \"单局卡壳/定向开局不该报信号\"\n db.close()\n\n\n@test\ndef 过程旁白_注入导师system且带硬规则():\n from app.services import mine_engine, tutor\n from app.models import TutorSession\n o1, o2, o3 = mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm\n try:\n pats = {p[\"id\"]: p for p in [_exp_pattern(\"BP-STRUG-011\", \"旁白题甲\")]}\n captured = {}\n stub = _stub_turn(tutor)\n tutor._call_llm = lambda system, history: (captured.__setitem__(\"sys\", system), stub(system, history))[1]\n # 造 2 局没走完的历史,再开第 3 局\n db, sess = _exp_session(tutor, mine_engine, pats, student_id=\"strug3\",\n pattern_id=\"BP-STRUG-011\", stage=\"①发现\")\n for i in range(2):\n db.add(TutorSession(id=f\"s_strug3_{i}\", student_id=\"strug3\", pattern_id=\"BP-STRUG-011\",\n manifest=sess.manifest, history=[], stage=\"②定位\", mine_status=\"planted\"))\n db.commit()\n tutor.run_turn(db, sess, \"程序好像不太对\")\n assert \"过程旁白\" in captured[\"sys\"] and \"第 3 次\" in captured[\"sys\"], \"应注入跨局旁白\"\n assert \"绝不向学生照念\" in captured[\"sys\"], \"旁白必须带不照念硬规则\"\n assert \"不得据此跳过或放松任何阶段门控\" in captured[\"sys\"], \"旁白必须带门控硬规则\"\n # 无历史学生:零注入,行为与现状一致\n captured.clear()\n db2, sess2 = _exp_session(tutor, mine_engine, pats, student_id=\"strug4\",\n pattern_id=\"BP-STRUG-011\", stage=\"①发现\")\n tutor.run_turn(db2, sess2, \"程序好像不太对\")\n assert \"过程旁白\" not in captured[\"sys\"], \"无信号必须零注入\"\n db.close(); db2.close()\n finally:\n mine_engine.get_pattern, mine_engine.load_patterns, tutor._call_llm = o1, o2, o3\n\n\n@test\ndef 足迹周报_窗口聚合卡点与进步():\n from datetime import datetime, timedelta, timezone as _tz\n from app.models import SessionMessage as SM, TutorSession as TS\n recent_ts = datetime.now(_tz.utc).isoformat()\n old_ts = (datetime.now(_tz.utc) - timedelta(days=30)).isoformat()\n # 两个 Episode:一个本周已内化(快照证据=试探开局),一个 30 天前的(必须被窗口排除)\n episodes = [\n {\"pattern_id\": \"BP-NULL-002\", \"pattern_name\": \"方法返回None未检查\", \"session_id\": \"fp_s1\",\n \"outcome\": \"已内化\", \"last_at\": recent_ts,\n \"rounds\": [{\"day\": recent_ts[:10], \"attempts\": [{\"time\": recent_ts}, {\"time\": recent_ts}]}]},\n {\"pattern_id\": \"BP-NULL-003\", \"pattern_name\": \"字典取值未判空\", \"session_id\": \"fp_s2\",\n \"outcome\": \"已解决\", \"last_at\": old_ts,\n \"rounds\": [{\"day\": old_ts[:10], \"attempts\": [{\"time\": old_ts}]}]},\n ]\n db = TestSession()\n db.add(TS(id=\"fp_tut1\", student_id=\"fp1\", pattern_id=\"BP-NULL-002\",\n manifest={\"mines\": []}, history=[], stage=\"②定位\", mine_status=\"planted\"))\n db.add(SM(id=\"sm_fp1\", session_id=\"fp_tut1\", seq=1, role=\"tutor\", kind=\"chat\", content=\"x\",\n meta={\"support_mode\": True, \"stage_before\": \"②定位\"}, created_at=recent_ts))\n for seq, touched in enumerate((False, False, True), start=1):\n _snap(db, \"fp_s1\", seq, touched)\n db.commit()\n fp = timeline.debug_footprint(db, \"fp1\", episodes)\n assert fp[\"enough\"] and fp[\"attempts\"] == 2 and fp[\"patterns_touched\"] == 1, \\\n f\"30 天前的尝试必须被窗口排除: {fp}\"\n assert fp[\"internalized\"] == [\"方法返回None未检查\"], fp\n assert fp[\"stuck_step\"] == \"定位原因\" and fp[\"stuck_count\"] == 1, \"卡点应翻成四步语言\"\n assert fp[\"targeted_progress\"] == [\"方法返回None未检查\"], \"试探开局且解决的题应报进步\"\n assert fp[\"loosened\"] and \"正常\" in fp[\"loosened\"][0][\"name\"], \"高频簇内化应报松动\"\n assert timeline.debug_footprint(db, \"fp_empty\", [])[\"enough\"] is False, \"无活动 enough=False\"\n db.close()\n\n\n# 认知先验层测试组:在 test_priors.py 独立维护(假 LLM、零外部依赖、可单跑),\n# 这里并入主套件一起跑。顶部已设 ACP_PRIORS=off,结算路径不打真网。\nimport test_priors as _priors # noqa: E402\n_tests.extend(_priors._tests)\n\n\ndef main():\n passed = failed = skipped = 0\n for fn in _tests:\n try:\n fn()\n print(f\"[PASS] {fn.__name__}\")\n passed += 1\n except _SkipTest as e:\n print(f\"[SKIP] {fn.__name__}: {e}\")\n skipped += 1\n except Exception as e:\n print(f\"[FAIL] {fn.__name__}: {e}\")\n traceback.print_exc()\n failed += 1\n print(f\"\\n{passed} 过 / {failed} 败 / {skipped} 跳过 / 共 {len(_tests)}\")\n sys.exit(1 if failed else 0)\n\n\nif __name__ == \"__main__\":\n main()\n"}}}}