feat: 初始化孕育无忧全栈项目基础代码结构

完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
This commit is contained in:
2026-05-21 00:46:48 +08:00
commit 409dabafb5
142 changed files with 31010 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

39
backend/app/config.py Normal file
View File

@@ -0,0 +1,39 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# MongoDB
mongodb_url: str = "mongodb://localhost:27017"
mongodb_db_name: str = "pregnancy_report"
# JWT
jwt_secret_key: str = "change-this-secret-key"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 10080 # 7 days
# WeChat
wechat_app_id: str = ""
wechat_app_secret: str = ""
# AI API (OpenAI 兼容接口,如硅基流动)
ai_api_key: str = ""
ai_base_url: str = "https://api.siliconflow.cn/v1"
ai_vision_model: str = "Qwen/Qwen2.5-VL-72B-Instruct"
ai_text_model: str = "Qwen/Qwen2.5-72B-Instruct" # 使用更快的72B模型
# Upload
upload_dir: str = "./uploads"
max_file_size: int = 10 * 1024 * 1024 # 10MB
# CORS
cors_origins: str = "*" # 多个域名用逗号分隔,如 https://a.com,https://b.com
# Server
host: str = "0.0.0.0"
port: int = 8000
debug: bool = True
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()

38
backend/app/database.py Normal file
View File

@@ -0,0 +1,38 @@
import os
import certifi
from beanie import init_beanie
from app.config import settings
from app.models.user import User
from app.models.report import Report
from app.models.analysis import Analysis
from app.models.chat import ChatSession
from app.models.checkup import CheckupPlan
async def init_db():
use_mock = os.environ.get("USE_MONGOMOCK", "").lower() in ("1", "true", "yes")
if use_mock:
# 使用 mongomock 内存数据库(无需安装 MongoDB)
from mongomock_motor import AsyncMongoMockClient
client = AsyncMongoMockClient()
else:
from motor.motor_asyncio import AsyncIOMotorClient
# Only use TLS for Atlas (mongodb+srv://) connections
if settings.mongodb_url.startswith("mongodb+srv://") or "mongodb.net" in settings.mongodb_url:
client = AsyncIOMotorClient(settings.mongodb_url, tlsCAFile=certifi.where())
else:
client = AsyncIOMotorClient(settings.mongodb_url)
await init_beanie(
database=client[settings.mongodb_db_name],
document_models=[
User,
Report,
Analysis,
ChatSession,
CheckupPlan,
],
)

114
backend/app/main.py Normal file
View File

@@ -0,0 +1,114 @@
import logging
import os
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from app.config import settings
from app.database import init_db
from app.routers import (
auth,
user,
report,
analysis,
chat,
health_records,
calendar,
doctor,
share,
)
# 日志配置
logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("孕育无忧")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("服务启动中...")
await init_db()
os.makedirs(settings.upload_dir, exist_ok=True)
logger.info("服务启动完成 http://%s:%s", settings.host, settings.port)
yield
logger.info("服务关闭")
app = FastAPI(
title="孕育无忧 API",
description="孕期健康检查报告解读后端服务",
version="1.0.0",
lifespan=lifespan,
)
# CORS 配置
cors_origins = (
["*"]
if settings.cors_origins == "*"
else [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
)
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 请求日志中间件
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = round((time.time() - start) * 1000)
logger.info(
"%s %s → %s (%dms)",
request.method,
request.url.path,
response.status_code,
duration,
)
return response
# 全局异常处理
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error("未处理异常 %s %s: %s", request.method, request.url.path, exc, exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "服务器内部错误,请稍后重试"},
)
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
app.include_router(user.router, prefix="/api/user", tags=["用户"])
app.include_router(report.router, prefix="/api/report", tags=["报告"])
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI解读"])
app.include_router(chat.router, prefix="/api/chat", tags=["AI对话"])
app.include_router(health_records.router, prefix="/api/health-records", tags=["健康档案"])
app.include_router(calendar.router, prefix="/api/calendar", tags=["产检日历"])
app.include_router(doctor.router, prefix="/api/doctor", tags=["专家咨询"])
app.include_router(share.router, prefix="/api/share", tags=["分享"])
@app.get("/api/health")
async def health_check():
return {"status": "ok", "service": "孕育无忧 API"}
@app.get("/")
async def web_demo():
"""Web 体验页面"""
return FileResponse(
os.path.join(os.path.dirname(__file__), "static", "index.html")
)

View File

View File

@@ -0,0 +1,36 @@
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.config import settings
from app.models.user import User
security = HTTPBearer()
def create_access_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
to_encode = {"sub": user_id, "exp": expire}
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> User:
token = credentials.credentials
try:
payload = jwt.decode(
token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]
)
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证信息")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token已过期或无效")
user = await User.get(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
return user

View File

View File

@@ -0,0 +1,60 @@
from datetime import datetime, timezone
from typing import Optional
from beanie import Document, PydanticObjectId
from pydantic import BaseModel, Field
class IndicatorInterpretation(BaseModel):
"""单项指标解读"""
name: str
nickname: str = "" # 白话名称,如"氧气快递员"
value: str = ""
status: str = "normal"
what_is_it: str = "" # 通俗解释
impact: str = "" # 对宝宝的影响
suggestion: str = "" # 行动建议
recheck_reminder: Optional[str] = None
class CategoryInterpretation(BaseModel):
"""分类解读(如血液指标、激素水平)"""
category: str
status: str = "normal"
indicators: list[IndicatorInterpretation] = Field(default_factory=list)
class ActionItem(BaseModel):
"""行动建议"""
content: str
priority: str = "medium" # high / medium / low
category: str = "" # 饮食 / 运动 / 就医 / 复查
class Analysis(Document):
report_id: PydanticObjectId
user_id: PydanticObjectId
# Overall assessment
overall_status: str = "normal" # normal / attention / warning
risk_level: str = "low" # low / medium / high
overall_summary: str = "" # 整体评估文案
emotional_summary: str = "" # 情感化总结,如"宝宝发育良好"
# Detailed interpretations
categories: list[CategoryInterpretation] = Field(default_factory=list)
# Action items
action_items: list[ActionItem] = Field(default_factory=list)
# Tips
tips: list[str] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "analyses"
indexes = [
"user_id",
"report_id",
]

View File

@@ -0,0 +1,24 @@
from datetime import datetime, timezone
from beanie import Document, PydanticObjectId
from pydantic import BaseModel, Field
class ChatMessage(BaseModel):
role: str # user / assistant
content: str
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class ChatSession(Document):
user_id: PydanticObjectId
report_id: PydanticObjectId
messages: list[ChatMessage] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "chat_sessions"
indexes = [
[("user_id", 1), ("report_id", 1)],
]

View File

@@ -0,0 +1,30 @@
from datetime import datetime, timezone
from typing import Optional
from beanie import Document, PydanticObjectId
from pydantic import BaseModel, Field
class CheckupItem(BaseModel):
title: str
week: int
date: Optional[str] = None
items: list[str] = Field(default_factory=list)
preparations: list[str] = Field(default_factory=list)
notes: str = ""
status: str = "upcoming" # upcoming / completed / missed
importance: str = "medium" # high / medium / low
report_ids: list[str] = Field(default_factory=list) # linked report IDs
class CheckupPlan(Document):
user_id: PydanticObjectId
checkups: list[CheckupItem] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "checkup_plans"
indexes = [
"user_id",
]

View File

@@ -0,0 +1,50 @@
from datetime import datetime, timezone
from typing import Optional
from beanie import Document, PydanticObjectId
from pydantic import BaseModel, Field
class DataItem(BaseModel):
"""单个检查指标"""
name: str
value: str
unit: str = ""
reference_range: str = ""
status: str = "normal" # normal / high / low
category: str = "" # 血液检查 / 激素水平 / 尿常规 etc.
class Report(Document):
user_id: PydanticObjectId
report_type: str = "" # 血常规 / B超 / 尿常规 etc.
pregnancy_week: Optional[int] = None
report_date: Optional[str] = None # 报告日期 YYYY-MM-DD
# Images
image_urls: list[str] = Field(default_factory=list)
# OCR results
ocr_raw_text: Optional[str] = None
data_items: list[DataItem] = Field(default_factory=list)
# User additions
symptoms: Optional[list[str]] = None # 自觉症状
notes: Optional[str] = None # 备注
# Privacy fields (auto-masked)
patient_name: Optional[str] = None
patient_id_masked: Optional[str] = None
# Status
status: str = "pending" # pending / verified / analyzed
overall_status: str = "normal" # normal / attention / warning
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "reports"
indexes = [
"user_id",
]

View File

@@ -0,0 +1,55 @@
from datetime import datetime, timezone
from typing import Optional
from beanie import Document
from pymongo import IndexModel, ASCENDING
from pydantic import BaseModel, Field
class EmergencyContact(BaseModel):
name: str = ""
relation: str = ""
phone: str = ""
class User(Document):
openid: str = Field(..., unique=True)
union_id: Optional[str] = None
session_key: Optional[str] = None
# Basic info
name: str = ""
phone: Optional[str] = None
age: Optional[int] = None
height: Optional[float] = None # cm
pre_pregnancy_weight: Optional[float] = None # kg
current_weight: Optional[float] = None # kg
blood_type: Optional[str] = None
allergies: list[str] = Field(default_factory=list)
medical_history: list[str] = Field(default_factory=list)
# Pregnancy info
last_period_date: Optional[datetime] = None
due_date: Optional[datetime] = None
pregnancy_week: Optional[int] = None
pregnancy_day: Optional[int] = None
multiple_pregnancy: Optional[str] = None # 单胎/双胎/三胎+
pregnancy_count: Optional[int] = None # 第几次怀孕
emergency_contact: EmergencyContact = Field(default_factory=EmergencyContact)
# Status
is_onboarded: bool = False
avatar_url: Optional[str] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Settings:
name = "users"
indexes = [
IndexModel(
[("phone", ASCENDING)],
unique=True,
partialFilterExpression={"phone": {"$type": "string"}},
),
]

View File

@@ -0,0 +1,9 @@
from app.routers.auth import router as auth_router
from app.routers.user import router as user_router
from app.routers.report import router as report_router
from app.routers.analysis import router as analysis_router
from app.routers.chat import router as chat_router
from app.routers.health_records import router as health_records_router
from app.routers.calendar import router as calendar_router
from app.routers.doctor import router as doctor_router
from app.routers.share import router as share_router

View File

@@ -0,0 +1,147 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.analysis import (
Analysis,
CategoryInterpretation,
IndicatorInterpretation,
ActionItem,
)
from app.models.report import Report
from app.models.user import User
from app.services.ai_service import analyze_report
from app.routers.calendar import link_report_to_checkup
from app.utils.helpers import parse_object_id
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{report_id}/generate")
async def generate_analysis(
report_id: str, user: User = Depends(get_current_user)
):
"""生成 AI 解读"""
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="报告不存在")
# Check if analysis already exists
existing = await Analysis.find_one(Analysis.report_id == report.id)
if existing:
return _analysis_to_dict(existing)
# Call Claude API
try:
result = await analyze_report(report, user)
except Exception:
logger.exception("AI 解读失败 report_id=%s", report_id)
raise HTTPException(status_code=502, detail="AI 解读服务暂时不可用,请稍后重试")
# Save analysis
categories = []
for cat in result.get("categories", []):
indicators = [
IndicatorInterpretation(**ind) for ind in cat.get("indicators", [])
]
categories.append(
CategoryInterpretation(
category=cat.get("category", ""),
status=cat.get("status", "normal"),
indicators=indicators,
)
)
action_items = [ActionItem(**a) for a in result.get("action_items", [])]
analysis = Analysis(
report_id=report.id,
user_id=user.id,
overall_status=result.get("overall_status", "normal"),
risk_level=result.get("risk_level", "low"),
overall_summary=result.get("overall_summary", ""),
emotional_summary=result.get("emotional_summary", ""),
categories=categories,
action_items=action_items,
tips=result.get("tips", []),
)
await analysis.insert()
# Update report status
report.status = "analyzed"
report.overall_status = analysis.overall_status
report.updated_at = datetime.now(timezone.utc)
await report.save()
# Auto-link report to the nearest calendar checkup
if report.pregnancy_week:
try:
await link_report_to_checkup(user.id, str(report.id), report.pregnancy_week)
except Exception:
logger.warning("自动关联日历失败 report_id=%s", report_id)
return _analysis_to_dict(analysis)
@router.get("/{report_id}")
async def get_analysis(report_id: str, user: User = Depends(get_current_user)):
"""获取报告的 AI 解读结果"""
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="报告不存在")
analysis = await Analysis.find_one(Analysis.report_id == report.id)
if not analysis:
raise HTTPException(status_code=404, detail="暂无解读结果,请先生成")
return _analysis_to_dict(analysis)
def _analysis_to_dict(analysis: Analysis) -> dict:
return {
"id": str(analysis.id),
"report_id": str(analysis.report_id),
"overall_status": analysis.overall_status,
"risk_level": analysis.risk_level,
"overall_summary": analysis.overall_summary,
"emotional_summary": analysis.emotional_summary,
"categories": [
{
"category": cat.category,
"status": cat.status,
"indicators": [
{
"name": ind.name,
"nickname": ind.nickname,
"value": ind.value,
"unit": "",
"reference_range": "",
"status": ind.status,
"what_is_it": ind.what_is_it,
"baby_impact": ind.impact,
"suggestion": ind.suggestion,
"follow_up": ind.recheck_reminder or "",
}
for ind in cat.indicators
],
}
for cat in analysis.categories
],
"action_items": [
{
"priority": a.priority,
"title": a.content,
"description": a.category,
"timeframe": "",
}
for a in analysis.action_items
],
"tips": analysis.tips,
"created_at": analysis.created_at.isoformat(),
}

