第一次提交

This commit is contained in:
2026-08-01 16:50:55 +08:00
commit c8f9e39039
68 changed files with 6016 additions and 0 deletions
View File
+111
View File
@@ -0,0 +1,111 @@
"""AI 任务:判分、讲解、知识点总结。统一记录到 ai_generations。"""
import json
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.practice import AiGeneration
from app.models.question import Question
from app.models.user import UserSettings
from app.services.llm.client import LLMClient
from app.services.llm.prompts import (
EXPLAIN_SYSTEM,
EXPLAIN_USER_TEMPLATE,
JUDGE_SYSTEM,
JUDGE_USER_TEMPLATE,
TAGS_SYSTEM,
TAGS_USER_TEMPLATE,
)
logger = logging.getLogger(__name__)
async def load_llm(db: AsyncSession, user_id: str) -> LLMClient:
"""加载用户的 LLMClient(未配置会抛 LLMConfigError)。"""
row = await db.scalar(select(UserSettings).where(UserSettings.user_id == user_id))
return LLMClient(row)
def _parse_json(raw: str) -> dict:
text = raw.strip()
if text.startswith("```"):
text = text.split("```", 2)[1]
if text.startswith("json"):
text = text[4:]
return json.loads(text.strip())
async def _record(
db: AsyncSession, user_id: str, question_id: str | None, task: str, result, content: str
):
db.add(
AiGeneration(
user_id=user_id,
question_id=question_id,
task=task,
model=result.model,
request_tokens=result.prompt_tokens,
response_tokens=result.completion_tokens,
content_markdown=content,
)
)
async def judge_answer(
db: AsyncSession, llm: LLMClient, user_id: str, q: Question, user_answer: list[str]
) -> dict:
"""主观题判分。返回 {is_correct, feedback_markdown, key_points_missed}。"""
answer = "".join(q.correct_answer) if q.correct_answer else "(未提供标准答案)"
result = await llm.chat_text(
JUDGE_SYSTEM,
JUDGE_USER_TEMPLATE.format(
stem=q.stem_markdown, answer=answer, user_answer="\n".join(user_answer)
),
json_mode=True,
)
await _record(db, user_id, q.id, "judge", result, result.content)
try:
data = _parse_json(result.content)
return {
"is_correct": bool(data.get("is_correct")),
"feedback_markdown": data.get("feedback_markdown", ""),
"key_points_missed": data.get("key_points_missed", []),
}
except (json.JSONDecodeError, KeyError) as e:
logger.warning("judge 解析失败:%s", e)
return {
"is_correct": False,
"feedback_markdown": result.content,
"key_points_missed": [],
}
async def explain_question(
db: AsyncSession, llm: LLMClient, user_id: str, q: Question
) -> str:
answer_hint = ""
if q.correct_answer:
answer_hint = f"参考答案:{''.join(q.correct_answer)}\n\n"
result = await llm.chat_text(
EXPLAIN_SYSTEM,
EXPLAIN_USER_TEMPLATE.format(stem=q.stem_markdown, answer_hint=answer_hint),
)
await _record(db, user_id, q.id, "explain", result, result.content)
return result.content
async def summarize_tags(
db: AsyncSession, llm: LLMClient, user_id: str, q: Question
) -> list[str]:
result = await llm.chat_text(
TAGS_SYSTEM, TAGS_USER_TEMPLATE.format(stem=q.stem_markdown), json_mode=True
)
await _record(db, user_id, q.id, "summarize", result, result.content)
try:
data = _parse_json(result.content)
tags = data.get("tags", [])
return [str(t).strip() for t in tags if str(t).strip()][:4]
except (json.JSONDecodeError, KeyError) as e:
logger.warning("tags 解析失败:%s", e)
return []
+103
View File
@@ -0,0 +1,103 @@
"""统一的 OpenAI 兼容 LLM client:文本 + 多模态。
配置来自用户的 UserSettingsbase_url / api_key / 模型名)。api_key 在库里
是 Fernet 加密的,这里解密后使用。
"""
import base64
from dataclasses import dataclass
from openai import AsyncOpenAI
from app.models.user import UserSettings
from app.services.crypto import decrypt
class LLMConfigError(RuntimeError):
"""用户尚未正确配置 AI 接口。"""
@dataclass
class LLMResult:
content: str
model: str | None
prompt_tokens: int | None
completion_tokens: int | None
class LLMClient:
def __init__(self, settings: UserSettings):
if not settings or not settings.llm_base_url or not settings.llm_api_key_encrypted:
raise LLMConfigError("请先在设置里配置 AI 接口(base_url 与 api_key")
api_key = decrypt(settings.llm_api_key_encrypted)
if not api_key:
raise LLMConfigError("api_key 解密失败,请在设置里重新填写")
self._client = AsyncOpenAI(base_url=settings.llm_base_url, api_key=api_key)
self._text_model = settings.llm_text_model
self._vision_model = settings.llm_vision_model or settings.llm_text_model
async def chat_text(
self,
system: str,
user: str,
*,
json_mode: bool = False,
model: str | None = None,
) -> LLMResult:
m = model or self._text_model
if not m:
raise LLMConfigError("未配置文本模型名")
kwargs = {}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
resp = await self._client.chat.completions.create(
model=m,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
**kwargs,
)
return self._to_result(resp, m)
async def chat_vision(
self,
system: str,
user_text: str,
image_bytes: bytes,
mime: str = "image/jpeg",
*,
json_mode: bool = False,
model: str | None = None,
) -> LLMResult:
m = model or self._vision_model
if not m:
raise LLMConfigError("未配置多模态模型名")
data_url = f"data:{mime};base64,{base64.b64encode(image_bytes).decode()}"
kwargs = {}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
resp = await self._client.chat.completions.create(
model=m,
messages=[
{"role": "system", "content": system},
{
"role": "user",
"content": [
{"type": "text", "text": user_text},
{"type": "image_url", "image_url": {"url": data_url}},
],
},
],
**kwargs,
)
return self._to_result(resp, m)
@staticmethod
def _to_result(resp, model: str) -> LLMResult:
usage = getattr(resp, "usage", None)
return LLMResult(
content=resp.choices[0].message.content or "",
model=model,
prompt_tokens=getattr(usage, "prompt_tokens", None),
completion_tokens=getattr(usage, "completion_tokens", None),
)
+75
View File
@@ -0,0 +1,75 @@
"""各 AI 任务的 prompt 模板。"""
# ---- OCR:多模态直接看图,转成含 LaTeX 的 Markdown(不作答)----
VLM_OCR_SYSTEM = (
"你是一个精准的题目识别助手。用户会给你一张包含题目的图片,"
"请把图片中的题目内容原样转写为 Markdown 文本。要求:\n"
"1. 数学公式用 LaTeX 表示,行内用 $...$,独立公式用 $$...$$。\n"
"2. 保留题目的选项(如 A/B/C/D)、题号、结构。\n"
"3. 只转写题目本身,不要作答、不要解释、不要添加任何额外内容。\n"
"4. 如果有多道题,全部转写。"
)
VLM_OCR_USER = "请转写这张图片里的题目。"
# ---- 结构化提取:把 OCR/图片内容解析成结构化题目 JSON ----
EXTRACT_SYSTEM = """你是一个题目结构化助手。用户会给你一段题目文本(Markdown,可能含 LaTeX 公式),
其中可能包含一道或多道题。请把**每一道题**解析成一个对象,输出 JSON:
{
"questions": [
{
"type": "unclassified | single_choice | multiple_choice | true_false | fill_blank | short_answer",
"stem_markdown": "题干(保留原始 LaTeX",
"options": [{"key": "A", "text_markdown": "..."}] // 仅选择/判断题;否则为 null,
"correct_answer": ["A"] 或 ["文本答案"] 或 null, // 原文未给出答案则为 null
"difficulty": null 或 1-5
}
]
}
题型判断规则:
- 有 ABCD 等多个选项且只选一个 → single_choice;可多选 → multiple_choice
- 判断对错(正确/错误、对/错、True/False)→ true_falseoptions 用 [{"key":"T","text_markdown":"正确"},{"key":"F","text_markdown":"错误"}]
- 有下划线、括号、填空 → fill_blank
- 其余开放性问答 → short_answer
- 判断不了、或者这段内容不像一道完整的题 → unclassified(这是可以接受的,不要硬猜)
重要:
- 有几道题就输出几个对象,不要合并成一条,也不要漏掉。
- 只有一道题时也要放在 questions 数组里。
- 原文没给答案就填 null,不要自己解题填答案。
只输出 JSON,不要额外文字。"""
EXTRACT_USER_TEMPLATE = "题目文本:\n\n{content}"
# ---- 判分(主观题)----
JUDGE_SYSTEM = """你是一个阅卷助手。根据题目、标准答案(可能没有)和学生答案,判断对错并给出反馈。
输出 JSON
{
"is_correct": true/false,
"feedback_markdown": "简短反馈,指出对错原因(可含 LaTeX)",
"key_points_missed": ["遗漏的要点", ...]
}
只输出 JSON。"""
JUDGE_USER_TEMPLATE = """题目:
{stem}
标准答案:{answer}
学生答案:
{user_answer}"""
# ---- 讲解 ----
EXPLAIN_SYSTEM = (
"你是一位耐心的老师。请针对给定题目给出面向自学者的分步讲解,"
"解释解题思路而非只给结论。使用 Markdown,公式用 LaTeX$...$ / $$...$$)。"
)
EXPLAIN_USER_TEMPLATE = """题目:
{stem}
{answer_hint}请给出详细讲解。"""
# ---- 知识点标签 ----
TAGS_SYSTEM = """你是一个知识点归类助手。根据题目内容,输出 1-4 个简短的知识点标签。
输出 JSON{"tags": ["标签1", "标签2"]}
标签要简洁(2-8 字),是学科知识点,不要句子。只输出 JSON。"""
TAGS_USER_TEMPLATE = "题目:\n{stem}"