"""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() == []