View File

@@ -0,0 +1,87 @@
import uuid
from fastapi import APIRouter, HTTPException
from pymongo.errors import DuplicateKeyError
from app.models.user import User
from app.schemas.auth import WechatLoginRequest, PhoneLoginRequest, TokenResponse
from app.middleware.auth import create_access_token
from app.services.wechat import code_to_session
router = APIRouter()
@router.post("/demo-login", response_model=TokenResponse)
async def demo_login():
"""Web 体验用的演示登录,每次创建独立的临时用户"""
demo_openid = f"demo_{uuid.uuid4().hex[:12]}"
user = User(
openid=demo_openid,
name="体验用户",
)
await user.insert()
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=True,
)
@router.post("/wechat-login", response_model=TokenResponse)
async def wechat_login(req: WechatLoginRequest):
"""微信小程序登录"""
try:
wechat_data = await code_to_session(req.code)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
openid = wechat_data["openid"]
user = await User.find_one(User.openid == openid)
is_new = user is None
if not user:
user = User(
openid=openid,
session_key=wechat_data.get("session_key"),
union_id=wechat_data.get("unionid"),
)
await user.insert()
else:
user.session_key = wechat_data.get("session_key")
await user.save()
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=is_new,
)
@router.post("/phone-login", response_model=TokenResponse)
async def phone_login(req: PhoneLoginRequest):
"""手机号登录(简化版,生产环境需接入短信验证)"""
# TODO: 接入真实短信验证服务
if req.sms_code != "123456":
raise HTTPException(status_code=400, detail="验证码错误")
user = await User.find_one(User.phone == req.phone)
is_new = user is None
if not user:
try:
user = User(openid=f"phone_{req.phone}", phone=req.phone)
await user.insert()
except DuplicateKeyError:
# 并发竞态:另一个请求已创建该用户,回退查找
user = await User.find_one(User.phone == req.phone)
is_new = False
token = create_access_token(str(user.id))
return TokenResponse(
access_token=token,
user_id=str(user.id),
is_new_user=is_new,
)

View File

@@ -0,0 +1,101 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.checkup import CheckupPlan, CheckupItem
from app.models.user import User
router = APIRouter()
# 标准产检计划
STANDARD_CHECKUPS = [
CheckupItem(title="首次产检", week=6, items=["B超确认宫内孕", "血常规", "尿常规", "肝功能", "肾功能"], preparations=["空腹", "带身份证和医保卡"], notes="确认怀孕和预产期", importance="high"),
CheckupItem(title="NT检查", week=12, items=["NT超声筛查", "血清学筛查", "血压", "体重"], preparations=["不需要空腹", "需憋尿"], notes="筛查唐氏综合征风险", importance="high"),
CheckupItem(title="唐氏筛查", week=16, items=["唐氏筛查抽血", "血压", "体重", "宫高腹围"], preparations=["空腹抽血"], notes="评估胎儿染色体异常风险", importance="high"),
CheckupItem(title="大排畸检查", week=22, items=["四维彩超", "血压", "体重", "宫高腹围"], preparations=["不需要空腹", "提前预约"], notes="全面筛查胎儿结构畸形", importance="high"),
CheckupItem(title="糖耐量检查", week=24, items=["OGTT糖耐量试验", "血压", "体重"], preparations=["空腹8-14小时", "检查前3天正常饮食"], notes="筛查妊娠期糖尿病", importance="high"),
CheckupItem(title="常规产检", week=28, items=["血常规", "尿常规", "血压", "体重", "胎心监护"], preparations=["空腹"], notes="进入孕晚期,增加产检频率", importance="medium"),
CheckupItem(title="常规产检", week=30, items=["B超评估胎儿发育", "血压", "体重", "胎心监护"], preparations=[], notes="评估胎儿生长发育", importance="medium"),
CheckupItem(title="常规产检", week=32, items=["血常规", "尿常规", "血压", "体重", "胎心监护", "B超"], preparations=["空腹"], notes="观察胎位", importance="medium"),
CheckupItem(title="GBS筛查", week=36, items=["B族链球菌筛查", "血压", "体重", "胎心监护", "内检"], preparations=[], notes="预防新生儿感染", importance="high"),
CheckupItem(title="每周产检", week=37, items=["胎心监护", "血压", "体重", "宫高腹围"], preparations=[], notes="每周一次,准备待产", importance="medium"),
CheckupItem(title="每周产检", week=38, items=["胎心监护", "B超评估", "血压", "体重"], preparations=[], notes="评估分娩时机", importance="medium"),
CheckupItem(title="每周产检", week=39, items=["胎心监护", "血压", "体重", "宫颈评估"], preparations=[], notes="关注产兆", importance="high"),
CheckupItem(title="预产期产检", week=40, items=["胎心监护", "B超", "血压", "体重", "催产评估"], preparations=[], notes="超过预产期需密切监护", importance="high"),
]
@router.get("/plan")
async def get_checkup_plan(user: User = Depends(get_current_user)):
"""获取产检计划"""
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user.id)
if not plan:
# Generate default plan
plan = CheckupPlan(user_id=user.id, checkups=STANDARD_CHECKUPS)
await plan.insert()
# Mark completed checkups based on current week
current_week = user.pregnancy_week or 0
checkups = []
for c in plan.checkups:
status = c.status
if c.week < current_week and status == "upcoming":
status = "missed"
checkups.append({
**c.model_dump(),
"status": status,
})
# Find next checkup
next_checkup = None
for c in checkups:
if c["status"] == "upcoming":
next_checkup = c
break
return {
"checkups": checkups,
"next_checkup": next_checkup,
"current_week": current_week,
}
@router.put("/plan/{week}/complete")
async def complete_checkup(week: int, user: User = Depends(get_current_user)):
"""标记产检已完成"""
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user.id)
if not plan:
raise HTTPException(status_code=404, detail="产检计划不存在")
for checkup in plan.checkups:
if checkup.week == week:
checkup.status = "completed"
break
plan.updated_at = datetime.now(timezone.utc)
await plan.save()
return {"message": "已标记完成"}
async def link_report_to_checkup(user_id, report_id: str, pregnancy_week: int):
"""将报告自动关联到最近的产检(±2周窗口),并标记该产检为已完成"""
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user_id)
if not plan:
return
best = None
best_diff = 999
for c in plan.checkups:
diff = abs(c.week - pregnancy_week)
if diff <= 2 and diff < best_diff:
best = c
best_diff = diff
if best:
if report_id not in best.report_ids:
best.report_ids.append(report_id)
best.status = "completed"
plan.updated_at = datetime.now(timezone.utc)
await plan.save()

View File

@@ -0,0 +1,98 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.chat import ChatSession, ChatMessage
from app.models.report import Report
from app.models.user import User
from app.schemas.chat import ChatMessageRequest, ChatSessionResponse, ChatMessageResponse
from app.services.ai_service import chat_with_ai
from app.utils.helpers import parse_object_id
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{report_id}/message")
async def send_message(
report_id: str,
req: ChatMessageRequest,
user: User = Depends(get_current_user),
):
"""发送消息并获取 AI 回复"""
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="报告不存在")
# Get or create chat session
session = await ChatSession.find_one(
ChatSession.user_id == user.id,
ChatSession.report_id == report.id,
)
if not session:
session = ChatSession(user_id=user.id, report_id=report.id)
await session.insert()
# Add user message
user_msg = ChatMessage(role="user", content=req.content)
session.messages.append(user_msg)
# Get AI response
history = [{"role": m.role, "content": m.content} for m in session.messages[:-1]]
try:
ai_response = await chat_with_ai(report, user, history, req.content)
except Exception:
logger.exception("AI 对话失败 report_id=%s", report_id)
raise HTTPException(status_code=502, detail="AI 对话服务暂时不可用,请稍后重试")
# Add AI message
ai_msg = ChatMessage(role="assistant", content=ai_response)
session.messages.append(ai_msg)
session.updated_at = datetime.now(timezone.utc)
await session.save()
return {
"user_message": {"role": "user", "content": req.content, "timestamp": user_msg.timestamp.isoformat()},
"ai_message": {"role": "assistant", "content": ai_response, "timestamp": ai_msg.timestamp.isoformat()},
}
@router.get("/{report_id}/history")
async def get_chat_history(
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="报告不存在")
session = await ChatSession.find_one(
ChatSession.user_id == user.id,
ChatSession.report_id == report.id,
)
if not session:
return {"messages": [], "quick_questions": _get_quick_questions()}
return {
"messages": [
{"role": m.role, "content": m.content, "timestamp": m.timestamp.isoformat()}
for m in session.messages
],
"quick_questions": _get_quick_questions(),
}
def _get_quick_questions() -> list[str]:
return [
"这个指标偏高会影响宝宝吗?",
"我需要注意什么饮食?",
"什么时候需要复查?",
"这个结果严重吗?",
"有什么改善建议?",
]

View File

@@ -0,0 +1,96 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from app.middleware.auth import get_current_user
from app.models.report import Report
from app.models.user import User
from app.services.ai_service import generate_doctor_questions
from app.utils.helpers import parse_object_id
logger = logging.getLogger(__name__)
router = APIRouter()
# 预设医生列表
DOCTORS = [
{
"id": "1",
"name": "王丽华",
"title": "主任医师",
"hospital": "北京协和医院",
"specialty": "高危妊娠、产前诊断",
"experience": "25年",
"rating": 4.9,
"review_count": 1280,
"consult_count": 5600,
"response_time": "30分钟内",
"price": {"text": 50, "video": 200, "urgent": 100},
"tags": ["高危妊娠", "产前诊断", "遗传咨询"],
},
{
"id": "2",
"name": "张美琳",
"title": "副主任医师",
"hospital": "上海第一妇婴保健院",
"specialty": "孕期营养、妊娠期糖尿病",
"experience": "18年",
"rating": 4.8,
"review_count": 960,
"consult_count": 3800,
"response_time": "1小时内",
"price": {"text": 40, "video": 180, "urgent": 80},
"tags": ["孕期营养", "妊娠糖尿病", "体重管理"],
},
{
"id": "3",
"name": "刘晓芳",
"title": "主任医师",
"hospital": "广州市妇女儿童医疗中心",
"specialty": "超声诊断、胎儿医学",
"experience": "20年",
"rating": 4.9,
"review_count": 1100,
"consult_count": 4200,
"response_time": "2小时内",
"price": {"text": 45, "video": 190, "urgent": 90},
"tags": ["超声诊断", "胎儿医学", "产前筛查"],
},
]
@router.get("/questions/{report_id}")
async def get_doctor_questions(
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="报告不存在")
try:
result = await generate_doctor_questions(report, user)
except Exception:
logger.exception("问诊清单生成失败 report_id=%s", report_id)
raise HTTPException(status_code=502, detail="AI 服务暂时不可用,请稍后重试")
return {
"report_id": report_id,
"questions": result.get("questions", []),
}
@router.get("/experts")
async def list_experts():
"""获取专家医生列表"""
return {"doctors": DOCTORS}
@router.get("/experts/{doctor_id}")
async def get_expert_detail(doctor_id: str):
"""获取医生详情"""
for doc in DOCTORS:
if doc["id"] == doctor_id:
return doc
raise HTTPException(status_code=404, detail="医生不存在")

View File

@@ -0,0 +1,258 @@
import re
from typing import Optional, Tuple
from beanie import PydanticObjectId
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user
from app.models.report import Report
from app.models.user import User
router = APIRouter()
def _parse_numeric_value(value) -> Optional[float]:
value_str = str(value).strip() if value is not None else ""
if not value_str:
return None
match = re.search(r'(\d+\.?\d*)', value_str)
if not match:
return None
try:
return float(match.group(1))
except (ValueError, AttributeError):
return None
def _parse_reference_range(reference_range: str) -> Tuple[Optional[float], Optional[float]]:
if not reference_range:
return None, None
match = re.search(r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)', reference_range)
if not match:
return None, None
return float(match.group(1)), float(match.group(2))
@router.get("/overview")
async def get_health_overview(user: User = Depends(get_current_user)):
"""健康档案概览"""
reports = await Report.find(Report.user_id == user.id).sort("-created_at").to_list()
total = len(reports)
normal = sum(1 for r in reports if r.overall_status == "normal")
attention = sum(1 for r in reports if r.overall_status == "attention")
warning = sum(1 for r in reports if r.overall_status == "warning")
recent = [
{
"id": str(r.id),
"report_type": r.report_type,
"overall_status": r.overall_status,
"created_at": r.created_at.isoformat(),
}
for r in reports[:5]
]
return {
"stats": {
"total": total,
"normal": normal,
"attention": attention,
"warning": warning,
},
"recent_reports": recent,
}
@router.get("/growth-curve")
async def get_growth_curve(user: User = Depends(get_current_user)):
"""获取胎儿生长曲线数据(从B超报告提取)"""
reports = await Report.find(
Report.user_id == user.id,
Report.report_type == "B超",
).sort("pregnancy_week").to_list()
data_points = []
for r in reports:
if not r.pregnancy_week:
continue
point = {"week": r.pregnancy_week}
for item in r.data_items:
name_lower = item.name.lower()
numeric_value = _parse_numeric_value(item.value)
if numeric_value is None:
continue
# 匹配指标名称
if "双顶径" in item.name or "bpd" in name_lower:
point["bpd"] = numeric_value
elif "腹围" in item.name or "ac" in name_lower:
point["ac"] = numeric_value
elif "股骨长" in item.name or "fl" in name_lower:
point["fl"] = numeric_value
# 只有包含至少一个生长指标的数据点才添加
if any(k in point for k in ["bpd", "ac", "fl"]):
data_points.append(point)
return {"data_points": data_points}
@router.get("/timeline")
async def get_timeline(user: User = Depends(get_current_user)):
"""报告时间轴"""
reports = await Report.find(Report.user_id == user.id).sort("-created_at").to_list()
timeline = []
for r in reports:
week = r.pregnancy_week or 0
if week <= 12:
period = "early"
elif week <= 28:
period = "mid"
else:
period = "late"
timeline.append({
"id": str(r.id),
"report_type": r.report_type,
"date": r.created_at.isoformat(),
"status": r.overall_status,
"summary": f"{len(r.data_items)}项指标",
"period": period,
"week": week,
})
return {"reports": timeline}
@router.get("/indicator-trends")
async def get_indicator_trends(user: User = Depends(get_current_user)):
"""获取跨报告指标趋势数据(同名数值指标出现2+次的)"""
reports = await Report.find(
Report.user_id == user.id,
).sort("pregnancy_week", "created_at").to_list()
# 收集所有指标:indicator_name -> list of {week, value, unit, ref_range, status, date, report_type}
indicator_map: dict[str, list[dict]] = {}
for r in reports:
week = r.pregnancy_week or 0
date_str = r.created_at.isoformat() if r.created_at else ""
for item in r.data_items:
value_str = str(item.value).strip() if item.value else ""
if not value_str:
continue
match = re.search(r'(\d+\.?\d*)', value_str)
if not match:
continue
try:
numeric_value = float(match.group(1))
except (ValueError, AttributeError):
continue
name = item.name.strip()
if name not in indicator_map:
indicator_map[name] = []
indicator_map[name].append({
"week": week,
"value": numeric_value,
"unit": item.unit or "",
"reference_range": item.reference_range or "",
"status": item.status or "normal",
"date": date_str,
"report_type": r.report_type,
})
# 保留所有有数据的指标(含仅1次的,用于综合分析)
indicators = []
for name, points in indicator_map.items():
# 解析参考范围(取第一个非空的)
ref_lower = None
ref_upper = None
for p in points:
if p["reference_range"]:
# 尝试解析 "3.5-5.0" 或 "3.5~5.0" 格式
ref_match = re.search(r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)', p["reference_range"])
if ref_match:
ref_lower = float(ref_match.group(1))
ref_upper = float(ref_match.group(2))
break
indicators.append({
"name": name,
"unit": points[0]["unit"],
"ref_lower": ref_lower,
"ref_upper": ref_upper,
"points": [{"week": p["week"], "value": p["value"], "date": p["date"],
"status": p["status"], "report_type": p["report_type"]} for p in points],
})
return {"indicators": indicators}
@router.get("/report-type-trends")
async def get_report_type_trends(user: User = Depends(get_current_user)):
"""按报告类型分组的指标趋势(同类型报告中出现2+次的数值指标)"""
reports = await Report.find(
Report.user_id == user.id,
).sort("pregnancy_week", "created_at").to_list()
# report_type -> indicator_name -> list of data points
type_map: dict[str, dict[str, list[dict]]] = {}
for r in reports:
rt = r.report_type or "其他"
week = r.pregnancy_week or 0
date_str = r.created_at.isoformat() if r.created_at else ""
if rt not in type_map:
type_map[rt] = {}
for item in r.data_items:
numeric_value = _parse_numeric_value(item.value)
if numeric_value is None:
continue
name = item.name.strip()
if name not in type_map[rt]:
type_map[rt][name] = []
type_map[rt][name].append({
"week": week,
"value": numeric_value,
"unit": item.unit or "",
"reference_range": item.reference_range or "",
"status": item.status or "normal",
"date": date_str,
})
# 组装结果:保留所有有数据的指标(含仅1个数据点的)
result = []
for rt, ind_map in type_map.items():
indicators = []
for name, points in ind_map.items():
ref_lower, ref_upper = None, None
for p in points:
ref_lower, ref_upper = _parse_reference_range(p["reference_range"])
if ref_lower is not None:
break
indicators.append({
"name": name,
"unit": points[0]["unit"],
"ref_lower": ref_lower,
"ref_upper": ref_upper,
"points": [{"week": p["week"], "value": p["value"], "date": p["date"],
"status": p["status"]} for p in points],
})
if indicators:
# 按数据点数量降序,让最有数据的指标排前面
indicators.sort(key=lambda x: len(x["points"]), reverse=True)
result.append({
"report_type": rt,
"report_count": sum(1 for r in reports if (r.report_type or "其他") == rt),
"indicators": indicators,
})
# 按报告份数降序
result.sort(key=lambda x: x["report_count"], reverse=True)
return {"report_types": result}

