第一次提交

This commit is contained in:
2026-08-01 16:50:55 +08:00
commit c8f9e39039
68 changed files with 6016 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
"""作答记录与按需判分。
笔记本的核心行为:提交答案只是记录,判分是另一个动作。
"""
import json
from unittest.mock import patch
import pytest
from app.services.llm.client import LLMResult
SINGLE = {
"type": "single_choice",
"stem_markdown": "2+2=?",
"options": [
{"key": "A", "text_markdown": "3"},
{"key": "B", "text_markdown": "4"},
],
"correct_answer": ["B"],
"tags": ["算术"],
}
MULTI = {
"type": "multiple_choice",
"stem_markdown": "哪些是偶数?",
"options": [
{"key": "A", "text_markdown": "2"},
{"key": "B", "text_markdown": "3"},
{"key": "C", "text_markdown": "4"},
],
"correct_answer": ["A", "C"],
}
# 拍照记下来的样子:没题型、没答案
CAPTURED = {"type": "unclassified", "stem_markdown": "随手记下的一道题"}
async def _create(client, headers, payload):
r = await client.post("/api/questions", json=payload, headers=headers)
assert r.status_code == 201
return r.json()["id"]
async def _attempt(client, headers, qid, answer):
r = await client.post(
f"/api/questions/{qid}/attempts", json={"user_answer": answer}, headers=headers
)
assert r.status_code == 201
return r.json()
class TestAttemptRecording:
"""提交答案 = 只记录,不判分、不碰 AI。"""
async def test_attempt_is_recorded_unjudged(self, client, auth_headers):
h = await auth_headers()
qid = await _create(client, h, SINGLE)
out = await _attempt(client, h, qid, ["B"])
assert out["is_correct"] is None, "提交时不该自动判分"
assert out["judged_by"] == "self"
assert out["user_answer"] == ["B"]
async def test_attempt_works_without_ai_configured(self, client, auth_headers):
"""回归:主观题在未配 AI 时也必须能记录,答案不能丢。
改造前这里会返回 400 且不写任何记录,用户输入直接丢失。
"""
h = await auth_headers()
qid = await _create(client, h, {"type": "short_answer", "stem_markdown": "简述惯性"})
out = await _attempt(client, h, qid, ["物体保持原有运动状态的性质"])
assert out["is_correct"] is None
history = (await client.get(f"/api/questions/{qid}/attempts", headers=h)).json()
assert len(history) == 1
assert history[0]["user_answer"] == ["物体保持原有运动状态的性质"]
async def test_attempt_on_captured_note(self, client, auth_headers):
"""未分类、无答案的笔记也能作答记录。"""
h = await auth_headers()
qid = await _create(client, h, CAPTURED)
out = await _attempt(client, h, qid, ["我的解法"])
assert out["is_correct"] is None
async def test_history_newest_first(self, client, auth_headers):
h = await auth_headers()
qid = await _create(client, h, SINGLE)
await _attempt(client, h, qid, ["A"])
await _attempt(client, h, qid, ["B"])
history = (await client.get(f"/api/questions/{qid}/attempts", headers=h)).json()
assert [x["user_answer"] for x in history] == [["B"], ["A"]]
async def test_cannot_attempt_others_note(self, client, auth_headers):
ha = await auth_headers("alice")
hb = await auth_headers("bob")
qid = await _create(client, ha, SINGLE)
r = await client.post(
f"/api/questions/{qid}/attempts", json={"user_answer": ["B"]}, headers=hb
)
assert r.status_code == 404
class TestLocalJudging:
"""有标准答案 → 本地精确判,不花 AI 额度。"""
@pytest.mark.parametrize(
"answer,expected",
[(["B"], True), (["A"], False), ([], False)],
)
async def test_single_choice(self, client, auth_headers, answer, expected):
h = await auth_headers()
qid = await _create(client, h, SINGLE)
a = await _attempt(client, h, qid, answer)
r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h)
assert r.status_code == 200
assert r.json()["is_correct"] is expected
assert r.json()["judged_by"] == "auto"
@pytest.mark.parametrize(
"answer,expected",
[
(["A", "C"], True),
(["C", "A"], True), # 顺序无关
(["a", "c"], True), # 大小写无关
(["A"], False), # 少选
(["A", "B", "C"], False), # 多选
],
)
async def test_multiple_choice(self, client, auth_headers, answer, expected):
h = await auth_headers()
qid = await _create(client, h, MULTI)
a = await _attempt(client, h, qid, answer)
r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h)
assert r.json()["is_correct"] is expected
async def test_judging_reveals_correct_answer(self, client, auth_headers):
h = await auth_headers()
qid = await _create(client, h, SINGLE)
a = await _attempt(client, h, qid, ["A"])
r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h)
assert r.json()["correct_answer"] == ["B"]
async def test_objective_without_answer_needs_ai(self, client, auth_headers):
"""客观题但没录标准答案 → 判不了,落到 AI 分支。
关键:不该像改造前那样静默算错。
"""
h = await auth_headers()
qid = await _create(
client,
h,
{
"type": "single_choice",
"stem_markdown": "没录答案的选择题",
"options": [{"key": "A", "text_markdown": "x"}],
},
)
a = await _attempt(client, h, qid, ["A"])
r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h)
# 未配 AI → 400,但作答记录仍在,且未被误判为错
assert r.status_code == 400
history = (await client.get(f"/api/questions/{qid}/attempts", headers=h)).json()
assert history[0]["is_correct"] is None
async def test_cannot_judge_others_attempt(self, client, auth_headers):
ha = await auth_headers("alice")
hb = await auth_headers("bob")
qid = await _create(client, ha, SINGLE)
a = await _attempt(client, ha, qid, ["B"])
r = await client.post(f"/api/attempts/{a['id']}/judge", headers=hb)
assert r.status_code == 404
class TestSelfJudging:
"""自评:我自己知道对错,不用花 AI 额度。"""
async def test_self_judge(self, client, auth_headers):
h = await auth_headers()
qid = await _create(client, h, CAPTURED)
a = await _attempt(client, h, qid, ["我的解法"])
r = await client.post(
f"/api/attempts/{a['id']}/self-judge", json={"is_correct": False}, headers=h
)
assert r.status_code == 200
assert r.json()["is_correct"] is False
assert r.json()["judged_by"] == "self"
async def test_self_judged_wrong_enters_review(self, client, auth_headers):
h = await auth_headers()
qid = await _create(client, h, CAPTURED)
a = await _attempt(client, h, qid, ["错的解法"])
await client.post(
f"/api/attempts/{a['id']}/self-judge", json={"is_correct": False}, headers=h
)
review = (await client.get("/api/notebook/review", headers=h)).json()
assert len(review) == 1
assert review[0]["question"]["id"] == qid
assert review[0]["last_wrong"] is True
class TestAiJudging:
"""没有标准答案 → 交给 AI 判(用户主动触发)。"""
async def test_ai_judge(self, client, auth_headers, configure_ai):
h = await auth_headers()
await configure_ai(h)
qid = await _create(client, h, {"type": "short_answer", "stem_markdown": "简述惯性"})
a = await _attempt(client, h, qid, ["物体保持原有状态"])
payload = json.dumps(
{
"is_correct": True,
"feedback_markdown": "答得不错",
"key_points_missed": [],
}
)
async def fake(self, system, user, json_mode=False, model=None):
return LLMResult(payload, "text-model", 10, 20)
with patch("app.services.llm.client.LLMClient.chat_text", fake):
r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h)
assert r.status_code == 200
assert r.json()["is_correct"] is True
assert r.json()["judged_by"] == "ai"
assert r.json()["feedback_markdown"] == "答得不错"
async def test_ai_judge_wrong_enters_review(self, client, auth_headers, configure_ai):
h = await auth_headers()
await configure_ai(h)
qid = await _create(client, h, {"type": "short_answer", "stem_markdown": "简述惯性"})
a = await _attempt(client, h, qid, ["不知道"])
payload = json.dumps(
{
"is_correct": False,
"feedback_markdown": "遗漏关键点",
"key_points_missed": ["惯性定义"],
}
)
async def fake(self, system, user, json_mode=False, model=None):
return LLMResult(payload, "text-model", 10, 20)
with patch("app.services.llm.client.LLMClient.chat_text", fake):
r = await client.post(f"/api/attempts/{a['id']}/judge", headers=h)
assert r.json()["key_points_missed"] == ["惯性定义"]
review = (await client.get("/api/notebook/review", headers=h)).json()
assert len(review) == 1