291 lines
9.9 KiB
Python
291 lines
9.9 KiB
Python
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,
|
||
)
|