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:
iven
2026-05-13 00:21:27 +08:00
parent 6d97328ff6
commit d6676abecf
9 changed files with 73 additions and 9 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,

View File

@@ -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?;

View File

@@ -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}))))
}

View File

@@ -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),
)
}
}

View File

@@ -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 补充分析(不阻塞,失败静默降级)