0841b1a103
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等) - 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板 - AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容 - 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled - 测试账号:13900999999 / test1234
298 lines
11 KiB
Python
298 lines
11 KiB
Python
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)}"},
|
||
)
|