Commit f5410548 authored by yuguo's avatar yuguo

fix

parent 626473e4
......@@ -9,8 +9,8 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model"
"internet-hospital/pkg/ai"
"internet-hospital/pkg/database"
)
......@@ -103,17 +103,30 @@ func (s *Service) GetAIRenewalAdvice(ctx context.Context, userID, renewalID stri
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", renewalID, userID).First(&r).Error; err != nil {
return "", err
}
prompt := fmt.Sprintf("患者患有%s,申请续方药品:%s,续方原因:%s。请从医学角度给出续方建议,包括用药注意事项、可能的药物相互作用和生活方式建议。", r.DiseaseName, r.Medicines, r.Reason)
result := ai.Call(ctx, ai.CallParams{
Scene: "renewal_advice",
UserID: userID,
Messages: []ai.ChatMessage{{Role: "user", Content: prompt}},
RequestSummary: r.DiseaseName,
})
if result.Error != nil {
return "", result.Error
// 解析药品列表以获取疗程长度(取续方申请里的 Medicines JSON)
var medicines []string
json.Unmarshal([]byte(r.Medicines), &medicines)
durationMonths := 1
agentCtx := map[string]interface{}{
"patient_id": userID,
"diagnosis": r.DiseaseName,
"current_drugs": medicines,
"duration_months": durationMonths,
}
msg := fmt.Sprintf("患者%s申请续药%d个月,当前用药:%s,原因:%s。请评估续药合理性并给出专业建议。",
r.DiseaseName, durationMonths, r.Medicines, r.Reason)
output, err := internalagent.GetService().Chat(ctx, "follow_up_agent", userID, "", msg, agentCtx)
if err != nil {
return "", fmt.Errorf("AI续药建议获取失败: %w", err)
}
if output == nil {
return "", fmt.Errorf("follow_up_agent 未初始化")
}
advice := result.Content
advice := output.Response
s.db.WithContext(ctx).Model(&r).Update("ai_advice", advice)
return advice, nil
}
......
......@@ -7,6 +7,7 @@ import (
"internet-hospital/internal/model"
"internet-hospital/pkg/response"
"internet-hospital/pkg/workflow"
)
// Handler 医生端API处理器
......@@ -59,6 +60,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
// 处方管理
dp.POST("/prescription/create", h.CreatePrescription)
dp.POST("/prescription/check", h.CheckPrescriptionSafety)
dp.GET("/prescriptions", h.GetDoctorPrescriptions)
dp.GET("/prescription/:id", h.GetPrescriptionDetail)
}
......@@ -189,10 +191,19 @@ func (h *Handler) EndConsult(c *gin.Context) {
Summary string `json:"summary"`
}
_ = c.ShouldBindJSON(&req)
userID, _ := c.Get("user_id")
if err := h.service.EndConsult(c.Request.Context(), id, req.Summary); err != nil {
response.Error(c, 500, "结束问诊失败")
return
}
// 异步触发 follow_up 工作流(失败不影响主流程)
go workflow.GetEngine().TriggerByCategory(c.Request.Context(), "follow_up", map[string]interface{}{
"consult_id": id,
"doctor_id": fmt.Sprintf("%v", userID),
})
response.Success(c, nil)
}
......@@ -389,3 +400,26 @@ func (h *Handler) GetPrescriptionDetail(c *gin.Context) {
}
response.Success(c, result)
}
// CheckPrescriptionSafety AI处方安全审核
func (h *Handler) CheckPrescriptionSafety(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
var req struct {
PatientID string `json:"patient_id" binding:"required"`
Drugs []string `json:"drugs" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误: "+err.Error())
return
}
result, err := h.service.CheckPrescriptionSafety(c.Request.Context(), userID.(string), req.PatientID, req.Drugs)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}
......@@ -3,10 +3,12 @@ package doctorportal
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model"
)
......@@ -187,3 +189,35 @@ func (s *Service) GetPrescriptionByID(ctx context.Context, id string) (*model.Pr
}
return &prescription, nil
}
// CheckPrescriptionSafety 通过 prescription_agent 审核处方安全性
func (s *Service) CheckPrescriptionSafety(ctx context.Context, userID, patientID string, drugs []string) (map[string]interface{}, error) {
drugList := strings.Join(drugs, "、")
agentCtx := map[string]interface{}{
"patient_id": patientID,
"drugs": drugs,
}
message := fmt.Sprintf("请审核以下处方的安全性:%s,检查药物相互作用和禁忌症", drugList)
agentSvc := internalagent.GetService()
output, err := agentSvc.Chat(ctx, "prescription_agent", userID, "", message, agentCtx)
if err != nil {
return nil, fmt.Errorf("处方安全审核失败: %w", err)
}
if output == nil {
return nil, fmt.Errorf("prescription_agent 未初始化")
}
// 判断是否有警告(简单关键词检测)
resp := output.Response
hasWarning := strings.Contains(resp, "相互作用") || strings.Contains(resp, "注意") || strings.Contains(resp, "警告") || strings.Contains(resp, "慎用")
hasContraindication := strings.Contains(resp, "禁忌") || strings.Contains(resp, "禁止") || strings.Contains(resp, "不宜")
return map[string]interface{}{
"report": resp,
"tool_calls": output.ToolCalls,
"iterations": output.Iterations,
"has_warning": hasWarning,
"has_contraindication": hasContraindication,
}, nil
}
......@@ -11,6 +11,7 @@ import (
"internet-hospital/internal/model"
"internet-hospital/pkg/ai"
"internet-hospital/pkg/middleware"
"internet-hospital/pkg/workflow"
)
// ChatMessage 对话消息(统一格式)
......@@ -265,6 +266,14 @@ func (h *Handler) FinishChat(c *gin.Context) {
h.service.db.Save(&preConsult)
// 异步触发 pre_consult 工作流(失败不影响主流程)
go workflow.GetEngine().TriggerByCategory(c.Request.Context(), "pre_consult", map[string]interface{}{
"pre_consult_id": preConsult.ID,
"patient_id": preConsult.PatientID,
"department": preConsult.AIDepartment,
"severity": preConsult.AISeverity,
})
// 查找推荐医生
doctors := h.service.findRecommendedDoctors(preConsult.AIDepartment)
doctorsJSON, _ := json.Marshal(doctors)
......
......@@ -472,6 +472,21 @@ func (e *Engine) executeTemplate(_ context.Context, node *Node, execCtx *Executi
}, nil
}
// TriggerByCategory 查找指定 category 的第一个 active 工作流并异步执行
// 失败不影响主流程(纯异步触发)
func (e *Engine) TriggerByCategory(ctx context.Context, category string, input map[string]interface{}) {
db := database.GetDB()
var wfDef model.WorkflowDefinition
if err := db.Where("category = ? AND status = 'active'", category).First(&wfDef).Error; err != nil {
// 没有对应分类的活跃工作流,静默跳过
return
}
go func() {
defer func() { recover() }()
e.Execute(ctx, wfDef.WorkflowID, input, "system")
}()
}
func replaceAll(s, old, new string) string {
if old == "" {
return s
......
......@@ -121,9 +121,15 @@ export const consultApi = {
// 结束问诊
endConsult: (id: string) => post<null>(`/consult/${id}/end`),
// AI辅助分析(鉴别诊断/用药建议)
// AI辅助分析(鉴别诊断/用药建议)—— 通过 Agent,返回 response + tool_calls
aiAssist: (id: string, scene: string) =>
post<{ scene: string; content: string }>(`/consult/${id}/ai-assist`, { scene }),
post<{
scene: string;
response: string;
tool_calls?: import('./agent').ToolCall[];
iterations?: number;
total_tokens?: number;
}>(`/consult/${id}/ai-assist`, { scene }),
// 取消问诊
cancelConsult: (id: string, reason?: string) =>
......
import { get, post, put } from './request';
import type { Consultation, ConsultMessage } from './consult';
import type { ToolCall } from './agent';
// ==================== 医生端 API ====================
......@@ -181,4 +182,14 @@ export const doctorPortalApi = {
getPatientDetail: (patientId: string) =>
get<PatientDetail>(`/doctor-portal/patient/${patientId}/detail`),
// === 处方安全审核 ===
checkPrescriptionSafety: (params: { patient_id: string; drugs: string[] }) =>
post<{
report: string;
tool_calls?: ToolCall[];
iterations?: number;
has_warning: boolean;
has_contraindication: boolean;
}>('/doctor-portal/prescription/check', params),
};
......@@ -15,14 +15,12 @@ import {
ThunderboltOutlined,
SwapOutlined,
} from '@ant-design/icons';
import { useUserStore } from '../../store/userStore';
import { agentApi } from '../../api/agent';
import type { ChatMessage, ToolCall, AgentOption } from './types';
import { PATIENT_AGENTS, DOCTOR_AGENTS } from './types';
const { TextArea } = Input;
const API = process.env.NEXT_PUBLIC_API_URL || '';
interface ChatPanelProps {
role: 'patient' | 'doctor';
patientContext?: {
......@@ -33,11 +31,10 @@ interface ChatPanelProps {
}
const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
const { accessToken: token } = useUserStore();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId] = useState(() => crypto.randomUUID());
const [sessionId, setSessionId] = useState('');
const [selectedAgent, setSelectedAgent] = useState<string>(
role === 'patient' ? 'pre_consult_agent' : 'diagnosis_agent'
);
......@@ -45,8 +42,31 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
const agents: AgentOption[] = role === 'patient' ? PATIENT_AGENTS : DOCTOR_AGENTS;
// mount时恢复最近会话
useEffect(() => {
if (messages.length === 0) {
const restoreSession = async () => {
try {
const res = await agentApi.getSessions(selectedAgent);
const sessions = res.data;
if (sessions && sessions.length > 0) {
const latest = sessions[0];
setSessionId(latest.session_id);
// 恢复历史消息
try {
const history = JSON.parse(latest.history || '[]') as { role: string; content: string }[];
if (history.length > 0) {
const restored: ChatMessage[] = history.map(h => ({
role: h.role as 'user' | 'assistant',
content: h.content,
timestamp: new Date(),
}));
setMessages(restored);
return;
}
} catch { /* 历史解析失败,使用默认欢迎消息 */ }
}
} catch { /* 会话恢复失败,使用默认欢迎消息 */ }
// 没有历史会话,展示欢迎消息
const agent = agents.find(a => a.id === selectedAgent);
setMessages([{
role: 'system',
......@@ -55,7 +75,8 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
: `您好,我是${agent?.name || 'AI诊断助手'}。请描述患者症状或输入您的问题。`,
timestamp: new Date(),
}]);
}
};
restoreSession();
}, []);
useEffect(() => {
......@@ -76,40 +97,29 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
};
if (patientContext) requestBody.context = patientContext;
const res = await fetch(`${API}/api/v1/agent/${selectedAgent}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
});
const res = await agentApi.chat(selectedAgent, requestBody as Parameters<typeof agentApi.chat>[1]);
const agentData = res.data;
if (res.ok) {
const data = await res.json();
const agentData = data.data;
setMessages(prev => [...prev, {
role: 'assistant',
content: agentData?.response || '暂时无法回答,请稍后重试。',
toolCalls: agentData?.tool_calls,
meta: {
iterations: agentData?.iterations,
tokens: agentData?.total_tokens,
agent_id: selectedAgent,
},
timestamp: new Date(),
}]);
} else {
setMessages(prev => [...prev, {
role: 'assistant',
content: '请求失败,请稍后重试。',
timestamp: new Date(),
}]);
// 如果服务端分配了新 session_id 就更新
if (agentData?.session_id && !sessionId) {
setSessionId(agentData.session_id);
}
setMessages(prev => [...prev, {
role: 'assistant',
content: agentData?.response || '暂时无法回答,请稍后重试。',
toolCalls: agentData?.tool_calls,
meta: {
iterations: agentData?.iterations,
tokens: agentData?.total_tokens,
agent_id: selectedAgent,
},
timestamp: new Date(),
}]);
} catch {
setMessages(prev => [...prev, {
role: 'assistant',
content: '网络错误,请检查网络连接。',
content: '请求失败,请稍后重试。',
timestamp: new Date(),
}]);
} finally {
......@@ -119,6 +129,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
const handleAgentChange = (agentId: string) => {
setSelectedAgent(agentId);
setSessionId(''); // 切换 Agent 时重置会话,由服务端创建新会话
const agent = agents.find(a => a.id === agentId);
setMessages([{
role: 'system',
......
import React, { useState } from 'react';
import {
Card, Tabs, Typography, Space, Empty, Tag, Divider, Alert, Spin, Badge, Button,
Collapse, Timeline,
} from 'antd';
import {
RobotOutlined, FileTextOutlined, UserOutlined,
MessageOutlined, MedicineBoxOutlined, ThunderboltOutlined,
ToolOutlined, CheckCircleOutlined, CloseCircleOutlined,
} from '@ant-design/icons';
import type { PreConsultResponse, ChatMessage } from '../../../api/preConsult';
import type { ToolCall } from '../../../api/agent';
import { consultApi } from '../../../api/consult';
import MarkdownRenderer from '../../../components/MarkdownRenderer';
......@@ -27,29 +30,77 @@ const AIPanel: React.FC<AIPanelProps> = ({
}) => {
const [diagnosisContent, setDiagnosisContent] = useState('');
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [diagnosisToolCalls, setDiagnosisToolCalls] = useState<ToolCall[]>([]);
const [medicationContent, setMedicationContent] = useState('');
const [medicationLoading, setMedicationLoading] = useState(false);
const [medicationToolCalls, setMedicationToolCalls] = useState<ToolCall[]>([]);
const [preConsultSubTab, setPreConsultSubTab] = useState('chat');
const handleAIAssist = async (scene: 'consult_diagnosis' | 'consult_medication') => {
if (!activeConsultId) return;
const setLoading = scene === 'consult_diagnosis' ? setDiagnosisLoading : setMedicationLoading;
const setContent = scene === 'consult_diagnosis' ? setDiagnosisContent : setMedicationContent;
const setToolCalls = scene === 'consult_diagnosis' ? setDiagnosisToolCalls : setMedicationToolCalls;
setLoading(true);
try {
const res = await consultApi.aiAssist(activeConsultId, scene);
setContent(res.data?.content || '暂无分析结果');
setContent(res.data?.response || '暂无分析结果');
setToolCalls(res.data?.tool_calls || []);
} catch (err: any) {
setContent('AI分析失败: ' + (err?.message || '请稍后重试'));
setToolCalls([]);
} finally {
setLoading(false);
}
};
const renderToolCalls = (toolCalls: ToolCall[]) => {
if (!toolCalls || toolCalls.length === 0) return null;
return (
<Collapse
size="small"
style={{ marginTop: 8, background: '#f9fafb', borderRadius: 8 }}
items={[{
key: 'tools',
label: (
<span style={{ fontSize: 12, color: '#6b7280' }}>
<ToolOutlined style={{ marginRight: 4 }} />
调用了 {toolCalls.length} 个工具
</span>
),
children: (
<Timeline
style={{ marginTop: 8, marginBottom: 0 }}
items={toolCalls.map((tc, idx) => ({
color: tc.success ? 'green' : 'red',
dot: tc.success
? <CheckCircleOutlined style={{ fontSize: 12 }} />
: <CloseCircleOutlined style={{ fontSize: 12 }} />,
children: (
<div key={idx} style={{ fontSize: 12 }}>
<div style={{ fontWeight: 500, color: '#374151' }}>{tc.tool_name}</div>
<div style={{ color: '#9ca3af', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260 }}>
参数: {tc.arguments}
</div>
{tc.result && (
<div style={{ color: tc.success ? '#52c41a' : '#ff4d4f', fontSize: 11 }}>
{tc.success ? '✓ 成功' : `✗ ${tc.result.error}`}
</div>
)}
</div>
),
}))}
/>
),
}]}
/>
);
};
const hasPreConsultData = preConsultReport && (
preConsultReport.ai_analysis || (preConsultReport.chat_messages && preConsultReport.chat_messages.length > 0)
);
// 渲染完整对话记录
const renderFullChatHistory = (chatMsgs: ChatMessage[]) => {
if (!chatMsgs || chatMsgs.length === 0) {
return <Alert message="暂无对话记录" type="info" showIcon style={{ fontSize: 12 }} />;
......@@ -74,7 +125,6 @@ const AIPanel: React.FC<AIPanelProps> = ({
const renderPreConsultContent = () => {
if (!preConsultReport) return null;
const patientInfo = (
<div style={{ padding: 8, background: '#f9f0ff', borderRadius: 6, marginBottom: 8 }}>
<Text strong style={{ fontSize: 13, color: '#722ed1' }}>
......@@ -88,7 +138,6 @@ const AIPanel: React.FC<AIPanelProps> = ({
)}
</div>
);
const tags = (preConsultReport.ai_severity || preConsultReport.ai_department) && (
<div style={{ display: 'flex', gap: 4, marginBottom: 8, flexWrap: 'wrap' }}>
{preConsultReport.ai_severity && (
......@@ -99,7 +148,6 @@ const AIPanel: React.FC<AIPanelProps> = ({
{preConsultReport.ai_department && <Tag color="blue">{preConsultReport.ai_department}</Tag>}
</div>
);
return (
<div>
{patientInfo}
......@@ -136,6 +184,54 @@ const AIPanel: React.FC<AIPanelProps> = ({
);
};
const renderAIAssistContent = (
loading: boolean,
content: string,
toolCalls: ToolCall[],
scene: 'consult_diagnosis' | 'consult_medication',
) => {
if (loading) return <div style={{ textAlign: 'center', padding: 20 }}><Spin tip="AI分析.." /></div>;
if (content) {
return (
<div>
<div style={{ maxHeight: 400, overflow: 'auto' }}>
<MarkdownRenderer content={content} fontSize={12} lineHeight={1.6} />
</div>
{renderToolCalls(toolCalls)}
{toolCalls.length > 0 && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 4 }}>
<ThunderboltOutlined style={{ marginRight: 2 }} />
通过 {toolCalls.length} 次工具调用生成
</div>
)}
<Divider style={{ margin: '8px 0' }} />
<Button
size="small"
icon={scene === 'consult_diagnosis' ? <ThunderboltOutlined /> : <MedicineBoxOutlined />}
onClick={() => handleAIAssist(scene)}
>
重新分析
</Button>
</div>
);
}
return (
<div style={{ textAlign: 'center', padding: 16 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
{scene === 'consult_diagnosis'
? '基于问诊对话内容,AI将辅助生成鉴别诊断'
: '基于问诊对话内容,AI将辅助生成用药建议'}
</Text>
<Button type="primary" size="small"
icon={scene === 'consult_diagnosis' ? <ThunderboltOutlined /> : <MedicineBoxOutlined />}
onClick={() => handleAIAssist(scene)}
>
{scene === 'consult_diagnosis' ? '生成鉴别诊断' : '生成用药建议'}
</Button>
</div>
);
};
return (
<Card
title={
......@@ -172,54 +268,12 @@ const AIPanel: React.FC<AIPanelProps> = ({
{
key: 'diagnosis',
label: '鉴别诊断',
children: diagnosisLoading ? (
<div style={{ textAlign: 'center', padding: 20 }}><Spin tip="AI分析.." /></div>
) : diagnosisContent ? (
<div>
<div style={{ maxHeight: 400, overflow: 'auto' }}>
<MarkdownRenderer content={diagnosisContent} fontSize={12} lineHeight={1.6} />
</div>
<Divider style={{ margin: '8px 0' }} />
<Button size="small" icon={<ThunderboltOutlined />} onClick={() => handleAIAssist('consult_diagnosis')}>
重新分析
</Button>
</div>
) : (
<div style={{ textAlign: 'center', padding: 16 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
基于问诊对话内容,AI将辅助生成鉴别诊断
</Text>
<Button type="primary" size="small" icon={<ThunderboltOutlined />} onClick={() => handleAIAssist('consult_diagnosis')}>
生成鉴别诊断
</Button>
</div>
),
children: renderAIAssistContent(diagnosisLoading, diagnosisContent, diagnosisToolCalls, 'consult_diagnosis'),
},
{
key: 'drugs',
label: '用药建议',
children: medicationLoading ? (
<div style={{ textAlign: 'center', padding: 20 }}><Spin tip="AI分析.." /></div>
) : medicationContent ? (
<div>
<div style={{ maxHeight: 400, overflow: 'auto' }}>
<MarkdownRenderer content={medicationContent} fontSize={12} lineHeight={1.6} />
</div>
<Divider style={{ margin: '8px 0' }} />
<Button size="small" icon={<MedicineBoxOutlined />} onClick={() => handleAIAssist('consult_medication')}>
重新生成
</Button>
</div>
) : (
<div style={{ textAlign: 'center', padding: 16 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
基于问诊对话内容,AI将辅助生成用药建议
</Text>
<Button type="primary" size="small" icon={<MedicineBoxOutlined />} onClick={() => handleAIAssist('consult_medication')}>
生成用药建议
</Button>
</div>
),
children: renderAIAssistContent(medicationLoading, medicationContent, medicationToolCalls, 'consult_medication'),
},
]}
/>
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment