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"""