chore: 初始化项目结构并添加基础配置文件

添加后端API和前端项目的初始目录结构,包括应用层模块、工具模块和模型定义
配置前后端的.dockerignore文件以排除开发环境文件
添加前端样式文件,包括字体、Tailwind CSS和主题配置
初始化Python虚拟环境中的类型标记文件
This commit is contained in:
2026-04-01 16:55:24 +08:00
commit cfdeeb5e41
4149 changed files with 837587 additions and 0 deletions

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": []}

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"),
}