移动端拍照上传、奥数区、学段约束解题与 AI 模型配置。
- 手机/平板响应式布局,支持拍照与相册上传 - 学生详情新增奥数区,按初/高中学段生成解法并禁止超纲 - 系统设置可配置 Ollama 或 OpenAI 兼容 API - 更新 frontend/dist 与使用说明
This commit is contained in:
@@ -11,6 +11,8 @@ class Settings(BaseSettings):
|
||||
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
|
||||
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
|
||||
OLLAMA_MODEL: str = "qwen2.5:7b"
|
||||
OPENAI_BASE_URL: str = "https://api.openai.com/v1"
|
||||
OPENAI_MODEL: str = "gpt-4o-mini"
|
||||
FLUCTUATION_THRESHOLD: float = 0.08
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:23566,http://localhost"
|
||||
WEB_PORT: int = 23566
|
||||
|
||||
@@ -22,6 +22,16 @@ class WrongQuestionStatus(str, enum.Enum):
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class WrongQuestionCategory(str, enum.Enum):
|
||||
regular = "regular"
|
||||
olympiad = "olympiad"
|
||||
|
||||
|
||||
class AIProvider(str, enum.Enum):
|
||||
ollama = "ollama"
|
||||
openai = "openai"
|
||||
|
||||
|
||||
class SchoolLevel(str, enum.Enum):
|
||||
junior_high = "junior_high"
|
||||
senior_high = "senior_high"
|
||||
@@ -123,6 +133,9 @@ class WrongQuestion(Base):
|
||||
status: Mapped[WrongQuestionStatus] = mapped_column(
|
||||
Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending
|
||||
)
|
||||
category: Mapped[WrongQuestionCategory] = mapped_column(
|
||||
Enum(WrongQuestionCategory), default=WrongQuestionCategory.regular
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -136,6 +149,12 @@ class SystemSettings(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
registration_enabled: Mapped[bool] = mapped_column(default=True)
|
||||
ai_provider: Mapped[str] = mapped_column(String(16), default="ollama")
|
||||
ollama_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
ollama_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
openai_base_url: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
openai_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
openai_api_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.schemas import (
|
||||
AdminUserCreate,
|
||||
AdminUserOut,
|
||||
AdminUserPasswordUpdate,
|
||||
AIProviderEnum,
|
||||
PublicSettingsOut,
|
||||
SystemSettingsOut,
|
||||
SystemSettingsUpdate,
|
||||
@@ -21,6 +22,19 @@ from app.schemas import (
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def settings_to_out(row: SystemSettings) -> SystemSettingsOut:
|
||||
return SystemSettingsOut(
|
||||
registration_enabled=row.registration_enabled,
|
||||
ai_provider=AIProviderEnum(row.ai_provider or "ollama"),
|
||||
ollama_base_url=row.ollama_base_url,
|
||||
ollama_model=row.ollama_model,
|
||||
openai_base_url=row.openai_base_url,
|
||||
openai_model=row.openai_model,
|
||||
openai_api_key_set=bool(row.openai_api_key),
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session) -> SystemSettings:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
@@ -36,7 +50,7 @@ def get_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_superuser),
|
||||
):
|
||||
return get_or_create_settings(db)
|
||||
return settings_to_out(get_or_create_settings(db))
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=SystemSettingsOut)
|
||||
@@ -48,10 +62,22 @@ def update_settings(
|
||||
row = get_or_create_settings(db)
|
||||
if data.registration_enabled is not None:
|
||||
row.registration_enabled = data.registration_enabled
|
||||
if data.ai_provider is not None:
|
||||
row.ai_provider = data.ai_provider.value
|
||||
if data.ollama_base_url is not None:
|
||||
row.ollama_base_url = data.ollama_base_url or None
|
||||
if data.ollama_model is not None:
|
||||
row.ollama_model = data.ollama_model or None
|
||||
if data.openai_base_url is not None:
|
||||
row.openai_base_url = data.openai_base_url or None
|
||||
if data.openai_model is not None:
|
||||
row.openai_model = data.openai_model or None
|
||||
if data.openai_api_key is not None and data.openai_api_key.strip():
|
||||
row.openai_api_key = data.openai_api_key.strip()
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
return settings_to_out(row)
|
||||
|
||||
|
||||
@router.patch("/profile", response_model=AdminUserOut)
|
||||
|
||||
@@ -8,10 +8,10 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from app.core.config import settings
|
||||
from app.core.database import SessionLocal, get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import Subject, User, WrongQuestion, WrongQuestionStatus
|
||||
from app.schemas import WrongQuestionOut, WrongQuestionUpdate
|
||||
from app.models.user import Subject, User, WrongQuestion, WrongQuestionCategory, WrongQuestionStatus
|
||||
from app.schemas import WrongQuestionCategoryEnum, WrongQuestionOut, WrongQuestionUpdate
|
||||
from app.services import llm as llm_service
|
||||
from app.services import ocr as ocr_service
|
||||
from app.services import ollama as ollama_service
|
||||
from app.services.student_access import get_student_for_user
|
||||
|
||||
router = APIRouter(tags=["wrong_questions"])
|
||||
@@ -23,6 +23,7 @@ def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
|
||||
student_id=wq.student_id,
|
||||
subject_id=wq.subject_id,
|
||||
subject_name=wq.subject.name if wq.subject else None,
|
||||
category=wq.category,
|
||||
image_path=wq.image_path,
|
||||
ocr_raw_text=wq.ocr_raw_text,
|
||||
question_text=wq.question_text,
|
||||
@@ -60,16 +61,25 @@ def _process_wrong_question(question_id: uuid.UUID):
|
||||
|
||||
subject_name = wq.subject.name if wq.subject else "综合"
|
||||
school_level = wq.student.school_level if wq.student else None
|
||||
olympiad = wq.category == WrongQuestionCategory.olympiad
|
||||
ai_cfg = llm_service.load_ai_config(db)
|
||||
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
question_text = loop.run_until_complete(
|
||||
ollama_service.format_question(subject_name, ocr_text, school_level)
|
||||
llm_service.format_question(ai_cfg, subject_name, ocr_text, school_level)
|
||||
)
|
||||
solution_text = loop.run_until_complete(
|
||||
ollama_service.generate_solution(subject_name, question_text, school_level)
|
||||
llm_service.generate_solution(
|
||||
ai_cfg,
|
||||
subject_name,
|
||||
question_text,
|
||||
school_level,
|
||||
olympiad=olympiad,
|
||||
)
|
||||
)
|
||||
wq.question_text = question_text
|
||||
wq.solution_text = solution_text
|
||||
@@ -88,6 +98,7 @@ def _process_wrong_question(question_id: uuid.UUID):
|
||||
def list_wrong_questions(
|
||||
student_id: uuid.UUID,
|
||||
subject_id: int | None = Query(None),
|
||||
category: WrongQuestionCategoryEnum | None = Query(None),
|
||||
q: str | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -100,6 +111,8 @@ def list_wrong_questions(
|
||||
)
|
||||
if subject_id is not None:
|
||||
query = query.filter(WrongQuestion.subject_id == subject_id)
|
||||
if category is not None:
|
||||
query = query.filter(WrongQuestion.category == category.value)
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
query = query.filter(
|
||||
@@ -121,6 +134,7 @@ async def upload_wrong_question(
|
||||
background_tasks: BackgroundTasks,
|
||||
subject_id: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
category: WrongQuestionCategoryEnum = Form(WrongQuestionCategoryEnum.regular),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -137,6 +151,7 @@ async def upload_wrong_question(
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
image_path="",
|
||||
category=WrongQuestionCategory(category.value),
|
||||
status=WrongQuestionStatus.pending,
|
||||
)
|
||||
db.add(wq)
|
||||
@@ -270,21 +285,27 @@ async def regenerate_solution(
|
||||
|
||||
subject_name = wq.subject.name if wq.subject else "综合"
|
||||
school_level = wq.student.school_level if wq.student else None
|
||||
olympiad = wq.category == WrongQuestionCategory.olympiad
|
||||
question_text = wq.question_text or wq.ocr_raw_text or ""
|
||||
ai_cfg = llm_service.load_ai_config(db)
|
||||
|
||||
try:
|
||||
if not wq.question_text and wq.ocr_raw_text:
|
||||
wq.question_text = await ollama_service.format_question(
|
||||
subject_name, wq.ocr_raw_text, school_level
|
||||
wq.question_text = await llm_service.format_question(
|
||||
ai_cfg, subject_name, wq.ocr_raw_text, school_level
|
||||
)
|
||||
question_text = wq.question_text
|
||||
wq.solution_text = await ollama_service.generate_solution(
|
||||
subject_name, question_text, school_level
|
||||
wq.solution_text = await llm_service.generate_solution(
|
||||
ai_cfg,
|
||||
subject_name,
|
||||
question_text,
|
||||
school_level,
|
||||
olympiad=olympiad,
|
||||
)
|
||||
wq.status = WrongQuestionStatus.solved
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Ollama 调用失败: {exc}"
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AI 调用失败: {exc}"
|
||||
) from exc
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -18,6 +18,16 @@ class WrongQuestionStatusEnum(str, Enum):
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class WrongQuestionCategoryEnum(str, Enum):
|
||||
regular = "regular"
|
||||
olympiad = "olympiad"
|
||||
|
||||
|
||||
class AIProviderEnum(str, Enum):
|
||||
ollama = "ollama"
|
||||
openai = "openai"
|
||||
|
||||
|
||||
class SchoolLevelEnum(str, Enum):
|
||||
junior_high = "junior_high"
|
||||
senior_high = "senior_high"
|
||||
@@ -58,6 +68,12 @@ class PublicSettingsOut(BaseModel):
|
||||
|
||||
class SystemSettingsOut(BaseModel):
|
||||
registration_enabled: bool
|
||||
ai_provider: AIProviderEnum
|
||||
ollama_base_url: str | None = None
|
||||
ollama_model: str | None = None
|
||||
openai_base_url: str | None = None
|
||||
openai_model: str | None = None
|
||||
openai_api_key_set: bool = False
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -65,6 +81,12 @@ class SystemSettingsOut(BaseModel):
|
||||
|
||||
class SystemSettingsUpdate(BaseModel):
|
||||
registration_enabled: bool | None = None
|
||||
ai_provider: AIProviderEnum | None = None
|
||||
ollama_base_url: str | None = None
|
||||
ollama_model: str | None = None
|
||||
openai_base_url: str | None = None
|
||||
openai_model: str | None = None
|
||||
openai_api_key: str | None = None
|
||||
|
||||
|
||||
class AdminProfileUpdate(BaseModel):
|
||||
@@ -207,6 +229,7 @@ class WrongQuestionOut(BaseModel):
|
||||
student_id: UUID
|
||||
subject_id: int
|
||||
subject_name: str | None = None
|
||||
category: WrongQuestionCategoryEnum
|
||||
image_path: str
|
||||
ocr_raw_text: str | None
|
||||
question_text: str | None
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import enum
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings as app_settings
|
||||
from app.models.user import SchoolLevel, SystemSettings
|
||||
from app.services.school_level import school_level_label
|
||||
|
||||
CURRICULUM_JUNIOR = """初中课程标准:代数、几何(全等/相似/勾股)、一次函数与简单二次函数、基础概率统计。
|
||||
严禁使用:高中导数、向量、解析几何、排列组合进阶、复数、微积分、大学线性代数等。"""
|
||||
|
||||
CURRICULUM_SENIOR = """高中课程标准:课内函数、三角、向量、解析几何、概率统计、导数(课内范围)等。
|
||||
严禁使用:大学数学分析、抽象代数、高等几何、超出课内的竞赛高阶技巧。"""
|
||||
|
||||
CURRICULUM_JUNIOR_OLYMPIAD = """初中奥数培优范围:整数/整除、因数分解、简单数论、代数恒等变形、几何辅助线与全等相似、简单组合计数。
|
||||
严禁使用:高中及以上方法(导数、向量、解析几何、微积分、复数运算等)。"""
|
||||
|
||||
CURRICULUM_SENIOR_OLYMPIAD = """高中奥数/竞赛入门范围:课内知识+常规竞赛技巧(不等式、构造、归纳、简单数论等)。
|
||||
严禁使用:大学数学、超出高中奥数培优体系的 IMO 高阶理论。"""
|
||||
|
||||
|
||||
def _curriculum_block(level: SchoolLevel | str | None, olympiad: bool) -> str:
|
||||
label = school_level_label(level)
|
||||
is_senior = level == SchoolLevel.senior_high or level == "senior_high"
|
||||
if olympiad:
|
||||
return CURRICULUM_SENIOR_OLYMPIAD if is_senior else CURRICULUM_JUNIOR_OLYMPIAD
|
||||
return CURRICULUM_SENIOR if is_senior else CURRICULUM_JUNIOR
|
||||
|
||||
|
||||
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。
|
||||
科目:{subject}
|
||||
请整理出清晰的题目内容(保留题号、选项、公式),只输出题目正文,不要解释。
|
||||
|
||||
OCR 原文:
|
||||
{ocr_text}
|
||||
"""
|
||||
|
||||
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法。
|
||||
|
||||
【学段要求 — 严禁超纲】
|
||||
{curriculum}
|
||||
|
||||
题目:
|
||||
{question_text}
|
||||
|
||||
请按以下结构输出:
|
||||
1. 考点分析({stage}范围内)
|
||||
2. 解题步骤(逐步推导,每步说明依据)
|
||||
3. 易错点提醒
|
||||
4. 若必须使用超纲方法才能解,请改用{stage}可理解的方法重新解答,不得输出超纲解法。
|
||||
"""
|
||||
|
||||
OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请为以下奥数题给出详细解题思路与完整解答。
|
||||
|
||||
【奥数学段要求 — 严禁超纲】
|
||||
{curriculum}
|
||||
|
||||
题目:
|
||||
{question_text}
|
||||
|
||||
请按以下结构输出:
|
||||
1. 题型与思路切入点({stage}奥数常见技巧)
|
||||
2. 详细解答步骤
|
||||
3. 关键技巧总结(仅限{stage}奥数范围)
|
||||
4. 严禁使用超出上述范围的方法;若题目过难,给出{stage}可接受的培优思路。
|
||||
"""
|
||||
|
||||
|
||||
class AIConfig:
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
ollama_base_url: str,
|
||||
ollama_model: str,
|
||||
openai_base_url: str,
|
||||
openai_model: str,
|
||||
openai_api_key: str | None,
|
||||
):
|
||||
self.provider = provider
|
||||
self.ollama_base_url = ollama_base_url
|
||||
self.ollama_model = ollama_model
|
||||
self.openai_base_url = openai_base_url
|
||||
self.openai_model = openai_model
|
||||
self.openai_api_key = openai_api_key
|
||||
|
||||
|
||||
def load_ai_config(db: Session) -> AIConfig:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
return AIConfig(
|
||||
provider="ollama",
|
||||
ollama_base_url=app_settings.OLLAMA_BASE_URL,
|
||||
ollama_model=app_settings.OLLAMA_MODEL,
|
||||
openai_base_url=app_settings.OPENAI_BASE_URL,
|
||||
openai_model=app_settings.OPENAI_MODEL,
|
||||
openai_api_key=None,
|
||||
)
|
||||
return AIConfig(
|
||||
provider=row.ai_provider or "ollama",
|
||||
ollama_base_url=row.ollama_base_url or app_settings.OLLAMA_BASE_URL,
|
||||
ollama_model=row.ollama_model or app_settings.OLLAMA_MODEL,
|
||||
openai_base_url=row.openai_base_url or app_settings.OPENAI_BASE_URL,
|
||||
openai_model=row.openai_model or app_settings.OPENAI_MODEL,
|
||||
openai_api_key=row.openai_api_key,
|
||||
)
|
||||
|
||||
|
||||
async def _ollama_generate(prompt: str, cfg: AIConfig) -> str:
|
||||
url = f"{cfg.ollama_base_url.rstrip('/')}/api/generate"
|
||||
payload = {"model": cfg.ollama_model, "prompt": prompt, "stream": False}
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
return (response.json().get("response") or "").strip()
|
||||
|
||||
|
||||
async def _openai_generate(prompt: str, cfg: AIConfig) -> str:
|
||||
if not cfg.openai_api_key:
|
||||
raise ValueError("未配置 OpenAI API Key")
|
||||
url = f"{cfg.openai_base_url.rstrip('/')}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {cfg.openai_api_key}"}
|
||||
payload = {
|
||||
"model": cfg.openai_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.3,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return (data["choices"][0]["message"]["content"] or "").strip()
|
||||
|
||||
|
||||
async def generate_text(prompt: str, cfg: AIConfig) -> str:
|
||||
if cfg.provider == "openai":
|
||||
return await _openai_generate(prompt, cfg)
|
||||
return await _ollama_generate(prompt, cfg)
|
||||
|
||||
|
||||
async def format_question(
|
||||
cfg: AIConfig,
|
||||
subject: str,
|
||||
ocr_text: str,
|
||||
school_level=None,
|
||||
) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text)
|
||||
return await generate_text(prompt, cfg)
|
||||
|
||||
|
||||
async def generate_solution(
|
||||
cfg: AIConfig,
|
||||
subject: str,
|
||||
question_text: str,
|
||||
school_level=None,
|
||||
*,
|
||||
olympiad: bool = False,
|
||||
) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
curriculum = _curriculum_block(school_level, olympiad)
|
||||
template = OLYMPIAD_SOLUTION_PROMPT if olympiad else SOLUTION_PROMPT
|
||||
prompt = template.format(
|
||||
stage=stage,
|
||||
subject=subject,
|
||||
curriculum=curriculum,
|
||||
question_text=question_text,
|
||||
)
|
||||
return await generate_text(prompt, cfg)
|
||||
@@ -7,7 +7,8 @@ from app.core.database import engine
|
||||
def run_migrations() -> None:
|
||||
"""Apply lightweight schema updates for existing databases."""
|
||||
inspector = inspect(engine)
|
||||
if "students" not in inspector.get_table_names():
|
||||
tables = set(inspector.get_table_names())
|
||||
if "students" not in tables:
|
||||
return
|
||||
|
||||
columns = {col["name"] for col in inspector.get_columns("students")}
|
||||
@@ -20,7 +21,7 @@ def run_migrations() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
if "users" in inspector.get_table_names():
|
||||
if "users" in tables:
|
||||
user_columns = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "is_superuser" not in user_columns:
|
||||
with engine.begin() as conn:
|
||||
@@ -33,3 +34,34 @@ def run_migrations() -> None:
|
||||
f"WHERE username = '{settings.ADMIN_DEFAULT_USERNAME}'"
|
||||
)
|
||||
)
|
||||
|
||||
if "wrong_questions" in tables:
|
||||
wq_columns = {col["name"] for col in inspector.get_columns("wrong_questions")}
|
||||
if "category" not in wq_columns:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE wrong_questions ADD COLUMN category VARCHAR(32) "
|
||||
"NOT NULL DEFAULT 'regular'"
|
||||
)
|
||||
)
|
||||
|
||||
if "system_settings" in tables:
|
||||
ss_columns = {col["name"] for col in inspector.get_columns("system_settings")}
|
||||
alters: list[str] = []
|
||||
if "ai_provider" not in ss_columns:
|
||||
alters.append("ADD COLUMN ai_provider VARCHAR(16) NOT NULL DEFAULT 'ollama'")
|
||||
if "ollama_base_url" not in ss_columns:
|
||||
alters.append("ADD COLUMN ollama_base_url VARCHAR(256)")
|
||||
if "ollama_model" not in ss_columns:
|
||||
alters.append("ADD COLUMN ollama_model VARCHAR(128)")
|
||||
if "openai_base_url" not in ss_columns:
|
||||
alters.append("ADD COLUMN openai_base_url VARCHAR(256)")
|
||||
if "openai_model" not in ss_columns:
|
||||
alters.append("ADD COLUMN openai_model VARCHAR(128)")
|
||||
if "openai_api_key" not in ss_columns:
|
||||
alters.append("ADD COLUMN openai_api_key VARCHAR(512)")
|
||||
if alters:
|
||||
with engine.begin() as conn:
|
||||
for clause in alters:
|
||||
conn.execute(text(f"ALTER TABLE system_settings {clause}"))
|
||||
|
||||
@@ -1,47 +1,5 @@
|
||||
import httpx
|
||||
"""Backward-compatible wrapper; prefer app.services.llm."""
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.school_level import school_level_label
|
||||
from app.services.llm import format_question, generate_solution, load_ai_config
|
||||
|
||||
QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。
|
||||
科目:{subject}
|
||||
请整理出清晰的题目内容(保留题号、选项、公式),只输出题目正文,不要解释。
|
||||
|
||||
OCR 原文:
|
||||
{ocr_text}
|
||||
"""
|
||||
|
||||
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法。
|
||||
要求:步骤清晰,语言适合{stage}学生理解,指出考点和易错点。
|
||||
|
||||
题目:
|
||||
{question_text}
|
||||
"""
|
||||
|
||||
|
||||
async def ollama_generate(prompt: str) -> str:
|
||||
url = f"{settings.OLLAMA_BASE_URL.rstrip('/')}/api/generate"
|
||||
payload = {
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return (data.get("response") or "").strip()
|
||||
|
||||
|
||||
async def format_question(subject: str, ocr_text: str, school_level=None) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text)
|
||||
return await ollama_generate(prompt)
|
||||
|
||||
|
||||
async def generate_solution(subject: str, question_text: str, school_level=None) -> str:
|
||||
stage = school_level_label(school_level)
|
||||
prompt = SOLUTION_PROMPT.format(
|
||||
stage=stage, subject=subject, question_text=question_text
|
||||
)
|
||||
return await ollama_generate(prompt)
|
||||
__all__ = ["format_question", "generate_solution", "load_ai_config"]
|
||||
|
||||
@@ -26,8 +26,30 @@ def seed_subjects(db: Session) -> None:
|
||||
|
||||
|
||||
def seed_admin_and_settings(db: Session) -> None:
|
||||
if db.get(SystemSettings, 1) is None:
|
||||
db.add(SystemSettings(id=1, registration_enabled=True))
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
db.add(
|
||||
SystemSettings(
|
||||
id=1,
|
||||
registration_enabled=True,
|
||||
ai_provider="ollama",
|
||||
ollama_base_url=settings.OLLAMA_BASE_URL,
|
||||
ollama_model=settings.OLLAMA_MODEL,
|
||||
openai_base_url=settings.OPENAI_BASE_URL,
|
||||
openai_model=settings.OPENAI_MODEL,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if not row.ollama_base_url:
|
||||
row.ollama_base_url = settings.OLLAMA_BASE_URL
|
||||
if not row.ollama_model:
|
||||
row.ollama_model = settings.OLLAMA_MODEL
|
||||
if not row.openai_base_url:
|
||||
row.openai_base_url = settings.OPENAI_BASE_URL
|
||||
if not row.openai_model:
|
||||
row.openai_model = settings.OPENAI_MODEL
|
||||
if not row.ai_provider:
|
||||
row.ai_provider = "ollama"
|
||||
|
||||
admin = db.query(User).filter(User.username == settings.ADMIN_DEFAULT_USERNAME).first()
|
||||
if admin is None:
|
||||
|
||||
Reference in New Issue
Block a user