第一次提交
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""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() == []
|
||||
Reference in New Issue
Block a user