智教助手平台:完整初始化
- 前端: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:
@@ -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)}"},
|
||||
)
|
||||
Reference in New Issue
Block a user