第一次提交
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user