第一次提交
This commit is contained in:
@@ -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 []
|
||||
@@ -0,0 +1,103 @@
|
||||
"""统一的 OpenAI 兼容 LLM client:文本 + 多模态。
|
||||
|
||||
配置来自用户的 UserSettings(base_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),
|
||||
)
|
||||
@@ -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_false,options 用 [{"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}"
|
||||
Reference in New Issue
Block a user