fix(ai): Copilot 审计修复 — C-1/H-1/H-2/H-3/H-4/H-5/L-2
- L-2: value_to_f64 对 Null 返回 NaN(防止误触发规则)
- C-1: load_patient_data 空数据时跳过写入快照
- H-1: 每日刷新定时器添加初始延迟
- H-2: copilot_consumer 传内层 content
- H-3: 前端 hooks/Alert 修复分页响应解析
- H-4: risk_handler 动态选择 AI provider
- H-5: 新增 DELETE /copilot/rules/{id} 软删除路由
This commit is contained in:
@@ -19,8 +19,8 @@ export function CopilotAlert() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listAlerts({ page_size: 50 });
|
||||
const result = res.data as unknown as { items: CopilotInsight[]; total: number };
|
||||
setAlerts(result.items ?? []);
|
||||
const payload = (res.data as { data?: CopilotInsight[] }).data ?? [];
|
||||
setAlerts(payload);
|
||||
} catch {
|
||||
// 静默
|
||||
} finally {
|
||||
|
||||
@@ -12,9 +12,10 @@ export function useCopilotInsights(patientId: string | undefined) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listInsights({ patient_id: patientId, page_size: 20 });
|
||||
const result = res.data as unknown as { items: CopilotInsight[]; total: number };
|
||||
setData(result.items ?? []);
|
||||
setTotal(result.total ?? 0);
|
||||
const payload = (res.data as { data?: CopilotInsight[]; total?: number }).data ?? [];
|
||||
const total = (res.data as { total?: number }).total ?? 0;
|
||||
setData(payload);
|
||||
setTotal(total);
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
|
||||
@@ -13,7 +13,8 @@ export function useCopilotRisk(patientId: string | undefined) {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getPatientRisk(patientId);
|
||||
setData(res.data as unknown as RiskScore);
|
||||
const payload = (res.data as { data?: RiskScore }).data ?? null;
|
||||
setData(payload);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载风险评分失败');
|
||||
} finally {
|
||||
|
||||
@@ -122,7 +122,7 @@ fn compare_f64(a: &Value, b: &Value) -> std::cmp::Ordering {
|
||||
fn value_to_f64(v: &Value) -> f64 {
|
||||
v.as_f64()
|
||||
.or_else(|| v.as_i64().map(|n| n as f64))
|
||||
.unwrap_or(0.0)
|
||||
.unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// 规则数据:(id, name, condition_expr, score, severity, suggestion)
|
||||
|
||||
@@ -81,6 +81,11 @@ async fn process_event(db: &sea_orm::DatabaseConnection, event: &erp_core::event
|
||||
.as_str()
|
||||
.unwrap_or("异常告警")
|
||||
.to_string();
|
||||
// 只传内层 content,避免重复存储顶层元数据
|
||||
let content = insight_data
|
||||
.get("content")
|
||||
.cloned()
|
||||
.unwrap_or(insight_data.clone());
|
||||
let _ = crate::service::insight_service::InsightService::create_insight(
|
||||
db,
|
||||
tenant_id,
|
||||
@@ -89,7 +94,7 @@ async fn process_event(db: &sea_orm::DatabaseConnection, event: &erp_core::event
|
||||
"rule".into(),
|
||||
Some(severity),
|
||||
title,
|
||||
insight_data,
|
||||
content,
|
||||
None,
|
||||
168, // 7 天过期
|
||||
None,
|
||||
|
||||
@@ -16,12 +16,21 @@ where
|
||||
{
|
||||
require_permission(&ctx, "copilot.risk.view")?;
|
||||
|
||||
// 动态选择第一个可用的 AI 提供商(优先 ollama,其次其他)
|
||||
let provider_name = state
|
||||
.provider_registry
|
||||
.provider_names()
|
||||
.into_iter()
|
||||
.find(|n| n == "ollama")
|
||||
.or_else(|| state.provider_registry.provider_names().into_iter().next())
|
||||
.unwrap_or_default();
|
||||
|
||||
let risk = crate::service::risk_service::RiskService::compute_risk_with_llm(
|
||||
&state.db,
|
||||
ctx.tenant_id,
|
||||
patient_id,
|
||||
&state.provider_registry,
|
||||
"ollama",
|
||||
&provider_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -127,3 +127,31 @@ where
|
||||
let result = active.update(&state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn delete_rule<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(rule_id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "copilot.rules.manage")?;
|
||||
|
||||
let model = copilot_rules::Entity::find()
|
||||
.filter(copilot_rules::Column::Id.eq(rule_id))
|
||||
.filter(copilot_rules::Column::TenantId.eq(ctx.tenant_id))
|
||||
.filter(copilot_rules::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| erp_core::error::AppError::NotFound("Copilot 规则".into()))?;
|
||||
|
||||
let mut active: copilot_rules::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(chrono::Utc::now()));
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.updated_by = Set(Some(ctx.user_id));
|
||||
active.update(&state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({"deleted": true}))))
|
||||
}
|
||||
|
||||
@@ -315,6 +315,8 @@ impl ErpModule for AiModule {
|
||||
// 每日凌晨 2:00 批量刷新所有在管患者风险快照
|
||||
let refresh_db = ctx.db.clone();
|
||||
tokio::spawn(async move {
|
||||
// 首次执行延迟到下一个凌晨 2:00(简单实现:延迟 6 小时后开始 24h 周期)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(86400));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
@@ -471,5 +473,9 @@ impl AiModule {
|
||||
"/copilot/rules/{id}",
|
||||
axum::routing::put(crate::handler::rule_handler::update_rule),
|
||||
)
|
||||
.route(
|
||||
"/copilot/rules/{id}",
|
||||
axum::routing::delete(crate::handler::rule_handler::delete_rule),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,20 @@ impl RiskService {
|
||||
) -> AppResult<RiskScore> {
|
||||
let rules = Self::load_rules(db, tenant_id).await?;
|
||||
let patient_data = Self::load_patient_data(db, tenant_id, patient_id).await?;
|
||||
|
||||
// 数据为空时返回低风险,不写入快照(避免虚假评分)
|
||||
if patient_data.as_object().is_none_or(|m| m.is_empty()) {
|
||||
tracing::debug!(
|
||||
patient_id = %patient_id,
|
||||
"患者数据为空,跳过风险评分写入"
|
||||
);
|
||||
return Ok(RiskScore {
|
||||
score: 0,
|
||||
level: "low".into(),
|
||||
matched_rules: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let risk = CopilotEngine::assess_patient(&rules, &patient_data);
|
||||
|
||||
// LLM 补充分析(不阻塞,失败静默降级)
|
||||
|
||||
Reference in New Issue
Block a user