智教助手平台:完整初始化

- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
This commit is contained in:
Zhang Jing Xuan
2026-07-29 16:29:10 +08:00
commit 0841b1a103
193 changed files with 44995 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
import re
from io import BytesIO
from urllib.parse import quote
from docx import Document
from pptx import Presentation
from PIL import Image
from pypdf import PdfReader
from openpyxl import load_workbook
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import StreamingResponse
from models.material import Material
from models.user import User
from schemas.ai import EssayGradeRequest, ExamExportRequest, ExamGenerateRequest, HtmlExportRequest
from services.limiter import limiter
from services.auth import get_current_user
from services.audit import log_action
from services.upload_security import validate_upload, sanitize_filename
from services.ai_service import AIService
from services.credits import credits_payload, spend_credits
from database import get_db
from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/ai", tags=["AI能力"])
ai_service = AIService()
@router.post("/essay-grade", response_model=dict)
@limiter.limit("10/minute")
async def grade_essay(
request: Request,
data: EssayGradeRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "essay_grade", "作文批改")
db.commit()
result = await ai_service.grade_essay(
essay_text=data.essay_text, grade_level=data.grade_level,
essay_type=data.essay_type, total_score=data.total_score,
)
return {"success": True, "data": result, "credits": credits_payload(account, "essay_grade")}
@router.post("/exam-generate", response_model=dict)
@limiter.limit("10/minute")
async def generate_exam(
request: Request,
data: ExamGenerateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "exam_generate", f"智能命题:{data.subject}")
db.commit()
result = await ai_service.generate_exam(
subject=data.subject, grade=data.grade,
knowledge_points=data.knowledge_points, difficulty=data.difficulty,
question_types=data.question_types, count=data.count, total_score=data.total_score,
)
return {"success": True, "data": result, "credits": credits_payload(account, "exam_generate")}
def _safe_filename(name: str, suffix: str) -> str:
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "export"
return f"{stem[:80]}{suffix}"
def _summarize_text(text: str, limit: int = 4000) -> str:
normalized = re.sub(r"[ \t]+", " ", text.replace("\r\n", "\n")).strip()
normalized = re.sub(r"\n{3,}", "\n\n", normalized)
return normalized[:limit]
def _title_from_filename(filename: str) -> str:
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
title = re.sub(r"[_-]+", " ", stem).strip() or "教学素材"
return title[:200]
def _decode_text(content: bytes) -> str:
for encoding in ("utf-8-sig", "utf-8", "gb18030", "gbk"):
try:
return content.decode(encoding)
except UnicodeDecodeError:
continue
return content.decode("utf-8", errors="ignore")
def _extract_docx(content: bytes) -> str:
document = Document(BytesIO(content))
paragraphs = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()]
table_lines = []
for table in document.tables:
for row in table.rows:
cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
if cells:
table_lines.append(" | ".join(cells))
return "\n".join([*paragraphs, *table_lines])
def _extract_pptx(content: bytes) -> str:
presentation = Presentation(BytesIO(content))
lines = []
for index, slide in enumerate(presentation.slides, start=1):
slide_lines = []
for shape in slide.shapes:
text = getattr(shape, "text", "").strip()
if text:
slide_lines.append(text)
if slide_lines:
lines.append(f"{index} 页:\n" + "\n".join(slide_lines))
return "\n\n".join(lines)
def _extract_pdf(content: bytes) -> str:
reader = PdfReader(BytesIO(content))
pages = []
for index, page in enumerate(reader.pages, start=1):
text = (page.extract_text() or "").strip()
if text:
pages.append(f"{index} 页:\n{text}")
return "\n\n".join(pages)
def _extract_xlsx(content: bytes) -> str:
workbook = load_workbook(BytesIO(content), data_only=True, read_only=True)
lines = []
for sheet in workbook.worksheets:
row_lines = []
for row in sheet.iter_rows(values_only=True):
cells = [str(cell).strip() for cell in row if cell is not None and str(cell).strip()]
if cells:
row_lines.append(" | ".join(cells))
if row_lines:
lines.append(f"工作表 {sheet.title}:\n" + "\n".join(row_lines[:200]))
return "\n\n".join(lines)
async def _describe_image(content: bytes, filename: str) -> str:
try:
with Image.open(BytesIO(content)) as image:
width, height = image.size
mode = image.mode
except Exception:
width, height, mode = 0, 0, ""
meta = f"图片文件:{filename}"
if width and height:
meta += f";尺寸:{width}×{height}px"
if mode:
meta += f";色彩模式:{mode}"
# Try AI vision OCR first; fall back to metadata-only description
try:
ocr_text = await ai_service.ocr_image(content, filename)
if ocr_text and "[无法识别文字内容]" not in ocr_text:
return f"{meta}\n\n【图片文字识别结果】\n{ocr_text}"
except Exception as exc:
import logging
logging.getLogger(__name__).warning("图片OCR失败,使用元数据描述: %s", exc)
return (
f"{meta}\n"
"这是一份拍照/图片教学素材,可能包含教材页、题目、板书、图表、实验现象或课堂场景。"
"当前系统已保存图片素材类型,可将其带入课件、动画、练习、教案或命题流程;"
"生成时请教师在提示词中补充图片中的关键文字、题干或知识点,以便产出更准确。"
)
@router.post("/materials/parse", response_model=dict)
@limiter.limit("20/minute")
async def parse_material(
request: Request,
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
content = await file.read()
check = validate_upload(file, content=content, max_size=50 * 1024 * 1024)
filename = check.filename
suffix = check.suffix
media_type = check.media_type
try:
if media_type.startswith("image/"):
text = await _describe_image(content, filename)
material_type = "image"
elif suffix == "docx":
text = _extract_docx(content)
material_type = "docx"
elif suffix == "pptx":
text = _extract_pptx(content)
material_type = "pptx"
elif suffix == "pdf":
text = _extract_pdf(content)
material_type = "pdf"
elif suffix in {"xlsx", "xls"}:
text = _extract_xlsx(content)
material_type = "xlsx"
elif suffix in {"txt", "md", "csv", "json"} or media_type.startswith("text/"):
text = _decode_text(content)
material_type = suffix or "text"
else:
raise HTTPException(status_code=415, detail="暂不支持该文件类型,请上传 txt、md、csv、json、docx、pptx、pdf、xlsx 或图片")
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=400, detail=f"材料解析失败:{exc}") from exc
summary = _summarize_text(text)
account = spend_credits(db, current_user, "material_parse", f"解析材料:{filename}")
material = Material(
user_id=current_user.id,
filename=filename,
title=_title_from_filename(filename),
material_type=material_type,
summary=summary,
char_count=len(text),
size=len(content),
source="upload",
)
db.add(material)
db.commit()
db.refresh(material)
log_action(db, action="material_parse", user=current_user, request=request, target_type="material", target_id=material.id, detail=f"解析 {filename}{material_type}, {len(content)}B")
return {
"success": True,
"credits": credits_payload(account, "material_parse"),
"data": {
"material_id": material.id,
"filename": filename,
"title": material.title,
"material_type": material_type,
"size": len(content),
"summary": summary,
"char_count": len(text),
},
}
@router.post("/export/html")
def export_html(data: HtmlExportRequest, current_user: User = Depends(get_current_user)):
content = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{data.title}</title>
</head>
<body>
{data.html}
</body>
</html>
"""
filename = _safe_filename(data.title, ".html")
return StreamingResponse(
BytesIO(content.encode("utf-8")),
media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.post("/export/exam-docx")
def export_exam_docx(data: ExamExportRequest, current_user: User = Depends(get_current_user)):
document = Document()
document.add_heading(data.title or "试卷", level=1)
meta = " / ".join(part for part in [data.subject, data.grade] if part)
if meta:
document.add_paragraph(meta)
for idx, question in enumerate(data.questions, start=1):
q_type = question.get("type") or question.get("question_type") or "题目"
score = question.get("score")
heading = f"{idx}. [{q_type}]"
if score:
heading += f"{score}分)"
document.add_paragraph(heading)
document.add_paragraph(str(question.get("content") or question.get("question") or ""))
options = question.get("options") or []
if isinstance(options, list):
for option in options:
document.add_paragraph(str(option), style=None)
if data.answers:
document.add_page_break()
document.add_heading("参考答案", level=1)
for idx, answer in enumerate(data.answers, start=1):
document.add_paragraph(f"{idx}. {answer}")
buffer = BytesIO()
document.save(buffer)
buffer.seek(0)
filename = _safe_filename(data.title, ".docx")
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)