View File

@@ -0,0 +1,290 @@
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,
)

View File

@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.middleware.auth import get_current_user
from app.models.analysis import Analysis
from app.models.report import Report
from app.models.user import User
from app.utils.helpers import parse_object_id, mask_name
router = APIRouter()
class ShareOptions(BaseModel):
privacy_mode: bool = True # 隐私模式,脱敏个人信息
include_details: bool = False # 是否包含详细数据
@router.post("/{report_id}/generate")
async def generate_share_data(
report_id: str,
options: ShareOptions = ShareOptions(privacy_mode=True, include_details=True),
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="报告不存在")
analysis = await Analysis.find_one(Analysis.report_id == report.id)
if not analysis:
raise HTTPException(status_code=404, detail="暂无解读结果")
# Build share data
share_data = {
"report_type": report.report_type,
"pregnancy_week": report.pregnancy_week,
"overall_status": analysis.overall_status,
"risk_level": analysis.risk_level,
"overall_summary": analysis.overall_summary,
"emotional_summary": analysis.emotional_summary,
"action_items": [a.model_dump() for a in analysis.action_items],
"tips": analysis.tips,
"categories": [
{
"category": cat.category,
"status": cat.status,
"indicators": [
{
"name": ind.name,
"nickname": ind.nickname,
"value": ind.value,
"status": ind.status,
"what_is_it": ind.what_is_it,
"impact": ind.impact,
"suggestion": ind.suggestion,
}
for ind in cat.indicators
],
}
for cat in analysis.categories
],
}
if not options.privacy_mode:
share_data["user_name"] = user.name
else:
share_data["user_name"] = mask_name(user.name)
return {"share_data": share_data}

View File

@@ -0,0 +1,84 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user
from app.models.user import User
from app.schemas.user import (
OnboardingRequest,
UserProfileUpdate,
UserProfileResponse,
)
router = APIRouter()
def _user_to_response(user: User) -> UserProfileResponse:
return UserProfileResponse(
id=str(user.id),
name=user.name,
phone=user.phone,
age=user.age,
height=user.height,
pre_pregnancy_weight=user.pre_pregnancy_weight,
current_weight=user.current_weight,
blood_type=user.blood_type,
allergies=user.allergies,
medical_history=user.medical_history,
last_period_date=user.last_period_date,
due_date=user.due_date,
pregnancy_week=user.pregnancy_week,
pregnancy_day=user.pregnancy_day,
multiple_pregnancy=user.multiple_pregnancy,
pregnancy_count=user.pregnancy_count,
emergency_contact=user.emergency_contact.model_dump() if user.emergency_contact else None,
is_onboarded=user.is_onboarded,
avatar_url=user.avatar_url,
created_at=user.created_at,
)
@router.get("/profile", response_model=UserProfileResponse)
async def get_profile(user: User = Depends(get_current_user)):
"""获取用户信息"""
return _user_to_response(user)
@router.post("/onboarding", response_model=UserProfileResponse)
async def complete_onboarding(
req: OnboardingRequest, user: User = Depends(get_current_user)
):
"""完成用户画像设置"""
user.name = req.name or user.name
user.age = req.age
user.height = req.height
user.pre_pregnancy_weight = req.pre_pregnancy_weight
user.current_weight = req.current_weight
user.blood_type = req.blood_type
user.allergies = req.allergies
user.medical_history = req.medical_history
user.last_period_date = req.last_period_date
user.due_date = req.due_date
user.pregnancy_week = req.pregnancy_week
user.pregnancy_day = req.pregnancy_day
user.multiple_pregnancy = req.multiple_pregnancy
user.pregnancy_count = req.pregnancy_count
if req.emergency_contact:
user.emergency_contact = req.emergency_contact
user.is_onboarded = True
user.updated_at = datetime.now(timezone.utc)
await user.save()
return _user_to_response(user)
@router.put("/profile", response_model=UserProfileResponse)
async def update_profile(
req: UserProfileUpdate, user: User = Depends(get_current_user)
):
"""更新用户信息"""
update_data = req.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
user.updated_at = datetime.now(timezone.utc)
await user.save()
return _user_to_response(user)

View File

View File

@@ -0,0 +1,20 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class WechatLoginRequest(BaseModel):
code: str # wx.login() 获取的 code
class PhoneLoginRequest(BaseModel):
phone: str
sms_code: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
is_new_user: bool = False

View File

@@ -0,0 +1,21 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class ChatMessageRequest(BaseModel):
content: str
class ChatMessageResponse(BaseModel):
role: str
content: str
timestamp: datetime
class ChatSessionResponse(BaseModel):
id: str
report_id: str
messages: list[ChatMessageResponse]
created_at: datetime

View File

@@ -0,0 +1,44 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class DataItemSchema(BaseModel):
name: str
value: str
unit: str = ""
reference_range: str = ""
status: str = "normal"
category: str = ""
class ReportVerifyRequest(BaseModel):
"""数据校对提交"""
data_items: list[DataItemSchema]
symptoms: Optional[list[str]] = None
notes: Optional[str] = None
report_type: Optional[str] = None
class ReportResponse(BaseModel):
id: str
report_type: str
pregnancy_week: Optional[int]
image_urls: list[str]
data_items: list[DataItemSchema]
symptoms: Optional[list[str]]
notes: Optional[str]
status: str
overall_status: str
created_at: datetime
class ReportListItem(BaseModel):
id: str
report_type: str
pregnancy_week: Optional[int]
overall_status: str
summary: str = ""
item_count: int = 0
created_at: datetime

View File

@@ -0,0 +1,70 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class EmergencyContactSchema(BaseModel):
name: str = ""
relation: str = ""
phone: str = ""
class UserProfileUpdate(BaseModel):
name: Optional[str] = None
age: Optional[int] = None
phone: Optional[str] = None
height: Optional[float] = None
pre_pregnancy_weight: Optional[float] = None
current_weight: Optional[float] = None
blood_type: Optional[str] = None
allergies: Optional[list[str]] = None
medical_history: Optional[list[str]] = None
last_period_date: Optional[datetime] = None
due_date: Optional[datetime] = None
pregnancy_week: Optional[int] = None
pregnancy_day: Optional[int] = None
multiple_pregnancy: Optional[str] = None
pregnancy_count: Optional[int] = None
emergency_contact: Optional[EmergencyContactSchema] = None
class OnboardingRequest(BaseModel):
name: Optional[str] = ""
age: Optional[int] = None
height: Optional[float] = None
pre_pregnancy_weight: Optional[float] = None
current_weight: Optional[float] = None
blood_type: Optional[str] = None
allergies: list[str] = []
medical_history: list[str] = []
last_period_date: Optional[datetime] = None
due_date: Optional[datetime] = None
pregnancy_week: Optional[int] = None
pregnancy_day: Optional[int] = None
multiple_pregnancy: Optional[str] = None
pregnancy_count: Optional[int] = None
emergency_contact: Optional[EmergencyContactSchema] = None
class UserProfileResponse(BaseModel):
id: str
name: str
phone: Optional[str]
age: Optional[int]
height: Optional[float]
pre_pregnancy_weight: Optional[float]
current_weight: Optional[float]
blood_type: Optional[str]
allergies: list[str]
medical_history: list[str]
last_period_date: Optional[datetime]
due_date: Optional[datetime]
pregnancy_week: Optional[int]
pregnancy_day: Optional[int]
multiple_pregnancy: Optional[str]
pregnancy_count: Optional[int]
emergency_contact: Optional[EmergencyContactSchema]
is_onboarded: bool
avatar_url: Optional[str]
created_at: datetime

View File

View File

