Files
2026-08-01 16:50:55 +08:00

209 lines
8.2 KiB
Python

"""AI 讲解/知识点,以及用户 AI 配置。判分相关见 test_grading.py。"""
import json
from unittest.mock import patch
from app.services.llm.client import LLMResult
SUBJECTIVE = {
"type": "short_answer",
"stem_markdown": "简述牛顿第一定律",
"correct_answer": ["惯性定律"],
}
def _fake_text(content: str):
async def _inner(self, system, user, json_mode=False, model=None):
return LLMResult(content, "text-model", 10, 20)
return _inner
class TestSettings:
async def test_api_key_never_returned(self, client, auth_headers, configure_ai):
h = await auth_headers()
out = await configure_ai(h, api_key="sk-secret-value")
assert out["has_api_key"] is True
assert "sk-secret-value" not in json.dumps(out)
r = await client.get("/api/settings", headers=h)
assert "sk-secret-value" not in r.text
assert r.json()["has_api_key"] is True
async def test_api_key_stored_encrypted(self, client, auth_headers, configure_ai):
"""库里存的必须是密文,且能正确解密回原值。"""
from sqlalchemy import select
from app.db import SessionLocal
from app.models.user import UserSettings
from app.services import crypto
h = await auth_headers()
await configure_ai(h, api_key="sk-secret-value")
async with SessionLocal() as db:
row = (await db.scalars(select(UserSettings))).first()
assert row.llm_api_key_encrypted != "sk-secret-value"
assert crypto.decrypt(row.llm_api_key_encrypted) == "sk-secret-value"
async def test_empty_api_key_leaves_existing(self, client, auth_headers, configure_ai):
"""留空表示不修改,不该把已配置的 key 清掉。"""
h = await auth_headers()
await configure_ai(h)
r = await client.put(
"/api/settings", json={"llm_text_model": "new-model"}, headers=h
)
assert r.json()["has_api_key"] is True
assert r.json()["llm_text_model"] == "new-model"
async def test_settings_isolated_per_user(self, client, auth_headers, configure_ai):
ha = await auth_headers("alice")
hb = await auth_headers("bob")
await configure_ai(ha)
assert (
await client.get("/api/settings", headers=hb)
).json()["has_api_key"] is False
class TestAiExplain:
async def test_explain_persists(self, client, auth_headers, configure_ai):
h = await auth_headers()
await configure_ai(h)
qid = (await client.post("/api/questions", json=SUBJECTIVE, headers=h)).json()["id"]
with patch(
"app.services.llm.client.LLMClient.chat_text",
_fake_text("## 讲解\n\n物体在无外力时 $v$ 恒定。"),
):
r = await client.post(f"/api/questions/{qid}/ai/explain", headers=h)
assert r.status_code == 200
assert "$v$" in r.json()["explanation_markdown"]
q = (await client.get(f"/api/questions/{qid}", headers=h)).json()
assert "讲解" in q["explanation_markdown"]
async def test_explain_never_touches_my_note(self, client, auth_headers, configure_ai):
"""我自己写的笔记是我的,AI 讲解不得覆盖它。"""
h = await auth_headers()
await configure_ai(h)
qid = (
await client.post(
"/api/questions",
json={**SUBJECTIVE, "my_note_markdown": "我当时错在没考虑摩擦力"},
headers=h,
)
).json()["id"]
with patch(
"app.services.llm.client.LLMClient.chat_text", _fake_text("AI 的讲解内容")
):
await client.post(f"/api/questions/{qid}/ai/explain", headers=h)
q = (await client.get(f"/api/questions/{qid}", headers=h)).json()
assert q["my_note_markdown"] == "我当时错在没考虑摩擦力"
assert q["explanation_markdown"] == "AI 的讲解内容"
async def test_unconfigured_ai_returns_400(self, client, auth_headers):
h = await auth_headers()
qid = (await client.post("/api/questions", json=SUBJECTIVE, headers=h)).json()["id"]
r = await client.post(f"/api/questions/{qid}/ai/explain", headers=h)
assert r.status_code == 400
assert "设置" in r.json()["detail"]
async def test_cannot_explain_others_note(self, client, auth_headers, configure_ai):
ha = await auth_headers("alice")
hb = await auth_headers("bob")
await configure_ai(hb)
qid = (await client.post("/api/questions", json=SUBJECTIVE, headers=ha)).json()["id"]
r = await client.post(f"/api/questions/{qid}/ai/explain", headers=hb)
assert r.status_code == 404
class TestAiTags:
async def test_summarize_tags_merges(self, client, auth_headers, configure_ai):
h = await auth_headers()
await configure_ai(h)
qid = (
await client.post(
"/api/questions", json={**SUBJECTIVE, "tags": ["物理"]}, headers=h
)
).json()["id"]
with patch(
"app.services.llm.client.LLMClient.chat_text",
_fake_text(json.dumps({"tags": ["力学", "牛顿定律"]})),
):
r = await client.post(f"/api/questions/{qid}/ai/summarize-tags", headers=h)
assert r.status_code == 200
assert set(r.json()["tags"]) == {"物理", "力学", "牛顿定律"}
async def test_malformed_tags_json_yields_no_tags(
self, client, auth_headers, configure_ai
):
h = await auth_headers()
await configure_ai(h)
qid = (await client.post("/api/questions", json=SUBJECTIVE, headers=h)).json()["id"]
with patch("app.services.llm.client.LLMClient.chat_text", _fake_text("not json")):
r = await client.post(f"/api/questions/{qid}/ai/summarize-tags", headers=h)
assert r.status_code == 200
assert r.json()["tags"] == []
async def test_tags_on_unclassified_note(self, client, auth_headers, configure_ai):
"""拍照记下的未分类笔记,也能让 AI 归类知识点。"""
h = await auth_headers()
await configure_ai(h)
qid = (
await client.post(
"/api/questions",
json={"type": "unclassified", "stem_markdown": "求导数"},
headers=h,
)
).json()["id"]
with patch(
"app.services.llm.client.LLMClient.chat_text",
_fake_text(json.dumps({"tags": ["导数"]})),
):
r = await client.post(f"/api/questions/{qid}/ai/summarize-tags", headers=h)
assert r.json()["tags"] == ["导数"]
class TestAiAudit:
async def test_ai_generation_is_audited(self, client, auth_headers, configure_ai):
"""每次 AI 调用都要留审计记录,便于观察成本。"""
from sqlalchemy import select
from app.db import SessionLocal
from app.models.practice import AiGeneration
h = await auth_headers()
await configure_ai(h)
qid = (await client.post("/api/questions", json=SUBJECTIVE, headers=h)).json()["id"]
with patch("app.services.llm.client.LLMClient.chat_text", _fake_text("讲解内容")):
await client.post(f"/api/questions/{qid}/ai/explain", headers=h)
async with SessionLocal() as db:
rows = (await db.scalars(select(AiGeneration))).all()
assert len(rows) == 1
assert rows[0].task == "explain"
assert rows[0].model == "text-model"
assert rows[0].request_tokens == 10
assert rows[0].response_tokens == 20
async def test_recording_attempt_makes_no_ai_call(self, client, auth_headers):
"""记录作答不该产生任何 AI 调用(也就不消耗额度)。"""
from sqlalchemy import select
from app.db import SessionLocal
from app.models.practice import AiGeneration
h = await auth_headers()
qid = (await client.post("/api/questions", json=SUBJECTIVE, headers=h)).json()["id"]
await client.post(
f"/api/questions/{qid}/attempts", json={"user_answer": ["x"]}, headers=h
)
async with SessionLocal() as db:
assert (await db.scalars(select(AiGeneration))).all() == []