2026-06-01 09:25:26 +08:00
|
|
|
from collections.abc import AsyncIterator
|
|
|
|
|
|
|
|
|
|
from app.agents.llm_adapter import DeepSeekClient, LLMResponse, LLMStreamChunk
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
from app.models.source_case import CaseBase
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PatientAgent:
|
|
|
|
|
"""AI 病人:根据病例资料、隐藏信息和短期 memory 回复医生问诊。"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, llm: DeepSeekClient | None = None) -> None:
|
|
|
|
|
self.llm = llm or DeepSeekClient()
|
|
|
|
|
|
2026-06-05 12:57:02 +08:00
|
|
|
async def reply(
|
|
|
|
|
self,
|
|
|
|
|
case: CaseBase,
|
|
|
|
|
memory_messages: list[dict],
|
|
|
|
|
user_message: str,
|
|
|
|
|
mode: str,
|
|
|
|
|
patient_config: dict | None = None,
|
|
|
|
|
) -> LLMResponse:
|
2026-06-01 09:25:26 +08:00
|
|
|
"""问诊回复:拼接病例上下文、短期记忆和用户输入后调用 Patient Agent。"""
|
2026-06-05 12:57:02 +08:00
|
|
|
messages = self._build_messages(case, memory_messages, user_message, mode, patient_config)
|
2026-06-01 09:25:26 +08:00
|
|
|
return await self.llm.chat(
|
|
|
|
|
messages,
|
|
|
|
|
settings.llm_fast_model,
|
|
|
|
|
thinking_enabled=settings.llm_fast_thinking_enabled,
|
|
|
|
|
max_tokens=settings.llm_fast_max_tokens,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def stream_reply(
|
|
|
|
|
self,
|
|
|
|
|
case: CaseBase,
|
|
|
|
|
memory_messages: list[dict],
|
|
|
|
|
user_message: str,
|
|
|
|
|
mode: str,
|
2026-06-05 12:57:02 +08:00
|
|
|
patient_config: dict | None = None,
|
2026-06-01 09:25:26 +08:00
|
|
|
) -> AsyncIterator[LLMStreamChunk]:
|
|
|
|
|
"""流式问诊:以 SSE 方式返回 AI 病人增量回复。"""
|
2026-06-05 12:57:02 +08:00
|
|
|
messages = self._build_messages(case, memory_messages, user_message, mode, patient_config)
|
2026-06-01 09:25:26 +08:00
|
|
|
async for chunk in self.llm.stream_chat(
|
|
|
|
|
messages,
|
|
|
|
|
settings.llm_fast_model,
|
|
|
|
|
thinking_enabled=settings.llm_fast_thinking_enabled,
|
|
|
|
|
max_tokens=settings.llm_fast_max_tokens,
|
|
|
|
|
):
|
|
|
|
|
yield chunk
|
|
|
|
|
|
2026-06-05 12:57:02 +08:00
|
|
|
def _build_messages(
|
|
|
|
|
self,
|
|
|
|
|
case: CaseBase,
|
|
|
|
|
memory_messages: list[dict],
|
|
|
|
|
user_message: str,
|
|
|
|
|
mode: str,
|
|
|
|
|
patient_config: dict | None = None,
|
|
|
|
|
) -> list[dict]:
|
2026-06-01 09:25:26 +08:00
|
|
|
"""提示词拼接:构造 AI 病人的系统提示词和对话历史。"""
|
|
|
|
|
profile = case.ai_patient_profile or {}
|
|
|
|
|
hidden_info = case.hidden_patient_info or {}
|
2026-06-05 12:57:02 +08:00
|
|
|
config_rule = self._build_patient_config_rule(patient_config)
|
2026-06-01 09:25:26 +08:00
|
|
|
mode_rule = {
|
|
|
|
|
"novice": "新手模式:回答清楚,必要时可提示医生继续追问症状、既往史或检查。",
|
|
|
|
|
"practice": "练习模式:只回答被问到的信息,不主动给诊断建议。",
|
|
|
|
|
"teaching": "教学模式:保持患者身份,允许在回答后补充简短学习提示。",
|
|
|
|
|
}.get(mode, "只回答被问到的信息。")
|
|
|
|
|
system = f"""
|
|
|
|
|
你是一名标准化 AI 病人或患儿家属,只能基于病例资料回答。
|
|
|
|
|
病例主诉:{case.chief_complaint}
|
|
|
|
|
患者人设:{profile}
|
|
|
|
|
隐藏信息:{hidden_info}
|
2026-06-05 12:57:02 +08:00
|
|
|
病人初始化配置:{config_rule}
|
2026-06-01 09:25:26 +08:00
|
|
|
回答规则:
|
|
|
|
|
1. 不主动透露未被问到的隐藏信息。
|
|
|
|
|
2. 不替医生做诊断,不提供治疗方案。
|
|
|
|
|
3. 不编造病例外检查检验结果。
|
2026-06-08 15:16:07 +08:00
|
|
|
4. 每次回答控制在1到3句话,使用患者或家属口吻,不输出分析过程。
|
|
|
|
|
5. 只输出给医生看的患者或家属回复纯文本,不输出 JSON、Markdown、标题、解释或思考过程。
|
2026-06-01 09:25:26 +08:00
|
|
|
6. 如果医生一次问多个问题,按问题顺序简短回答,不扩展病例外信息。
|
|
|
|
|
7. {mode_rule}
|
|
|
|
|
"""
|
|
|
|
|
messages = [{"role": "system", "content": system.strip()}]
|
|
|
|
|
messages.extend(self._to_llm_history(memory_messages[-12:]))
|
|
|
|
|
messages.append({"role": "user", "content": user_message})
|
|
|
|
|
return messages
|
|
|
|
|
|
2026-06-05 12:57:02 +08:00
|
|
|
def _build_patient_config_rule(self, patient_config: dict | None) -> str:
|
|
|
|
|
"""配置提示:把训练页初始化配置转成 AI 病人表达约束。"""
|
|
|
|
|
if not patient_config:
|
2026-06-08 15:16:07 +08:00
|
|
|
return (
|
|
|
|
|
"使用默认门诊、青年、高等教育、平和性格的表达方式。"
|
|
|
|
|
"配置只影响表达风格,不能改变病例事实、不能泄露隐藏信息、不能编造检查检验结果。"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-05 12:57:02 +08:00
|
|
|
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", "平和")
|
2026-06-08 15:16:07 +08:00
|
|
|
|
|
|
|
|
visit_rules = {
|
|
|
|
|
"门诊": "按门诊沟通节奏回答,病情描述相对稳定。",
|
|
|
|
|
"急诊": "语气更急迫,优先表达担忧和症状变化,但每次仍控制在1到3句话。",
|
|
|
|
|
"病房": "体现住院或病房随访语境,回答更偏病程观察和治疗反应。",
|
|
|
|
|
}
|
|
|
|
|
age_rules = {
|
|
|
|
|
"儿童": "以家属代述为主,避免让患儿直接使用成人化表达。",
|
|
|
|
|
"青年": "表达相对清楚,能主动配合基础问诊。",
|
|
|
|
|
"中年": "可体现工作、家庭负担和慢病背景对就诊的影响。",
|
|
|
|
|
"老年": "表达稍慢,关注基础病、用药史和照护者补充信息。",
|
|
|
|
|
}
|
|
|
|
|
education_rules = {
|
|
|
|
|
"小学及以下": "少用医学术语,症状描述更口语化,需要医生解释才理解专业概念。",
|
|
|
|
|
"中等教育": "能理解常见健康解释,但不主动使用专业诊断结论。",
|
|
|
|
|
"高等教育": "能清楚描述症状细节,但仍不能替医生做诊断。",
|
|
|
|
|
}
|
|
|
|
|
personality_rules = {
|
|
|
|
|
"平和": "情绪稳定,按问题回答。",
|
|
|
|
|
"焦虑": "回答中带担忧,可能追问孩子是否严重、是否需要住院。",
|
|
|
|
|
"急躁": "回答更短,更希望尽快得到处理结果。",
|
|
|
|
|
"配合": "愿意补充相关细节,但不主动泄露未被问到的隐藏信息。",
|
|
|
|
|
"多疑": "会追问检查、用药和治疗依据。",
|
|
|
|
|
}
|
2026-06-05 12:57:02 +08:00
|
|
|
return (
|
|
|
|
|
f"就诊环境={visit_environment};年龄段={age_group};文化程度={education_level};性格={personality}。"
|
2026-06-08 15:16:07 +08:00
|
|
|
f"就诊环境规则:{visit_rules.get(visit_environment, '按常规问诊节奏回答。')}"
|
|
|
|
|
f"年龄段规则:{age_rules.get(age_group, '按普通成年患者表达。')}"
|
|
|
|
|
f"文化程度规则:{education_rules.get(education_level, '按普通健康素养表达。')}"
|
|
|
|
|
f"性格规则:{personality_rules.get(personality, '按问题简短回答。')}"
|
|
|
|
|
"上述配置只影响语气、配合度、理解能力和表达细节;不能改变病例事实,不能主动泄露隐藏信息,不能编造检查检验结果。"
|
2026-06-05 12:57:02 +08:00
|
|
|
)
|
|
|
|
|
|
2026-06-01 09:25:26 +08:00
|
|
|
def _to_llm_history(self, memory_messages: list[dict]) -> list[dict]:
|
|
|
|
|
"""历史转换:把业务角色 doctor/patient 转换为 LLM role。"""
|
|
|
|
|
role_map = {"doctor": "user", "patient": "assistant", "system": "system", "tool": "assistant"}
|
|
|
|
|
return [
|
|
|
|
|
{"role": role_map.get(item.get("role"), "user"), "content": item.get("content", "")}
|
|
|
|
|
for item in memory_messages
|
|
|
|
|
if item.get("content")
|
|
|
|
|
]
|