@@ -0,0 +1,239 @@
"""
AI service: Report analysis, chat, doctor questions.
Uses OpenAI-compatible API (e.g. SiliconFlow).
"""
import json
import logging
from typing import Optional
from openai import AsyncOpenAI
from app.config import settings
from app.models.report import Report
from app.models.user import User
logger = logging.getLogger(__name__)
def _get_client() -> AsyncOpenAI:
return AsyncOpenAI(api_key=settings.ai_api_key, base_url=settings.ai_base_url)
def _build_report_context(report: Report, user: User) -> str:
"""构建报告上下文给 AI"""
lines = [
f"孕妈信息: 年龄{user.age}岁",
]
if report.pregnancy_week:
lines.append(f"检查时孕周: 第{report.pregnancy_week}周")
if report.report_date:
lines.append(f"报告日期: {report.report_date}")
if user.last_period_date:
lines.append(f"末次月经: {user.last_period_date}")
if user.height:
lines.append(f"身高: {user.height}cm")
if user.current_weight:
lines.append(f"当前体重: {user.current_weight}kg")
if user.blood_type:
lines.append(f"血型: {user.blood_type}")
if user.medical_history:
lines.append(f"病史: {', '.join(user.medical_history)}")
lines.append(f"\n报告类型: {report.report_type}")
lines.append("\n检查指标:")
for item in report.data_items:
ref = f", 参考范围: {item.reference_range}" if item.reference_range else ""
lines.append(f" - {item.name}: {item.value} {item.unit}{ref}")
if report.symptoms:
lines.append(f"\n自觉症状: {', '.join(report.symptoms)}")
return "\n".join(lines)
def _parse_json_response(text: str) -> dict:
"""从 AI 回复中提取 JSON"""
if "```json" in text:
text = text.split("```json")[1].split("```")[0]
elif "```" in text:
text = text.split("```")[1].split("```")[0]
return json.loads(text.strip())
async def _chat_complete(system: str, user_content: str, messages: Optional[list] = None, max_tokens: int = 4096) -> str:
"""统一调用 AI 文本模型"""
client = _get_client()
msgs = [{"role": "system", "content": system}]
if messages:
msgs.extend(messages)
else:
msgs.append({"role": "user", "content": user_content})
try:
response = await client.chat.completions.create(
model=settings.ai_text_model,
max_tokens=max_tokens,
temperature=0.7, # 降低温度提高生成速度
messages=msgs,
)
except Exception as e:
err_msg = str(e)
logger.error("AI 调用失败: %s: %s", type(e).__name__, err_msg[:200])
if "auth" in err_msg.lower() or "api key" in err_msg.lower():
raise RuntimeError("AI 服务认证失败,请检查 API Key") from e
raise RuntimeError("AI 服务暂时不可用,请稍后重试") from e
return response.choices[0].message.content
# ─── Report Analysis ───
ANALYSIS_SYSTEM_PROMPT = """你是一位专业、温暖的孕期健康顾问AI。你的任务是解读孕期检查报告,用通俗易懂的语言帮助孕妈理解检查结果。
核心要求:
1. 必须结合孕周来判断指标是否正常(孕期不同阶段的正常范围不同,如血红蛋白在孕中晚期会生理性下降)
2. 将每个指标的测定结果与报告中的参考范围对比,准确判断偏高/偏低/正常
3. 如果参考范围缺失,根据你的医学知识和该孕周的标准范围来判断
4. 用温暖、关怀的语气,避免医学术语恐吓
5. 对每个指标给出白话化的昵称(如血红蛋白→"氧气快递员",双顶径→"宝宝头围",腹围→"宝宝肚围",股骨长→"宝宝腿长")
6. 解释每个指标的含义、对宝宝的影响、行动建议
7. 给出整体评估和风险等级
8. 行动建议按优先级排序
**特别注意:**
- 如果是B超报告,重点关注胎儿生长指标(双顶径、腹围、股骨长),即使只有这3个指标也要给出完整的解读
- B超报告的解读要重点说明宝宝的生长发育情况,是否符合孕周标准
- 即使指标数量很少(如只有3个),也要给出详细的解读和建议
请严格按以下JSON格式返回:
{
"overall_status": "normal/attention/warning",
"risk_level": "low/medium/high",
"overall_summary": "整体评估文案",
"emotional_summary": "情感化总结,如'宝宝发育良好,继续保持'",
"categories": [
{
"category": "分类名称",
"status": "normal/attention/warning",
"indicators": [
{
"name": "指标名",
"nickname": "白话昵称",
"value": "值",
"status": "normal/high/low",
"what_is_it": "通俗解释",
"impact": "对宝宝的影响",
"suggestion": "行动建议",
"recheck_reminder": "复查提醒(可选)"
}
]
}
],
"action_items": [
{
"content": "具体建议",
"priority": "high/medium/low",
"category": "饮食/运动/就医/复查"
}
],
"tips": ["温馨提示1", "温馨提示2"]
}"""
async def analyze_report(report: Report, user: User) -> dict:
"""调用 AI 解读报告"""
logger.info("开始AI解读报告 report_id=%s", report.id)
context = _build_report_context(report, user)
response_text = await _chat_complete(
system=ANALYSIS_SYSTEM_PROMPT,
user_content=f"请解读以下孕期检查报告:\n\n{context}",
)
try:
return _parse_json_response(response_text)
except (json.JSONDecodeError, IndexError):
return {
"overall_status": "normal",
"risk_level": "low",
"overall_summary": response_text,
"emotional_summary": "报告解读完成",
"categories": [],
"action_items": [],
"tips": [],
}
# ─── Chat ───
CHAT_SYSTEM_PROMPT = """你是一位专业、温暖的孕期健康顾问AI。孕妈正在就她的检查报告向你提问。
要求:
1. 用通俗易懂、温暖关怀的语气回答
2. 基于报告数据给出专业建议
3. 遇到严重问题建议及时就医
4. 回答简洁明了,避免过长
以下是孕妈的检查报告信息:
{report_context}"""
async def chat_with_ai(
report: Report, user: User, messages: list[dict], user_message: str
) -> str:
"""AI 对话追问"""
context = _build_report_context(report, user)
system = CHAT_SYSTEM_PROMPT.format(report_context=context)
conversation = [{"role": "system", "content": system}]
for msg in messages[-10:]:
conversation.append({"role": msg["role"], "content": msg["content"]})
conversation.append({"role": "user", "content": user_message})
client = _get_client()
try:
response = await client.chat.completions.create(
model=settings.ai_text_model,
max_tokens=1024,
messages=conversation,
)
except Exception as e:
logger.error("AI 对话失败: %s", e)
raise RuntimeError("AI 对话服务暂时不可用") from e
return response.choices[0].message.content
# ─── Doctor Questions ───
DOCTOR_QUESTIONS_SYSTEM_PROMPT = """你是一位孕期健康顾问AI。根据检查报告中的异常项,帮孕妈生成3-5条看医生时可以问的问题。
要求:
1. 问题要专业但容易理解
2. 按重要性排序
3. 给出为什么要问这个问题的原因
请严格按以下JSON格式返回:
{
"questions": [
{
"category": "分类",
"question": "问医生的话术",
"reason": "为什么要问这个",
"priority": "high/medium/low"
}
]
}"""
async def generate_doctor_questions(report: Report, user: User) -> dict:
"""生成问诊清单"""
context = _build_report_context(report, user)
response_text = await _chat_complete(
system=DOCTOR_QUESTIONS_SYSTEM_PROMPT,
user_content=f"请根据以下报告生成问诊清单:\n\n{context}",
max_tokens=1024,
)
try:
return _parse_json_response(response_text)
except (json.JSONDecodeError, IndexError):
return {"questions": []}

142
backend/app/services/ocr.py Normal file
View File

@@ -0,0 +1,142 @@
"""
OCR service: Use vision model to extract medical report data from images.
Supports OpenAI-compatible APIs (e.g. SiliconFlow).
"""
import base64
import json
import logging
from pathlib import Path
import aiofiles
from openai import AsyncOpenAI
from app.config import settings
logger = logging.getLogger(__name__)
OCR_PROMPT = """请分析这张图片,首先判断它是否是孕期/产检相关的医疗检查报告。
**孕期相关报告包括但不限于:**
- 产检报告(B超/彩超、NT检查、四维彩超、大排畸等)
- 血液检查(血常规、血糖、血型、唐氏筛查、TORCH、甲功等)
- 尿液检查(尿常规等)
- 肝肾功能检查
- 凝血功能检查
- 心电图报告
- 白带常规
- 胎心监护报告
- 其他与孕期产检相关的检查报告
**不属于孕期报告的情况:**
- 非医疗报告(发票、收据、菜单、截图、照片等)
- 与孕期无关的医疗报告(如骨科、眼科、口腔科等非产检相关)
- 无法识别内容的模糊图片
**请严格按以下JSON格式返回(不要输出其他内容):**
如果**不是**孕期相关报告,返回:
{
"is_pregnancy_report": false,
"reject_reason": "简短说明为什么不是孕期报告(如:这是一张超市购物小票)"
}
如果**是**孕期相关报告,提取所有检查指标并返回:
{
"is_pregnancy_report": true,
"patient_name": "患者姓名(如有)",
"report_type": "报告类型(如血常规、尿常规、B超等)",
"report_date": "报告日期(如有,格式 YYYY-MM-DD)",
"data_items": [
{
"name": "测定项目",
"value": "测定结果(只提取数字,不要单位)",
"unit": "单位",
"reference_range": "参考范围(如有)"
}
]
}
**重要提示:**
1. 必须先判断是否为孕期报告,is_pregnancy_report 字段必填
2. value字段只填写纯数字,不要包含单位(如"23.5"而不是"23.5mm")
3. 单位单独放在unit字段中
4. 如果是B超报告,务必确保双顶径(BPD)、腹围(AC)、股骨长(FL)这三个指标被正确提取"""
def _get_client() -> AsyncOpenAI:
return AsyncOpenAI(api_key=settings.ai_api_key, base_url=settings.ai_base_url)
async def ocr_recognize(image_path: str) -> dict:
"""
Use vision model to extract medical report data from an image.
Returns structured OCR results.
"""
# Resolve path relative to upload_dir
if image_path.startswith("/uploads/"):
relative = image_path[len("/uploads/"):]
full_path = Path(settings.upload_dir) / relative
else:
full_path = Path(settings.upload_dir) / image_path.lstrip("/")
if not full_path.exists():
logger.error("OCR 文件不存在: %s", full_path)
raise FileNotFoundError(f"报告图片不存在: {image_path}")
# Async file read
async with aiofiles.open(full_path, "rb") as f:
raw = await f.read()
image_data = base64.standard_b64encode(raw).decode("utf-8")
# Determine media type
suffix = full_path.suffix.lower()
media_type_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
media_type = media_type_map.get(suffix, "image/jpeg")
client = _get_client()
try:
response = await client.chat.completions.create(
model=settings.ai_vision_model,
max_tokens=2048,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{image_data}",
},
},
{
"type": "text",
"text": OCR_PROMPT,
},
],
}
],
)
except Exception as e:
err_msg = str(e)
logger.error("AI OCR 调用失败: %s: %s", type(e).__name__, err_msg[:200])
if "auth" in err_msg.lower() or "api key" in err_msg.lower():
raise RuntimeError("AI 服务认证失败,请检查 API Key 是否有效") from e
if "<html" in err_msg.lower() or "cloudflare" in err_msg.lower():
raise RuntimeError("AI 服务请求被拦截,请检查 API 配置") from e
raise RuntimeError("AI 识别服务暂时不可用,请稍后重试") from e
response_text = response.choices[0].message.content
try:
if "```json" in response_text:
response_text = response_text.split("```json")[1].split("```")[0]
elif "```" in response_text:
response_text = response_text.split("```")[1].split("```")[0]
return json.loads(response_text.strip())
except json.JSONDecodeError:
return {"raw_text": response_text, "data_items": []}

