import logging from datetime import datetime, timezone, date from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from app.middleware.auth import get_current_user from app.models.report import Report, DataItem from app.models.user import User from app.schemas.report import ( ReportVerifyRequest, ReportResponse, ReportListItem, DataItemSchema, ) from app.services.storage import save_upload_file from app.services.ocr import ocr_recognize from app.utils.helpers import parse_object_id, mask_name logger = logging.getLogger(__name__) router = APIRouter() ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"} MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB @router.post("/upload") async def upload_report_image( file: UploadFile = File(...), user: User = Depends(get_current_user), ): """上传报告图片并进行 OCR 识别""" # Validate file type if file.content_type not in ALLOWED_CONTENT_TYPES: raise HTTPException( status_code=400, detail=f"不支持的文件类型: {file.content_type},请上传 JPG/PNG/WebP 图片", ) # Validate file size (read content and check) content = await file.read() if len(content) > MAX_FILE_SIZE: raise HTTPException(status_code=400, detail="文件大小不能超过 10MB") # Reset file position for storage await file.seek(0) url = await save_upload_file(file, sub_dir="reports") # OCR recognition try: ocr_result = await ocr_recognize(url) except FileNotFoundError: raise HTTPException(status_code=500, detail="文件保存异常,请重试") except RuntimeError as e: # OCR 服务已捕获并转换为友好消息 logger.warning("OCR 识别失败: %s", e) raise HTTPException(status_code=503, detail=str(e)) except Exception as e: logger.exception("OCR 识别失败") raise HTTPException( status_code=500, detail="AI识别服务暂时不可用,请稍后重试", ) # 检查是否为孕期相关报告 if not ocr_result.get("is_pregnancy_report", True): reject_reason = ocr_result.get("reject_reason", "") raise HTTPException( status_code=422, detail=f"请上传孕期检查报告。{reject_reason}", ) # 根据报告日期 + 末次经期推算检查时的孕周 report_date_str = ocr_result.get("report_date") pregnancy_week_at_report = user.pregnancy_week # 默认用用户当前孕周 if report_date_str and user.last_period_date: try: report_dt = date.fromisoformat(report_date_str) lmp = date.fromisoformat(user.last_period_date) if isinstance(user.last_period_date, str) else user.last_period_date delta_days = (report_dt - lmp).days if 0 < delta_days < 300: pregnancy_week_at_report = delta_days // 7 except (ValueError, TypeError): pass # Create report with OCR data data_items = [DataItem(**item) for item in ocr_result.get("data_items", [])] report = Report( user_id=user.id, report_type=ocr_result.get("report_type", ""), pregnancy_week=pregnancy_week_at_report, report_date=report_date_str, image_urls=[url], data_items=data_items, patient_name=ocr_result.get("patient_name"), status="pending", ) await report.insert() return { "report_id": str(report.id), "image_url": url, "ocr_result": { "report_type": report.report_type, "data_items": [item.model_dump() for item in data_items], "patient_name": mask_name(report.patient_name), }, } @router.post("/{report_id}/add-image") async def add_image_to_report( report_id: str, file: UploadFile = File(...), user: User = Depends(get_current_user), ): """向已有报告追加图片并进行 OCR 识别,合并数据""" oid = parse_object_id(report_id) report = await Report.get(oid) if not report or report.user_id != user.id: raise HTTPException(status_code=404, detail="报告不存在") if file.content_type not in ALLOWED_CONTENT_TYPES: raise HTTPException( status_code=400, detail=f"不支持的文件类型: {file.content_type},请上传 JPG/PNG/WebP 图片", ) content = await file.read() if len(content) > MAX_FILE_SIZE: raise HTTPException(status_code=400, detail="文件大小不能超过 10MB") await file.seek(0) url = await save_upload_file(file, sub_dir="reports") # OCR recognition try: ocr_result = await ocr_recognize(url) except FileNotFoundError: raise HTTPException(status_code=500, detail="文件保存异常,请重试") except RuntimeError as e: logger.warning("OCR 识别失败: %s", e) raise HTTPException(status_code=503, detail=str(e)) except Exception: logger.exception("OCR 识别失败") raise HTTPException(status_code=500, detail="AI识别服务暂时不可用,请稍后重试") # 检查是否为孕期相关报告 if not ocr_result.get("is_pregnancy_report", True): reject_reason = ocr_result.get("reject_reason", "") raise HTTPException( status_code=422, detail=f"请上传孕期检查报告。{reject_reason}", ) # Merge new data into existing report new_items = [DataItem(**item) for item in ocr_result.get("data_items", [])] report.image_urls.append(url) report.data_items.extend(new_items) # Update report_type if current report has multiple types new_type = ocr_result.get("report_type", "") if new_type and new_type != report.report_type: report.report_type = f"{report.report_type}+{new_type}" if report.report_type else new_type report.updated_at = datetime.now(timezone.utc) await report.save() return { "report_id": str(report.id), "image_url": url, "image_count": len(report.image_urls), "ocr_result": { "report_type": new_type, "data_items": [item.model_dump() for item in new_items], "patient_name": mask_name(ocr_result.get("patient_name")), }, "total_items": len(report.data_items), } @router.put("/{report_id}/verify", response_model=ReportResponse) async def verify_report( report_id: str, req: ReportVerifyRequest, user: User = Depends(get_current_user), ): """数据校对确认""" oid = parse_object_id(report_id) report = await Report.get(oid) if not report or report.user_id != user.id: raise HTTPException(status_code=404, detail="报告不存在") report.data_items = [DataItem(**item.model_dump()) for item in req.data_items] report.symptoms = req.symptoms report.notes = req.notes if req.report_type: report.report_type = req.report_type report.status = "verified" # Calculate overall status statuses = [item.status for item in report.data_items] if any(s in ("high", "low") for s in statuses): abnormal_count = sum(1 for s in statuses if s != "normal") report.overall_status = "warning" if abnormal_count >= 3 else "attention" else: report.overall_status = "normal" report.updated_at = datetime.now(timezone.utc) await report.save() return _report_to_response(report) @router.get("/list", response_model=list[ReportListItem]) async def list_reports( limit: int = 50, user: User = Depends(get_current_user), ): """获取报告列表""" reports = await Report.find( Report.user_id == user.id ).sort("-created_at").limit(limit).to_list() return [ ReportListItem( id=str(r.id), report_type=r.report_type, pregnancy_week=r.pregnancy_week, overall_status=r.overall_status, summary=_generate_summary(r), item_count=len(r.data_items), created_at=r.created_at, ) for r in reports ] @router.get("/{report_id}", response_model=ReportResponse) async def get_report(report_id: str, user: User = Depends(get_current_user)): """获取报告详情""" oid = parse_object_id(report_id) report = await Report.get(oid) if not report or report.user_id != user.id: raise HTTPException(status_code=404, detail="报告不存在") return _report_to_response(report) def _generate_summary(report: Report) -> str: """从报告指标中生成有意义的摘要文本""" if not report.data_items: return "暂无指标数据" # 优先展示异常指标 abnormal = [ item for item in report.data_items if item.status in ("high", "low") ] # 再补充正常的关键指标 normal = [item for item in report.data_items if item.status == "normal"] parts = [] # 先添加异常指标(最多2个) for item in abnormal[:2]: flag = "↑偏高" if item.status == "high" else "↓偏低" val = f"{item.name}{item.value}{item.unit}({flag})" if item.unit else f"{item.name}{item.value}({flag})" parts.append(val) # 再补充正常指标,总共最多3个 for item in normal[:3 - len(parts)]: val = f"{item.name}{item.value}{item.unit}" if item.unit else f"{item.name}{item.value}" parts.append(val) if not parts: return f"共{len(report.data_items)}项指标" return ",".join(parts) def _report_to_response(report: Report) -> ReportResponse: return ReportResponse( id=str(report.id), report_type=report.report_type, pregnancy_week=report.pregnancy_week, image_urls=report.image_urls, data_items=[DataItemSchema(**item.model_dump()) for item in report.data_items], symptoms=report.symptoms, notes=report.notes, status=report.status, overall_status=report.overall_status, created_at=report.created_at, )