chore: 初始化项目结构并添加基础配置文件
添加后端API和前端项目的初始目录结构,包括应用层模块、工具模块和模型定义 配置前后端的.dockerignore文件以排除开发环境文件 添加前端样式文件,包括字体、Tailwind CSS和主题配置 初始化Python虚拟环境中的类型标记文件
This commit is contained in:
0
后端-孕期报告解读-api/app/services/__init__.py
Normal file
0
后端-孕期报告解读-api/app/services/__init__.py
Normal file
239
后端-孕期报告解读-api/app/services/ai_service.py
Normal file
239
后端-孕期报告解读-api/app/services/ai_service.py
Normal 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
后端-孕期报告解读-api/app/services/ocr.py
Normal file
142
后端-孕期报告解读-api/app/services/ocr.py
Normal 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": []}
|
||||
24
后端-孕期报告解读-api/app/services/storage.py
Normal file
24
后端-孕期报告解读-api/app/services/storage.py
Normal 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}"
|
||||
26
后端-孕期报告解读-api/app/services/wechat.py
Normal file
26
后端-孕期报告解读-api/app/services/wechat.py
Normal 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"),
|
||||
}
|
||||
Reference in New Issue
Block a user