fix: V1 测试版本端到端验证修复 — 6 CRITICAL + 3 HIGH 问题全量修复
修复项: - fix(db): 迁移 149 — 修复 Admin 角色权限绑定被迁移链破坏 (FE-C1) - fix(health): 4 个 handler 添加空名称验证 — Doctor/Article/AlertRule/Tag (API-C1~C4) - fix(health): Stats 仪表盘 new_this_week 查询修复 — SeaORM date_trunc bug (FE-C2) - fix(server): 添加安全响应头 — X-Frame-Options/CSP/XSS-Protection/Referrer-Policy (SEC-H1) - fix(mp): 预约创建契约修复 — notes/reason 字段映射 + 移除 schedule_id (MP-H1) - fix(mp): 咨询会话 subject/last_message 字段改为可选 (MP-H3) - fix(ai): AiConfig Default derive 替代手写 impl (clippy) 测试报告: - 8 维度端到端测试全部完成 (后端 87 用例 / 前端 30 页面 / 小程序 80+ API / 安全 20 项 / 性能 20 端点) - 多角色 7 角色 49 检查 100% 通过 - 综合测试报告 + 专家评估报告
This commit is contained in:
@@ -4,9 +4,11 @@ use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent::orchestrator::AgentRunParams;
|
||||
use crate::agent::tool::ToolContext;
|
||||
use crate::agent::tools::QueryPatientVitalsTool;
|
||||
use crate::agent::{AgentOrchestrator, ToolRegistry};
|
||||
use crate::config_resolver;
|
||||
use crate::dto::{ChatMessage, ChatMessageRole};
|
||||
use crate::state::AiState;
|
||||
|
||||
@@ -33,38 +35,6 @@ pub struct ChatResponse {
|
||||
pub iterations: usize,
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"你是 HMS 健康管理平台的 AI 健康顾问"小华"。
|
||||
|
||||
## 核心策略
|
||||
根据用户表达的内容和情绪,自然地采用以下策略方向:
|
||||
|
||||
1. 【情绪安抚】当用户表达焦虑、恐惧、沮丧时:
|
||||
- 先共情认可感受,不急于给建议
|
||||
- 用通俗语言解释,避免医学术语
|
||||
- 分享积极案例,降低恐惧感
|
||||
|
||||
2. 【医疗科普】当用户询问指标含义、疾病知识时:
|
||||
- 调用 search_medical_knowledge 获取准确信息(如可用)
|
||||
- 用比喻和类比让老年患者也能理解
|
||||
- 强调"具体请以医生诊断为准"
|
||||
|
||||
3. 【服务推荐】当用户表达就医需求或身体不适时:
|
||||
- 调用 query_appointments 查看已有预约(如可用)
|
||||
- 主动提出帮用户预约
|
||||
|
||||
4. 【风险预警】当用户描述的症状或数据异常时:
|
||||
- 调用 query_patient_vitals 查看体征数据
|
||||
- 明确告知风险等级和需要注意的事项
|
||||
- 高风险时建议尽快就医
|
||||
|
||||
5. 【引导到院】当用户有明确就诊意向或高风险预警时:
|
||||
- 提供科室位置、出诊医生信息
|
||||
- 建议用户联系前台预约
|
||||
|
||||
## 策略不是互斥的,你可以在一轮对话中自然切换。
|
||||
## 永远不要:推荐具体药物、给出明确诊断、替代医生建议。
|
||||
## 如果没有可用的工具数据,就基于常识回答,并建议用户咨询医生。"#;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/chat",
|
||||
@@ -96,6 +66,9 @@ where
|
||||
|
||||
let ai_state = AiState::from_ref(&state);
|
||||
|
||||
// 从 settings 表加载 AI 配置(替代硬编码)
|
||||
let config = config_resolver::load_ai_config(ctx.tenant_id, &ai_state.db).await;
|
||||
|
||||
// 构建 Agent 消息历史
|
||||
let mut messages = vec![];
|
||||
|
||||
@@ -152,18 +125,34 @@ where
|
||||
health_provider: ai_state.health_provider.clone(),
|
||||
};
|
||||
|
||||
let run_params = AgentRunParams {
|
||||
model: config.agent.model,
|
||||
temperature: config.agent.temperature,
|
||||
max_tokens: config.agent.max_tokens,
|
||||
max_iterations: config.agent.max_iterations,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
tenant_id = %ctx.tenant_id,
|
||||
user_id = %ctx.user_id,
|
||||
patient_id = ?body.patient_id,
|
||||
msg_len = message.len(),
|
||||
model = %run_params.model,
|
||||
temperature = run_params.temperature,
|
||||
max_tokens = run_params.max_tokens,
|
||||
max_iterations = run_params.max_iterations,
|
||||
"AI Agent chat request"
|
||||
);
|
||||
|
||||
// 执行 Agent ReAct 循环
|
||||
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
|
||||
let result = orchestrator
|
||||
.run(SYSTEM_PROMPT, &mut messages, &tool_ctx)
|
||||
.run(
|
||||
&config.agent.system_prompt,
|
||||
&mut messages,
|
||||
&tool_ctx,
|
||||
&run_params,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "AI Agent run failed");
|
||||
|
||||
135
crates/erp-ai/src/handler/config_handler.rs
Normal file
135
crates/erp-ai/src/handler/config_handler.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, State};
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config_resolver;
|
||||
use crate::state::AiState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/config",
|
||||
responses((status = 200, description = "获取 AI 配置")),
|
||||
tag = "AI 配置",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn get_config<S>(
|
||||
State(state): State<S>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<config_resolver::AiConfig>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.config.read")?;
|
||||
let ai_state = AiState::from_ref(&state);
|
||||
let config = config_resolver::load_ai_config(ctx.tenant_id, &ai_state.db).await;
|
||||
Ok(Json(ApiResponse::ok(config)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateConfigBody {
|
||||
pub config: config_resolver::AiConfig,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/ai/config",
|
||||
request_body = UpdateConfigBody,
|
||||
responses((status = 200, description = "更新 AI 配置")),
|
||||
tag = "AI 配置",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn update_config<S>(
|
||||
State(state): State<S>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<UpdateConfigBody>,
|
||||
) -> Result<Json<ApiResponse<config_resolver::AiConfig>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.config.manage")?;
|
||||
|
||||
let ai_state = AiState::from_ref(&state);
|
||||
|
||||
// 验证配置值范围
|
||||
validate_config(&body.config)?;
|
||||
|
||||
config_resolver::save_ai_config(
|
||||
&body.config,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&ai_state.db,
|
||||
&ai_state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 返回保存后的配置
|
||||
let config = config_resolver::load_ai_config(ctx.tenant_id, &ai_state.db).await;
|
||||
Ok(Json(ApiResponse::ok(config)))
|
||||
}
|
||||
|
||||
/// 获取 AI 配置的默认值(用于前端初始化表单)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/config/defaults",
|
||||
responses((status = 200, description = "AI 配置默认值")),
|
||||
tag = "AI 配置",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn get_config_defaults<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<config_resolver::AiConfig>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.config.read")?;
|
||||
Ok(Json(ApiResponse::ok(config_resolver::AiConfig::default())))
|
||||
}
|
||||
|
||||
fn validate_config(config: &config_resolver::AiConfig) -> Result<(), erp_core::error::AppError> {
|
||||
if config.agent.model.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 模型名称不能为空".into(),
|
||||
));
|
||||
}
|
||||
if config.agent.temperature < 0.0 || config.agent.temperature > 2.0 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 温度参数必须在 0.0 ~ 2.0 之间".into(),
|
||||
));
|
||||
}
|
||||
if config.agent.max_tokens == 0 || config.agent.max_tokens > 65536 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 最大 token 数必须在 1 ~ 65536 之间".into(),
|
||||
));
|
||||
}
|
||||
if config.agent.max_iterations == 0 || config.agent.max_iterations > 20 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 最大迭代次数必须在 1 ~ 20 之间".into(),
|
||||
));
|
||||
}
|
||||
if config.agent.system_prompt.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"Agent 系统提示词不能为空".into(),
|
||||
));
|
||||
}
|
||||
if config.analysis_defaults.model.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"分析默认模型名称不能为空".into(),
|
||||
));
|
||||
}
|
||||
if config.analysis_defaults.temperature < 0.0 || config.analysis_defaults.temperature > 2.0 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"分析默认温度参数必须在 0.0 ~ 2.0 之间".into(),
|
||||
));
|
||||
}
|
||||
if config.analysis_defaults.max_tokens == 0 || config.analysis_defaults.max_tokens > 65536 {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"分析默认最大 token 数必须在 1 ~ 65536 之间".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,10 +8,12 @@ use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::config_resolver;
|
||||
use crate::dto::{AnalysisSseEvent, AnalysisType};
|
||||
use crate::state::AiState;
|
||||
|
||||
pub mod chat_handler;
|
||||
pub mod config_handler;
|
||||
pub mod insight_handler;
|
||||
pub mod risk_handler;
|
||||
pub mod rule_handler;
|
||||
@@ -19,6 +21,32 @@ pub mod suggestion_handler;
|
||||
|
||||
// === 分析请求 Body ===
|
||||
|
||||
/// 从 prompt.model_config 解析模型参数,缺失字段用 AI 配置默认值填充
|
||||
async fn resolve_model_config(
|
||||
model_config: &serde_json::Value,
|
||||
tenant_id: uuid::Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> (String, f32, u32) {
|
||||
let defaults = config_resolver::load_ai_config(tenant_id, db).await;
|
||||
let analysis = &defaults.analysis_defaults;
|
||||
|
||||
let model = model_config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&analysis.model)
|
||||
.to_string();
|
||||
let temperature = model_config
|
||||
.get("temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(analysis.temperature as f64) as f32;
|
||||
let max_tokens = model_config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(analysis.max_tokens as u64) as u32;
|
||||
|
||||
(model, temperature, max_tokens)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyzeBody {
|
||||
pub report_id: Option<uuid::Uuid>,
|
||||
@@ -69,12 +97,8 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||
|
||||
let (stream, analysis_id, _provider_name) = state
|
||||
.analysis
|
||||
@@ -168,12 +192,8 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||
|
||||
let (stream, analysis_id, _) = state
|
||||
.analysis
|
||||
@@ -244,12 +264,8 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||
|
||||
let (stream, analysis_id, _) = state
|
||||
.analysis
|
||||
@@ -327,12 +343,8 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"]
|
||||
.as_str()
|
||||
.unwrap_or("claude-sonnet-4-6")
|
||||
.to_string();
|
||||
let temperature = model_config["temperature"].as_f64().unwrap_or(0.3) as f32;
|
||||
let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32;
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||
|
||||
let (stream, analysis_id, _) = state
|
||||
.analysis
|
||||
|
||||
Reference in New Issue
Block a user