完善训练链路接口与PDF下载

This commit is contained in:
刘金宝
2026-06-08 15:16:07 +08:00
parent 41a2851120
commit 11b1712b01
12 changed files with 550 additions and 164 deletions
+36 -4
View File
@@ -73,8 +73,8 @@ class PatientAgent:
1. 不主动透露未被问到的隐藏信息。
2. 不替医生做诊断,不提供治疗方案。
3. 不编造病例外检查检验结果。
4. 每次回答控制在1到3句话,使用患家属口吻,不输出分析过程。
5. 只输出给医生看的家属回纯文本,不输出 JSON、Markdown、标题、解释或思考过程。
4. 每次回答控制在1到3句话,使用患者或家属口吻,不输出分析过程。
5. 只输出给医生看的患者或家属回纯文本,不输出 JSON、Markdown、标题、解释或思考过程。
6. 如果医生一次问多个问题,按问题顺序简短回答,不扩展病例外信息。
7. {mode_rule}
"""
@@ -86,16 +86,48 @@ class PatientAgent:
def _build_patient_config_rule(self, patient_config: dict | None) -> str:
"""配置提示:把训练页初始化配置转成 AI 病人表达约束。"""
if not patient_config:
return "使用默认门诊、青年、高等教育、平和性格的表达方式。"
return (
"使用默认门诊、青年、高等教育、平和性格的表达方式。"
"配置只影响表达风格,不能改变病例事实、不能泄露隐藏信息、不能编造检查检验结果。"
)
labels = patient_config.get("labels") if isinstance(patient_config, dict) else None
values = labels or (patient_config.get("values") if isinstance(patient_config, dict) else {}) or {}
visit_environment = values.get("visit_environment", "门诊")
age_group = values.get("age_group", "青年")
education_level = values.get("education_level", "高等教育")
personality = values.get("personality", "平和")
visit_rules = {
"门诊": "按门诊沟通节奏回答,病情描述相对稳定。",
"急诊": "语气更急迫,优先表达担忧和症状变化,但每次仍控制在1到3句话。",
"病房": "体现住院或病房随访语境,回答更偏病程观察和治疗反应。",
}
age_rules = {
"儿童": "以家属代述为主,避免让患儿直接使用成人化表达。",
"青年": "表达相对清楚,能主动配合基础问诊。",
"中年": "可体现工作、家庭负担和慢病背景对就诊的影响。",
"老年": "表达稍慢,关注基础病、用药史和照护者补充信息。",
}
education_rules = {
"小学及以下": "少用医学术语,症状描述更口语化,需要医生解释才理解专业概念。",
"中等教育": "能理解常见健康解释,但不主动使用专业诊断结论。",
"高等教育": "能清楚描述症状细节,但仍不能替医生做诊断。",
}
personality_rules = {
"平和": "情绪稳定,按问题回答。",
"焦虑": "回答中带担忧,可能追问孩子是否严重、是否需要住院。",
"急躁": "回答更短,更希望尽快得到处理结果。",
"配合": "愿意补充相关细节,但不主动泄露未被问到的隐藏信息。",
"多疑": "会追问检查、用药和治疗依据。",
}
return (
f"就诊环境={visit_environment};年龄段={age_group};文化程度={education_level};性格={personality}"
"回答时根据性格调整情绪和配合度,根据文化程度调整表达清晰度,但不得改变病例事实。"
f"就诊环境规则:{visit_rules.get(visit_environment, '按常规问诊节奏回答。')}"
f"年龄段规则:{age_rules.get(age_group, '按普通成年患者表达。')}"
f"文化程度规则:{education_rules.get(education_level, '按普通健康素养表达。')}"
f"性格规则:{personality_rules.get(personality, '按问题简短回答。')}"
"上述配置只影响语气、配合度、理解能力和表达细节;不能改变病例事实,不能主动泄露隐藏信息,不能编造检查检验结果。"
)
def _to_llm_history(self, memory_messages: list[dict]) -> list[dict]:
+20
View File
@@ -1,4 +1,7 @@
from pathlib import Path
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.core.response import ApiResponse, ok
@@ -37,3 +40,20 @@ def export_pdf(
export = PdfExportService(db).export(evaluation_id, ctx.user_id)
db.commit()
return ok(ExportPdfResponse(export_id=export.id, file_path=export.file_path))
@router.get("/{evaluation_id}/download-pdf", response_class=FileResponse)
def download_pdf(
evaluation_id: int,
ctx: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""PDF 下载:校验评价归属后生成报告,并以文件流方式触发浏览器下载。"""
export = PdfExportService(db).export(evaluation_id, ctx.user_id)
db.commit()
file_path = Path(export.file_path)
return FileResponse(
path=file_path,
media_type="application/pdf",
filename=file_path.name,
)
+12 -5
View File
@@ -1,5 +1,12 @@
from typing import Literal
from pydantic import BaseModel
VisitEnvironment = Literal["outpatient", "emergency", "ward"]
AgeGroup = Literal["child", "youth", "middle_aged", "elderly"]
EducationLevel = Literal["primary_or_below", "secondary", "higher"]
Personality = Literal["calm", "anxious", "impatient", "cooperative", "suspicious"]
class ConfigOption(BaseModel):
"""训练配置选项:用于前端渲染单个可选项。"""
@@ -12,14 +19,14 @@ class ConfigOption(BaseModel):
class PatientConfig(BaseModel):
"""病人初始化配置:控制 AI 病人的就诊场景、年龄段、文化程度和性格。"""
visit_environment: str = "outpatient"
age_group: str = "youth"
education_level: str = "higher"
personality: str = "calm"
visit_environment: VisitEnvironment = "outpatient"
age_group: AgeGroup = "youth"
education_level: EducationLevel = "higher"
personality: Personality = "calm"
class TrainingConfigOptionsResponse(BaseModel):
"""训练配置响应:返回默认配置和全部可选项。"""
"""训练配置响应:返回推荐配置和全部可选项。"""
case_id: int
recommended: PatientConfig
+8 -2
View File
@@ -39,11 +39,11 @@ class OrderService:
def create_physical_exam_order(self, session_id: int, user_id: str, item_code: str) -> CreateOrderResponse:
"""体格检查结果:复用检查申请逻辑,结果仍只来自数据库。"""
return self.create_order(session_id, user_id, item_code)
return self._create_order(session_id, user_id, item_code, require_physical=True)
def create_auxiliary_exam_order(self, session_id: int, user_id: str, item_code: str) -> CreateOrderResponse:
"""辅助检查结果:复用检查申请逻辑,结果仍只来自数据库。"""
return self.create_order(session_id, user_id, item_code)
return self._create_order(session_id, user_id, item_code, require_physical=False)
def _items_response(self, items) -> OrderItemsResponse:
"""检查列表响应:把 ORM 检查项转换成前端列表结构。"""
@@ -64,6 +64,10 @@ class OrderService:
def create_order(self, session_id: int, user_id: str, item_code: str) -> CreateOrderResponse:
"""检查申请:从数据库读取检查结果并写入当前会话记录。"""
return self._create_order(session_id, user_id, item_code)
def _create_order(self, session_id: int, user_id: str, item_code: str, require_physical: bool | None = None) -> CreateOrderResponse:
"""检查申请:统一校验会话、项目类型和幂等逻辑,检查结果只来自数据库。"""
session = self._get_session(session_id, user_id)
if session.status not in {"inquiry", "diagnosis", "treatment"}:
raise AppError("SESSION_STATUS_INVALID", "current session does not allow ordering", 400)
@@ -71,6 +75,8 @@ class OrderService:
item = self.case_repo.get_exam_item(session.case_id, item_code)
if not item:
raise AppError("ORDER_ITEM_NOT_FOUND", "order item not found for current case", 404)
if require_physical is not None and self._is_physical_item(item) != require_physical:
raise AppError("ORDER_ITEM_TYPE_MISMATCH", "order item type does not match current exam endpoint", 400)
existing_order = self.session_repo.get_order_by_item(session.id, item.item_code)
if existing_order:
+1 -1
View File
@@ -49,7 +49,7 @@ class SessionService:
if not case:
raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404)
patient_config = TrainingConfigService(self.db).normalize_patient_config(payload.patient_config)
patient_config = TrainingConfigService(self.db).normalize_patient_config(payload.patient_config, case)
session_code = f"sess_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}"
memory_key = f"mem:{session_code}"
session = self.session_repo.create_session(
+95 -17
View File
@@ -1,6 +1,9 @@
from typing import Any
from sqlalchemy.orm import Session
from app.core.exceptions import AppError
from app.models.source_case import CaseBase
from app.repositories.case_repository import CaseRepository
from app.schemas.training_config import (
ConfigOption,
@@ -18,9 +21,9 @@ class TrainingConfigService:
self.case_repo = CaseRepository(db)
def get_recommended(self, case_id: int) -> TrainingConfigRecommendedResponse:
"""推荐配置:根据病例返回训练页默认病人初始化配置。"""
self._ensure_case(case_id)
recommended = self.default_patient_config()
"""推荐配置:根据病例内容返回训练页默认病人初始化配置。"""
case = self._get_case(case_id)
recommended = self.default_patient_config(case)
return TrainingConfigRecommendedResponse(
case_id=case_id,
recommended=recommended,
@@ -29,9 +32,9 @@ class TrainingConfigService:
)
def get_options(self, case_id: int) -> TrainingConfigOptionsResponse:
"""配置选项:返回训练页自定义配置的全部可选项。"""
self._ensure_case(case_id)
recommended = self.default_patient_config()
"""配置选项:返回训练页自定义病人初始化配置的全部可选项和病例推荐值"""
case = self._get_case(case_id)
recommended = self.default_patient_config(case)
return TrainingConfigOptionsResponse(
case_id=case_id,
recommended=recommended,
@@ -39,18 +42,51 @@ class TrainingConfigService:
options=self.config_options(),
)
def default_patient_config(self) -> PatientConfig:
"""默认配置:按当前产品原型初始化病人信息"""
def default_patient_config(self, case: CaseBase | None = None) -> PatientConfig:
"""默认配置:按病例年龄、科室语义和病情关键词初始化 AI 病人沟通风格"""
if case is None:
return PatientConfig(
visit_environment="outpatient",
age_group="youth",
education_level="higher",
personality="calm",
)
text = self._case_text(case)
patient_age = case.patient_age
visit_environment = "outpatient"
personality = "calm"
if self._contains_any(
text,
["急诊", "危重", "休克", "昏迷", "意识障碍", "呼吸困难", "低氧", "抢救", "SpO2 90", "血氧明显下降"],
):
visit_environment = "emergency"
personality = "anxious"
elif self._contains_any(text, ["住院", "病房", "入院", "住院治疗"]):
visit_environment = "ward"
age_group = "youth"
if (patient_age is not None and patient_age < 14) or self._contains_any(
text,
["儿童", "患儿", "儿科", "小儿", "幼儿", "婴儿"],
):
age_group = "child"
elif patient_age is not None and patient_age >= 65:
age_group = "elderly"
elif patient_age is not None and patient_age >= 45:
age_group = "middle_aged"
return PatientConfig(
visit_environment="outpatient",
age_group="youth",
visit_environment=visit_environment,
age_group=age_group,
education_level="higher",
personality="calm",
personality=personality,
)
def normalize_patient_config(self, config: PatientConfig | None) -> dict:
def normalize_patient_config(self, config: PatientConfig | None, case: CaseBase | None = None) -> dict:
"""配置归一:校验并补齐前端传入的病人初始化配置。"""
selected = config or self.default_patient_config()
selected = config or self.default_patient_config(case)
values = selected.model_dump()
allowed = {key: {item.value for item in items} for key, items in self.config_options().items()}
for key, value in values.items():
@@ -75,7 +111,7 @@ class TrainingConfigService:
return {
"visit_environment": [
ConfigOption(value="outpatient", label="门诊", description="适合常规问诊训练"),
ConfigOption(value="emergency", label="急诊", description="病情紧急沟通节奏更快"),
ConfigOption(value="emergency", label="急诊", description="病情紧急沟通节奏更快"),
ConfigOption(value="ward", label="病房", description="适合住院病情追踪和处置沟通"),
],
"age_group": [
@@ -98,7 +134,49 @@ class TrainingConfigService:
],
}
def _ensure_case(self, case_id: int) -> None:
"""病例校验:确认配置请求对应已发布病例。"""
if not self.case_repo.get_active_case(case_id):
def _get_case(self, case_id: int) -> CaseBase:
"""病例校验:读取配置请求对应已发布病例。"""
case = self.case_repo.get_active_case(case_id)
if not case:
raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404)
return case
def _case_text(self, case: CaseBase) -> str:
"""病例文本:拼接用于推荐配置推断的病例字段。"""
parts: list[str] = [
case.title or "",
case.chief_complaint or "",
case.description or "",
case.tags or "",
case.patient_gender or "",
]
for field_name in (
"symptom_tags",
"disease_tags",
"competency_tags",
"guideline_tags",
"knowledge_points",
"icd_codes",
):
parts.extend(self._stringify(getattr(case, field_name, None)))
if case.traditional_case:
parts.extend(self._stringify(case.traditional_case.guideline_reference))
if case.teaching_case:
parts.extend(self._stringify(case.teaching_case.teaching_goal))
return " ".join(item for item in parts if item)
def _stringify(self, value: Any) -> list[str]:
"""字段展开:把 JSON、列表和普通文本统一转换为可检索字符串。"""
if value is None:
return []
if isinstance(value, str):
return [value]
if isinstance(value, dict):
return [str(item) for pair in value.items() for item in pair if item is not None]
if isinstance(value, list | tuple | set):
return [str(item) for item in value if item is not None]
return [str(value)]
def _contains_any(self, text: str, keywords: list[str]) -> bool:
"""关键词判断:判断病例文本是否命中任一推荐配置关键词。"""
return any(keyword in text for keyword in keywords)