View File

@@ -0,0 +1,24 @@
import os
import uuid
from pathlib import Path
import aiofiles
from fastapi import UploadFile
from app.config import settings
async def save_upload_file(file: UploadFile, sub_dir: str = "reports") -> str:
"""Save uploaded file and return the relative URL path"""
upload_path = Path(settings.upload_dir) / sub_dir
upload_path.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename).suffix if file.filename else ".jpg"
filename = f"{uuid.uuid4().hex}{ext}"
file_path = upload_path / filename
content = await file.read()
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
return f"/uploads/{sub_dir}/{filename}"

View File

@@ -0,0 +1,26 @@
import httpx
from app.config import settings
async def code_to_session(code: str) -> dict:
"""用小程序 code 换取 openid 和 session_key"""
url = "https://api.weixin.qq.com/sns/jscode2session"
params = {
"appid": settings.wechat_app_id,
"secret": settings.wechat_app_secret,
"js_code": code,
"grant_type": "authorization_code",
}
async with httpx.AsyncClient() as client:
resp = await client.get(url, params=params)
data = resp.json()
if "errcode" in data and data["errcode"] != 0:
raise Exception(f"微信登录失败: {data.get('errmsg', '未知错误')}")
return {
"openid": data["openid"],
"session_key": data["session_key"],
"unionid": data.get("unionid"),
}

View File

