{"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"}}}}