@@ -0,0 +1,617 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>孕育无忧 - 孕期报告智能解读</title>
<style>
:root{--pink:#e91e8c;--pink-light:#fce4f3;--pink-bg:#fff5fb;--green:#27ae60;--orange:#f39c12;--red:#e74c3c;--gray:#999;--border:#eee;--shadow:0 2px 12px rgba(0,0,0,.08);--radius:12px}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB",sans-serif;background:#f5f5f5;color:#333;max-width:480px;margin:0 auto;min-height:100vh;position:relative}
.loading-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(255,255,255,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999}
.spinner{width:40px;height:40px;border:4px solid var(--pink-light);border-top-color:var(--pink);border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.loading-overlay p{margin-top:12px;color:var(--pink);font-size:14px}
/* Header */
.header{background:linear-gradient(135deg,var(--pink),#c2185b);color:#fff;padding:20px;text-align:center;position:sticky;top:0;z-index:100}
.header h1{font-size:20px;font-weight:600}
.header p{font-size:12px;opacity:.85;margin-top:4px}
/* Tab bar */
.tab-bar{display:flex;background:#fff;border-bottom:1px solid var(--border);position:sticky;top:72px;z-index:99}
.tab-bar button{flex:1;padding:12px 0;border:none;background:none;font-size:13px;color:var(--gray);cursor:pointer;border-bottom:2px solid transparent;transition:.2s}
.tab-bar button.active{color:var(--pink);border-bottom-color:var(--pink);font-weight:600}
/* Pages */
.page{display:none;padding:16px}
.page.active{display:block}
/* Cards */
.card{background:#fff;border-radius:var(--radius);padding:16px;margin-bottom:12px;box-shadow:var(--shadow)}
.card h3{font-size:15px;margin-bottom:8px;color:#333}
.card p{font-size:13px;color:#666;line-height:1.6}
/* Upload area */
.upload-area{border:2px dashed #ddd;border-radius:var(--radius);padding:40px 20px;text-align:center;cursor:pointer;transition:.2s;background:#fafafa}
.upload-area:hover{border-color:var(--pink);background:var(--pink-bg)}
.upload-area .icon{font-size:48px;margin-bottom:12px}
.upload-area p{color:var(--gray);font-size:14px}
.upload-area input{display:none}
/* Image preview */
.preview-img{max-width:100%;border-radius:8px;margin:12px 0}
/* Status badges */
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:12px;font-weight:500}
.badge-normal{background:#e8f5e9;color:var(--green)}
.badge-attention{background:#fff3e0;color:var(--orange)}
.badge-warning{background:#ffebee;color:var(--red)}
.badge-high{background:#ffebee;color:var(--red)}
.badge-low{background:#fff3e0;color:var(--orange)}
/* Buttons */
.btn{display:inline-block;padding:10px 24px;border-radius:24px;border:none;font-size:14px;cursor:pointer;transition:.2s;font-weight:500}
.btn-primary{background:var(--pink);color:#fff}
.btn-primary:hover{background:#c2185b}
.btn-primary:disabled{background:#ccc;cursor:not-allowed}
.btn-outline{border:1px solid var(--pink);color:var(--pink);background:none}
.btn-block{display:block;width:100%;text-align:center}
/* Data items table */
.data-table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}
.data-table th{text-align:left;padding:8px 6px;border-bottom:2px solid var(--border);color:var(--gray);font-weight:500}
.data-table td{padding:8px 6px;border-bottom:1px solid var(--border)}
/* Chat */
.chat-container{display:flex;flex-direction:column;height:calc(100vh - 170px)}
.chat-messages{flex:1;overflow-y:auto;padding:12px 0}
.chat-msg{margin-bottom:12px;display:flex}
.chat-msg.user{justify-content:flex-end}
.chat-bubble{max-width:80%;padding:10px 14px;border-radius:16px;font-size:14px;line-height:1.6;white-space:pre-wrap}
.chat-msg.user .chat-bubble{background:var(--pink);color:#fff;border-bottom-right-radius:4px}
.chat-msg.assistant .chat-bubble{background:#fff;color:#333;border-bottom-left-radius:4px;box-shadow:var(--shadow)}
.chat-input-area{display:flex;gap:8px;padding:12px 0}
.chat-input-area input{flex:1;padding:10px 16px;border:1px solid #ddd;border-radius:24px;font-size:14px;outline:none}
.chat-input-area input:focus{border-color:var(--pink)}
.quick-questions{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
.quick-q{padding:6px 12px;background:var(--pink-bg);color:var(--pink);border-radius:16px;font-size:12px;cursor:pointer;border:none;transition:.2s}
.quick-q:hover{background:var(--pink-light)}
/* Report list */
.report-item{display:flex;align-items:center;padding:12px;border-bottom:1px solid var(--border);cursor:pointer;transition:.1s}
.report-item:hover{background:#fafafa}
.report-item .info{flex:1;margin-left:12px}
.report-item .info h4{font-size:14px;margin-bottom:4px}
.report-item .info p{font-size:12px;color:var(--gray)}
.report-icon{width:40px;height:40px;border-radius:10px;background:var(--pink-light);display:flex;align-items:center;justify-content:center;font-size:20px}
/* Analysis result */
.analysis-section{margin-bottom:16px}
.analysis-section h4{font-size:14px;margin-bottom:8px;color:var(--pink)}
.indicator-card{background:#fafafa;border-radius:8px;padding:12px;margin-bottom:8px}
.indicator-card .name{font-weight:600;font-size:14px}
.indicator-card .nickname{color:var(--pink);font-size:12px;margin-left:6px}
.indicator-card .detail{font-size:13px;color:#555;margin-top:6px;line-height:1.6}
/* Action items */
.action-item{display:flex;align-items:flex-start;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)}
.action-dot{width:8px;height:8px;border-radius:50%;margin-top:5px;flex-shrink:0}
.action-dot.high{background:var(--red)}
.action-dot.medium{background:var(--orange)}
.action-dot.low{background:var(--green)}
/* Empty state */
.empty-state{text-align:center;padding:60px 20px;color:var(--gray)}
.empty-state .icon{font-size:64px;margin-bottom:16px}
/* Verify form */
.verify-item{display:flex;gap:8px;margin-bottom:8px;align-items:center;flex-wrap:wrap}
.verify-item input,.verify-item select{padding:6px 10px;border:1px solid #ddd;border-radius:6px;font-size:13px}
.verify-item input{width:80px}
.verify-item .item-name{font-size:13px;font-weight:500;min-width:100px}
textarea{width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:13px;resize:vertical;min-height:60px}
</style>
</head>
<body>
<div class="loading-overlay" id="globalLoading" style="display:none">
<div class="spinner"></div>
<p id="loadingText">加载中...</p>
</div>
<div class="header">
<h1>孕育无忧</h1>
<p>AI 智能解读孕期检查报告</p>
</div>
<div class="tab-bar">
<button class="active" onclick="showPage('home')">首页</button>
<button onclick="showPage('reports')">报告</button>
<button onclick="showPage('chat')">AI问答</button>
<button onclick="showPage('me')">我的</button>
</div>
<!-- ===== 首页 ===== -->
<div class="page active" id="page-home">
<div class="card">
<h3>📸 上传检查报告</h3>
<p style="margin-bottom:12px">拍照或选择孕期检查报告图片,AI 自动识别并解读</p>
<div class="upload-area" id="uploadArea">
<div class="icon">📋</div>
<p>点击上传报告图片</p>
<p style="font-size:12px;margin-top:4px;color:#bbb">支持 JPG、PNG 格式</p>
<input type="file" id="fileInput" accept="image/*">
</div>
</div>
<div id="ocrResult" style="display:none">
<div class="card">
<h3>📝 OCR 识别结果</h3>
<p style="margin-bottom:8px;color:var(--gray);font-size:12px">请核对以下识别数据,如有误请修正后确认</p>
<img id="previewImg" class="preview-img" alt="报告图片">
<div id="ocrDataItems"></div>
<div style="margin-top:12px">
<label style="font-size:13px;font-weight:500">自觉症状(可选)</label>
<textarea id="symptomsInput" placeholder="如:头晕、水肿、腰痛等..." style="margin-top:6px"></textarea>
</div>
<div style="margin-top:12px;text-align:center">
<button class="btn btn-primary btn-block" onclick="verifyReport()">确认数据,生成 AI 解读</button>
</div>
</div>
</div>
<div id="analysisResult" style="display:none"></div>
</div>
<!-- ===== 报告列表 ===== -->
<div class="page" id="page-reports">
<div class="card">
<h3>📋 我的报告</h3>
<div id="reportList">
<div class="empty-state">
<div class="icon">📭</div>
<p>暂无报告,去首页上传吧</p>
</div>
</div>
</div>
</div>
<!-- ===== AI 对话 ===== -->
<div class="page" id="page-chat">
<div class="chat-container">
<div id="chatReportSelect" class="card" style="padding:10px 14px">
<label style="font-size:13px;color:var(--gray)">选择报告进行追问:</label>
<select id="chatReportId" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;margin-top:6px;font-size:13px">
<option value="">请先上传报告</option>
</select>
</div>
<div id="quickQuestions" class="quick-questions"></div>
<div class="chat-messages" id="chatMessages">
<div class="empty-state">
<div class="icon">💬</div>
<p>选择报告后可以向 AI 追问</p>
</div>
</div>
<div class="chat-input-area">
<input type="text" id="chatInput" placeholder="输入你的问题..." onkeydown="if(event.key==='Enter')sendChat()">
<button class="btn btn-primary" onclick="sendChat()">发送</button>
</div>
</div>
</div>
<!-- ===== 我的 ===== -->
<div class="page" id="page-me">
<div class="card" style="text-align:center;padding:30px">
<div style="width:64px;height:64px;border-radius:50%;background:var(--pink-light);margin:0 auto 12px;display:flex;align-items:center;justify-content:center;font-size:28px">👩</div>
<h3 id="userName">体验用户</h3>
<p style="color:var(--gray);margin-top:4px" id="userInfo">加载中...</p>
</div>
<div class="card">
<h3>🍎 营养建议</h3>
<p style="margin-bottom:12px;font-size:13px;color:var(--gray)">根据报告选择对应的营养建议</p>
<select id="nutritionReportId" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px">
<option value="">选择报告</option>
</select>
<button class="btn btn-outline btn-block" style="margin-top:8px" onclick="getNutrition()">获取营养建议</button>
<div id="nutritionResult" style="margin-top:12px"></div>
</div>
<div class="card">
<h3>🏃‍♀️ 运动建议</h3>
<button class="btn btn-outline btn-block" onclick="getExercise()">获取运动建议</button>
<div id="exerciseResult" style="margin-top:12px"></div>
</div>
</div>
<script>
const API = '';
let TOKEN = '';
let currentReportId = '';
let ocrDataItems = [];
// ---- Helpers ----
function showLoading(text='加载中...'){
document.getElementById('loadingText').textContent=text;
document.getElementById('globalLoading').style.display='flex';
}
function hideLoading(){document.getElementById('globalLoading').style.display='none'}
async function api(path,opts={}){
const headers = {'Authorization':'Bearer '+TOKEN, ...(opts.headers||{})};
if(opts.body && !(opts.body instanceof FormData)){
headers['Content-Type']='application/json';
opts.body=JSON.stringify(opts.body);
}
const res = await fetch(API+path, {...opts, headers});
if(!res.ok){
const err = await res.json().catch(()=>({detail:'请求失败'}));
throw new Error(err.detail||'请求失败');
}
return res.json();
}
function showPage(name){
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
document.getElementById('page-'+name).classList.add('active');
document.querySelectorAll('.tab-bar button').forEach((b,i)=>{
b.classList.toggle('active', ['home','reports','chat','me'][i]===name);
});
if(name==='reports') loadReportList();
if(name==='chat') loadChatReportSelect();
if(name==='me') loadProfile();
}
function badgeHtml(status){
const map={normal:'正常',attention:'注意',warning:'警告',high:'偏高',low:'偏低'};
return `<span class="badge badge-${status}">${map[status]||status}</span>`;
}
// ---- Auth ----
async function demoLogin(){
showLoading('正在登录...');
try{
const data = await fetch(API+'/api/auth/demo-login',{method:'POST'}).then(r=>r.json());
TOKEN = data.access_token;
hideLoading();
}catch(e){
hideLoading();
alert('登录失败:'+e.message);
}
}
// ---- Upload ----
document.getElementById('uploadArea').onclick=()=>document.getElementById('fileInput').click();
document.getElementById('fileInput').onchange=async function(){
const file = this.files[0];
if(!file) return;
showLoading('AI 正在识别报告...');
const fd = new FormData();
fd.append('file', file);
try{
const data = await api('/api/report/upload',{method:'POST',body:fd});
currentReportId = data.report_id;
ocrDataItems = data.ocr_result.data_items || [];
// Show preview
document.getElementById('previewImg').src = API + '/' + data.image_url;
renderOcrItems(ocrDataItems, data.ocr_result.report_type);
document.getElementById('ocrResult').style.display='block';
document.getElementById('analysisResult').style.display='none';
hideLoading();
}catch(e){
hideLoading();
alert('上传失败:'+e.message);
}
};
function renderOcrItems(items, reportType){
let html = `<div class="verify-item" style="margin-bottom:12px">
<span class="item-name">报告类型</span>
<input id="reportTypeInput" value="${reportType||''}" style="flex:1" placeholder="如:血常规">
</div>`;
html += '<table class="data-table"><thead><tr><th>指标</th><th>数值</th><th>单位</th><th>参考范围</th><th>状态</th></tr></thead><tbody>';
items.forEach((item,i)=>{
html += `<tr>
<td><input data-idx="${i}" data-field="name" value="${item.name||''}" style="width:80px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
<td><input data-idx="${i}" data-field="value" value="${item.value||''}" style="width:60px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
<td><input data-idx="${i}" data-field="unit" value="${item.unit||''}" style="width:50px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
<td><input data-idx="${i}" data-field="reference_range" value="${item.reference_range||''}" style="width:80px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
<td><select data-idx="${i}" data-field="status" style="border:1px solid #eee;padding:4px;border-radius:4px">
<option value="normal" ${item.status==='normal'?'selected':''}>正常</option>
<option value="high" ${item.status==='high'?'selected':''}>偏高</option>
<option value="low" ${item.status==='low'?'selected':''}>偏低</option>
</select></td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('ocrDataItems').innerHTML = html;
}
async function verifyReport(){
// Collect edited data
const items = ocrDataItems.map((_,i)=>{
const get = f => document.querySelector(`[data-idx="${i}"][data-field="${f}"]`).value;
return {name:get('name'),value:get('value'),unit:get('unit'),reference_range:get('reference_range'),status:get('status'),category:''};
});
const reportType = document.getElementById('reportTypeInput').value;
const symptoms = document.getElementById('symptomsInput').value;
showLoading('正在确认数据...');
try{
await api(`/api/report/${currentReportId}/verify`,{
method:'PUT',
body:{data_items:items,symptoms:symptoms||null,notes:null,report_type:reportType||null}
});
hideLoading();
generateAnalysis();
}catch(e){
hideLoading();
alert('确认失败:'+e.message);
}
}
async function generateAnalysis(){
showLoading('AI 正在解读报告,请稍候...');
try{
const data = await api(`/api/analysis/${currentReportId}/generate`,{method:'POST'});
renderAnalysis(data);
hideLoading();
}catch(e){
hideLoading();
alert('解读失败:'+e.message);
}
}
function renderAnalysis(data){
let html = '<div class="card"><h3>🤖 AI 解读报告</h3>';
// Overall
html += `<div class="analysis-section">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
${badgeHtml(data.overall_status)}
<span style="font-size:13px;color:var(--gray)">风险等级:${({low:'低',medium:'中',high:'高'})[data.risk_level]||data.risk_level}</span>
</div>
<p style="font-size:14px;line-height:1.6">${data.overall_summary}</p>
<p style="font-size:14px;color:var(--pink);margin-top:6px">${data.emotional_summary}</p>
</div>`;
// Categories
if(data.categories && data.categories.length){
data.categories.forEach(cat=>{
html += `<div class="analysis-section">
<h4>${cat.category} ${badgeHtml(cat.status)}</h4>`;
(cat.indicators||[]).forEach(ind=>{
html += `<div class="indicator-card">
<div><span class="name">${ind.name}</span><span class="nickname">${ind.nickname||''}</span> ${badgeHtml(ind.status)} <span style="font-size:12px;color:var(--gray);margin-left:6px">${ind.value||''}</span></div>
<div class="detail">
${ind.what_is_it?'<div>📖 '+ind.what_is_it+'</div>':''}
${ind.impact?'<div>👶 '+ind.impact+'</div>':''}
${ind.suggestion?'<div>💡 '+ind.suggestion+'</div>':''}
${ind.recheck_reminder?'<div>🔔 '+ind.recheck_reminder+'</div>':''}
</div>
</div>`;
});
html += '</div>';
});
}
// Action items
if(data.action_items && data.action_items.length){
html += '<div class="analysis-section"><h4>📋 行动建议</h4>';
data.action_items.forEach(a=>{
html += `<div class="action-item"><div class="action-dot ${a.priority}"></div><div>
<div style="font-size:13px">${a.content}</div>
<div style="font-size:11px;color:var(--gray)">${a.category||''}</div>
</div></div>`;
});
html += '</div>';
}
// Tips
if(data.tips && data.tips.length){
html += '<div class="analysis-section"><h4>💝 温馨提示</h4>';
data.tips.forEach(t=>{html += `<p style="font-size:13px;padding:4px 0;color:#555">• ${t}</p>`});
html += '</div>';
}
html += `<div style="margin-top:12px;display:flex;gap:8px">
<button class="btn btn-outline" style="flex:1" onclick="showPage('chat')">💬 追问 AI</button>
<button class="btn btn-outline" style="flex:1" onclick="getDoctorQuestions()">🩺 问诊清单</button>
</div>`;
html += '</div>';
document.getElementById('analysisResult').innerHTML = html;
document.getElementById('analysisResult').style.display = 'block';
}
// ---- Report List ----
async function loadReportList(){
try{
const data = await api('/api/report/list');
if(!data.length){
document.getElementById('reportList').innerHTML='<div class="empty-state"><div class="icon">📭</div><p>暂无报告,去首页上传吧</p></div>';
return;
}
let html='';
data.forEach(r=>{
const date = new Date(r.created_at).toLocaleDateString('zh-CN');
html += `<div class="report-item" onclick="viewReport('${r.id}')">
<div class="report-icon">📋</div>
<div class="info"><h4>${r.report_type||'检查报告'}</h4><p>${date} · 孕${r.pregnancy_week||'?'}周 · ${r.item_count}项指标</p></div>
${badgeHtml(r.overall_status)}
</div>`;
});
document.getElementById('reportList').innerHTML=html;
}catch(e){}
}
async function viewReport(id){
currentReportId = id;
showLoading('加载报告...');
try{
const data = await api(`/api/analysis/${id}`);
renderAnalysis(data);
showPage('home');
document.getElementById('ocrResult').style.display='none';
document.getElementById('analysisResult').style.display='block';
hideLoading();
}catch(e){
hideLoading();
// No analysis yet, show report detail
try{
const report = await api(`/api/report/${id}`);
currentReportId = id;
ocrDataItems = report.data_items||[];
renderOcrItems(ocrDataItems, report.report_type);
showPage('home');
document.getElementById('ocrResult').style.display='block';
document.getElementById('analysisResult').style.display='none';
}catch(e2){alert(e2.message)}
}
}
// ---- Chat ----
async function loadChatReportSelect(){
try{
const data = await api('/api/report/list');
const sel = document.getElementById('chatReportId');
sel.innerHTML = data.length ? data.map(r=>`<option value="${r.id}">${r.report_type||'报告'} - ${new Date(r.created_at).toLocaleDateString('zh-CN')}</option>`).join('') : '<option value="">请先上传报告</option>';
if(currentReportId) sel.value=currentReportId;
loadChatHistory();
}catch(e){}
}
async function loadChatHistory(){
const rid = document.getElementById('chatReportId').value;
if(!rid) return;
try{
const data = await api(`/api/chat/${rid}/history`);
const container = document.getElementById('chatMessages');
if(!data.messages.length){
container.innerHTML='<div class="empty-state" style="padding:30px"><div class="icon">💬</div><p>有任何疑问都可以问我哦~</p></div>';
}else{
container.innerHTML = data.messages.map(m=>`<div class="chat-msg ${m.role}"><div class="chat-bubble">${m.content}</div></div>`).join('');
container.scrollTop = container.scrollHeight;
}
// Quick questions
const qqs = data.quick_questions||["这个指标偏高会影响宝宝吗?","我需要注意什么饮食?","什么时候需要复查?"];
document.getElementById('quickQuestions').innerHTML = qqs.map(q=>`<button class="quick-q" onclick="askQuick('${q}')">${q}</button>`).join('');
}catch(e){}
}
document.getElementById('chatReportId').onchange=loadChatHistory;
function askQuick(q){document.getElementById('chatInput').value=q;sendChat()}
async function sendChat(){
const rid = document.getElementById('chatReportId').value;
const input = document.getElementById('chatInput');
const msg = input.value.trim();
if(!rid||!msg) return;
input.value='';
const container = document.getElementById('chatMessages');
// Clear empty state
if(container.querySelector('.empty-state')) container.innerHTML='';
container.innerHTML+=`<div class="chat-msg user"><div class="chat-bubble">${msg}</div></div>`;
container.innerHTML+=`<div class="chat-msg assistant" id="typingIndicator"><div class="chat-bubble">思考中...</div></div>`;
container.scrollTop = container.scrollHeight;
try{
const data = await api(`/api/chat/${rid}/message`,{method:'POST',body:{content:msg}});
document.getElementById('typingIndicator').remove();
container.innerHTML+=`<div class="chat-msg assistant"><div class="chat-bubble">${data.ai_message.content}</div></div>`;
container.scrollTop = container.scrollHeight;
}catch(e){
document.getElementById('typingIndicator').remove();
container.innerHTML+=`<div class="chat-msg assistant"><div class="chat-bubble" style="color:var(--red)">回复失败:${e.message}</div></div>`;
}
}
// ---- Profile ----
async function loadProfile(){
try{
const data = await api('/api/user/profile');
document.getElementById('userName').textContent = data.name||'体验用户';
document.getElementById('userInfo').textContent = `${data.age||'?'}岁 · 孕${data.pregnancy_week||'?'}周${data.pregnancy_day||0}天 · ${data.blood_type||'?'}型血`;
// Also update nutrition select
const reports = await api('/api/report/list');
document.getElementById('nutritionReportId').innerHTML = reports.length ? reports.map(r=>`<option value="${r.id}">${r.report_type||'报告'} - ${new Date(r.created_at).toLocaleDateString('zh-CN')}</option>`).join('') : '<option value="">暂无报告</option>';
}catch(e){}
}
// ---- Nutrition ----
async function getNutrition(){
const rid = document.getElementById('nutritionReportId').value;
if(!rid){alert('请先选择报告');return}
showLoading('AI 正在生成营养建议...');
try{
const data = await api(`/api/nutrition/${rid}`);
let html='';
(data.recipes||[]).forEach(r=>{
html+=`<div style="background:#fafafa;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:600;font-size:14px">🍳 ${r.name}</div>
<p style="font-size:12px;color:var(--gray);margin-top:4px">${r.description||''}</p>
<p style="font-size:12px;margin-top:4px">⏱ ${r.time||''} · ${r.difficulty||''}</p>
<p style="font-size:12px;color:var(--pink);margin-top:4px">📌 ${r.reason||''}</p>
${r.ingredients?'<p style="font-size:12px;margin-top:4px">食材:'+r.ingredients.join('、')+'</p>':''}
</div>`;
});
if(data.dietary_tips&&data.dietary_tips.length){
html+='<div style="margin-top:8px"><h4 style="font-size:13px;color:var(--orange)">⚠️ 饮食提醒</h4>';
data.dietary_tips.forEach(t=>{html+=`<p style="font-size:13px;padding:2px 0">• ${t}</p>`});
html+='</div>';
}
document.getElementById('nutritionResult').innerHTML=html;
hideLoading();
}catch(e){hideLoading();alert('获取失败:'+e.message)}
}
// ---- Exercise ----
async function getExercise(){
showLoading('AI 正在生成运动建议...');
try{
const data = await api('/api/exercise/recommendations');
let html='';
(data.exercises||[]).forEach(ex=>{
html+=`<div style="background:#fafafa;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:600;font-size:14px">🏃 ${ex.name}</div>
<p style="font-size:12px;color:var(--gray);margin-top:4px">${ex.description||''}</p>
<p style="font-size:12px;margin-top:4px">⏱ ${ex.duration||''} · 🔥 ${ex.calories||''} · ${ex.difficulty||''}</p>
${ex.benefits?'<p style="font-size:12px;color:var(--green);margin-top:4px">✅ '+ex.benefits.join(' · ')+'</p>':''}
${ex.precautions?'<p style="font-size:12px;color:var(--orange);margin-top:4px">⚠️ '+ex.precautions.join(' · ')+'</p>':''}
</div>`;
});
if(data.daily_schedule&&data.daily_schedule.length){
html+='<div style="margin-top:8px"><h4 style="font-size:13px;color:var(--pink)">📅 每日运动安排</h4>';
data.daily_schedule.forEach(s=>{html+=`<p style="font-size:13px;padding:2px 0">${s.time} ${s.activity} (${s.duration})</p>`});
html+='</div>';
}
document.getElementById('exerciseResult').innerHTML=html;
hideLoading();
}catch(e){hideLoading();alert('获取失败:'+e.message)}
}
// ---- Doctor Questions ----
async function getDoctorQuestions(){
if(!currentReportId){alert('请先选择报告');return}
showLoading('AI 正在生成问诊清单...');
try{
const data = await api(`/api/doctor/questions/${currentReportId}`);
let html='<div class="card"><h3>🩺 问诊清单</h3><p style="font-size:12px;color:var(--gray);margin-bottom:12px">带着这些问题去看医生,沟通更高效</p>';
(data.questions||[]).forEach(q=>{
html+=`<div class="action-item"><div class="action-dot ${q.priority}"></div><div>
<div style="font-size:14px;font-weight:500">"${q.question}"</div>
<div style="font-size:12px;color:var(--gray);margin-top:2px">💡 ${q.reason}</div>
</div></div>`;
});
html+='</div>';
document.getElementById('analysisResult').innerHTML+=html;
hideLoading();
}catch(e){hideLoading();alert('获取失败:'+e.message)}
}
// ---- Init ----
demoLogin();
</script>
</body>
</html>

View File

View File

@@ -0,0 +1,19 @@
from typing import Optional
from beanie import PydanticObjectId
from fastapi import HTTPException
def parse_object_id(value: str) -> PydanticObjectId:
"""Parse a string to PydanticObjectId, raising 400 on invalid format."""
try:
return PydanticObjectId(value)
except Exception:
raise HTTPException(status_code=400, detail="无效的ID格式")
def mask_name(name: Optional[str]) -> str:
"""隐私脱敏:姓名"""
if not name or len(name) < 2:
return name or ""
return name[0] + "*" * (len(name) - 1)