fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
功能修复: 1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查 2. 仪表盘统计容错:单个查询失败返回零值而非 500 3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致 4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径 5. 积分端点权限码:health.health-data.list → health.points.list 6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage 7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档 Clippy 全 workspace 清零(14→0 errors): - erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处 - erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处 - erp-ai: 修复 dead_code、unused import 等 11 处 - erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处 - erp-server-migration: 修复 enum_variant_names 5 处 - erp-auth/config/workflow/message: 各 1-3 处 工程改进: - lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy) - cargo fmt 统一格式化
This commit is contained in:
@@ -49,7 +49,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn provider_type_all_variants() {
|
||||
for pt in [ProviderType::Claude, ProviderType::Openai, ProviderType::Ollama, ProviderType::Rules] {
|
||||
for pt in [
|
||||
ProviderType::Claude,
|
||||
ProviderType::Openai,
|
||||
ProviderType::Ollama,
|
||||
ProviderType::Rules,
|
||||
] {
|
||||
let json = serde_json::to_string(&pt).unwrap();
|
||||
let back: ProviderType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, pt);
|
||||
|
||||
@@ -121,10 +121,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn analysis_type_prompt_name() {
|
||||
assert_eq!(AnalysisType::LabReport.prompt_name(), "lab_report_interpretation");
|
||||
assert_eq!(
|
||||
AnalysisType::LabReport.prompt_name(),
|
||||
"lab_report_interpretation"
|
||||
);
|
||||
assert_eq!(AnalysisType::Trends.prompt_name(), "health_trend_analysis");
|
||||
assert_eq!(AnalysisType::CheckupPlan.prompt_name(), "personalized_checkup_plan");
|
||||
assert_eq!(AnalysisType::ReportSummary.prompt_name(), "report_summary_generation");
|
||||
assert_eq!(
|
||||
AnalysisType::CheckupPlan.prompt_name(),
|
||||
"personalized_checkup_plan"
|
||||
);
|
||||
assert_eq!(
|
||||
AnalysisType::ReportSummary.prompt_name(),
|
||||
"report_summary_generation"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- AnalysisType serde round-trip ----
|
||||
@@ -195,7 +204,10 @@ mod tests {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let back: AnalysisSseEvent = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
AnalysisSseEvent::Done { analysis_id, status } => {
|
||||
AnalysisSseEvent::Done {
|
||||
analysis_id,
|
||||
status,
|
||||
} => {
|
||||
assert_eq!(analysis_id, id);
|
||||
assert_eq!(status, "completed");
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ pub enum AiError {
|
||||
DbError(String),
|
||||
|
||||
#[error("AI 配额已耗尽: {reason}")]
|
||||
QuotaExhausted { tenant_id: uuid::Uuid, reason: String },
|
||||
QuotaExhausted {
|
||||
tenant_id: uuid::Uuid,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("缓存错误: {0}")]
|
||||
CacheError(String),
|
||||
@@ -54,9 +57,7 @@ impl From<AiError> for AppError {
|
||||
AiError::Validation(msg) => AppError::Validation(msg),
|
||||
AiError::AnalysisNotFound(id) => AppError::NotFound(format!("分析结果: {id}")),
|
||||
AiError::PromptNotFound(name) => AppError::NotFound(format!("Prompt 模板: {name}")),
|
||||
AiError::ProviderUnavailable(p) => {
|
||||
AppError::Internal(format!("AI 提供商 {p} 不可用"))
|
||||
}
|
||||
AiError::ProviderUnavailable(p) => AppError::Internal(format!("AI 提供商 {p} 不可用")),
|
||||
AiError::RateLimitExceeded => AppError::TooManyRequests,
|
||||
AiError::QuotaExhausted { .. } => AppError::TooManyRequests,
|
||||
AiError::VersionMismatch => AppError::VersionMismatch,
|
||||
@@ -153,7 +154,7 @@ mod tests {
|
||||
let err = AiError::RateLimitExceeded;
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::TooManyRequests => {},
|
||||
AppError::TooManyRequests => {}
|
||||
other => panic!("期望 AppError::TooManyRequests,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
@@ -163,7 +164,7 @@ mod tests {
|
||||
let err = AiError::VersionMismatch;
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::VersionMismatch => {},
|
||||
AppError::VersionMismatch => {}
|
||||
other => panic!("期望 AppError::VersionMismatch,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::Json;
|
||||
use erp_core::health_provider::TimeRange;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
@@ -34,9 +34,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.manage")?;
|
||||
let report_id = body.report_id.ok_or_else(|| {
|
||||
erp_core::error::AppError::Validation("report_id 必填".into())
|
||||
})?;
|
||||
let report_id = body
|
||||
.report_id
|
||||
.ok_or_else(|| erp_core::error::AppError::Validation("report_id 必填".into()))?;
|
||||
|
||||
let lab_dto = state
|
||||
.health_provider
|
||||
@@ -57,7 +57,10 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string();
|
||||
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;
|
||||
|
||||
@@ -83,7 +86,15 @@ where
|
||||
let patient_id_clone = uuid::Uuid::nil(); // lab report 场景 patient_id 从 report 关联
|
||||
let doctor_id_clone = ctx.user_id;
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "lab_report", ctx.tenant_id, patient_id_clone, doctor_id_clone);
|
||||
let sse_stream = build_sse_stream(
|
||||
stream,
|
||||
analysis_id_clone,
|
||||
state_clone,
|
||||
"lab_report",
|
||||
ctx.tenant_id,
|
||||
patient_id_clone,
|
||||
doctor_id_clone,
|
||||
);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -97,9 +108,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.manage")?;
|
||||
let patient_id = body.patient_id.ok_or_else(|| {
|
||||
erp_core::error::AppError::Validation("patient_id 必填".into())
|
||||
})?;
|
||||
let patient_id = body
|
||||
.patient_id
|
||||
.ok_or_else(|| erp_core::error::AppError::Validation("patient_id 必填".into()))?;
|
||||
|
||||
let metrics = body.metrics.unwrap_or_else(|| {
|
||||
vec![
|
||||
@@ -126,7 +137,10 @@ where
|
||||
));
|
||||
}
|
||||
|
||||
let sanitized_data = state.analysis.sanitizer.sanitize_trend_analysis(&trend_data)?;
|
||||
let sanitized_data = state
|
||||
.analysis
|
||||
.sanitizer
|
||||
.sanitize_trend_analysis(&trend_data)?;
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
@@ -134,7 +148,10 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string();
|
||||
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;
|
||||
|
||||
@@ -158,7 +175,15 @@ where
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "trend", ctx.tenant_id, uuid::Uuid::nil(), ctx.user_id);
|
||||
let sse_stream = build_sse_stream(
|
||||
stream,
|
||||
analysis_id_clone,
|
||||
state_clone,
|
||||
"trend",
|
||||
ctx.tenant_id,
|
||||
uuid::Uuid::nil(),
|
||||
ctx.user_id,
|
||||
);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -172,9 +197,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.manage")?;
|
||||
let patient_id = body.patient_id.ok_or_else(|| {
|
||||
erp_core::error::AppError::Validation("patient_id 必填".into())
|
||||
})?;
|
||||
let patient_id = body
|
||||
.patient_id
|
||||
.ok_or_else(|| erp_core::error::AppError::Validation("patient_id 必填".into()))?;
|
||||
|
||||
let summary_dto = state
|
||||
.health_provider
|
||||
@@ -191,7 +216,10 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string();
|
||||
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;
|
||||
|
||||
@@ -215,7 +243,15 @@ where
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "checkup_plan", ctx.tenant_id, uuid::Uuid::nil(), ctx.user_id);
|
||||
let sse_stream = build_sse_stream(
|
||||
stream,
|
||||
analysis_id_clone,
|
||||
state_clone,
|
||||
"checkup_plan",
|
||||
ctx.tenant_id,
|
||||
uuid::Uuid::nil(),
|
||||
ctx.user_id,
|
||||
);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -229,9 +265,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.manage")?;
|
||||
let report_id = body.report_id.ok_or_else(|| {
|
||||
erp_core::error::AppError::Validation("report_id 必填".into())
|
||||
})?;
|
||||
let report_id = body
|
||||
.report_id
|
||||
.ok_or_else(|| erp_core::error::AppError::Validation("report_id 必填".into()))?;
|
||||
|
||||
let report_dto = state
|
||||
.health_provider
|
||||
@@ -255,7 +291,10 @@ where
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string();
|
||||
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;
|
||||
|
||||
@@ -279,7 +318,15 @@ where
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "report_summary", ctx.tenant_id, uuid::Uuid::nil(), ctx.user_id);
|
||||
let sse_stream = build_sse_stream(
|
||||
stream,
|
||||
analysis_id_clone,
|
||||
state_clone,
|
||||
"report_summary",
|
||||
ctx.tenant_id,
|
||||
uuid::Uuid::nil(),
|
||||
ctx.user_id,
|
||||
);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -309,7 +356,12 @@ where
|
||||
};
|
||||
let (items, total) = state
|
||||
.analysis
|
||||
.list_analysis(ctx.tenant_id, params.patient_id, params.analysis_type, &pagination)
|
||||
.list_analysis(
|
||||
ctx.tenant_id,
|
||||
params.patient_id,
|
||||
params.analysis_type,
|
||||
&pagination,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 批量查询 patient_name(通过 raw SQL 避免跨 crate 依赖 erp-health)
|
||||
@@ -321,7 +373,10 @@ where
|
||||
|
||||
let patient_names: std::collections::HashMap<uuid::Uuid, String> = if !patient_ids.is_empty() {
|
||||
#[derive(sea_orm::FromQueryResult)]
|
||||
struct PatientName { id: uuid::Uuid, name: String }
|
||||
struct PatientName {
|
||||
id: uuid::Uuid,
|
||||
name: String,
|
||||
}
|
||||
let ids: Vec<uuid::Uuid> = patient_ids.into_iter().collect();
|
||||
use sea_orm::FromQueryResult;
|
||||
PatientName::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
@@ -339,15 +394,19 @@ where
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
let data: Vec<serde_json::Value> = items.into_iter().map(|a| {
|
||||
let mut val = serde_json::to_value(&a).unwrap_or_default();
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert("patient_name".to_string(), serde_json::json!(
|
||||
patient_names.get(&a.patient_id).cloned()
|
||||
));
|
||||
}
|
||||
val
|
||||
}).collect();
|
||||
let data: Vec<serde_json::Value> = items
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let mut val = serde_json::to_value(&a).unwrap_or_default();
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert(
|
||||
"patient_name".to_string(),
|
||||
serde_json::json!(patient_names.get(&a.patient_id).cloned()),
|
||||
);
|
||||
}
|
||||
val
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": data,
|
||||
@@ -549,7 +608,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
Ok(Json(ApiResponse::ok(state.provider_registry.provider_names())))
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state.provider_registry.provider_names(),
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn quota_summary<S>(
|
||||
@@ -570,7 +631,10 @@ where
|
||||
pub async fn assess_dialysis_risk<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<crate::service::dialysis_risk_scorer::DialysisLabInput>,
|
||||
) -> Result<Json<ApiResponse<crate::service::dialysis_risk_scorer::DialysisRiskAssessment>>, erp_core::error::AppError>
|
||||
) -> Result<
|
||||
Json<ApiResponse<crate::service::dialysis_risk_scorer::DialysisRiskAssessment>>,
|
||||
erp_core::error::AppError,
|
||||
>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
@@ -612,7 +676,9 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let model = params.model.unwrap_or_else(|| "claude-sonnet-4-6".to_string());
|
||||
let model = params
|
||||
.model
|
||||
.unwrap_or_else(|| "claude-sonnet-4-6".to_string());
|
||||
let estimate = crate::service::cost::CostService::estimate_cost(¶ms.analysis_type, &model);
|
||||
Ok(Json(ApiResponse::ok(estimate)))
|
||||
}
|
||||
@@ -699,9 +765,10 @@ fn validate_prompt_safety(content: &str) -> Result<(), erp_core::error::AppError
|
||||
let lower = content.to_lowercase();
|
||||
for pattern in &suspicious {
|
||||
if lower.contains(pattern) {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
format!("提示词内容包含不安全模式: {}", pattern),
|
||||
));
|
||||
return Err(erp_core::error::AppError::Validation(format!(
|
||||
"提示词内容包含不安全模式: {}",
|
||||
pattern
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
@@ -26,19 +26,14 @@ where
|
||||
require_permission(&ctx, "ai.suggestion.list")?;
|
||||
|
||||
if let Some(analysis_id) = params.analysis_id {
|
||||
let items = SuggestionService::list_by_analysis(
|
||||
&state.db,
|
||||
ctx.tenant_id,
|
||||
analysis_id,
|
||||
)
|
||||
.await?;
|
||||
let items =
|
||||
SuggestionService::list_by_analysis(&state.db, ctx.tenant_id, analysis_id).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": items.len(),
|
||||
}))))
|
||||
} else {
|
||||
let items =
|
||||
SuggestionService::list_pending(&state.db, ctx.tenant_id).await?;
|
||||
let items = SuggestionService::list_pending(&state.db, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": items.len(),
|
||||
@@ -69,7 +64,7 @@ where
|
||||
_ => {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"action 必须为 approve 或 reject".into(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,7 +146,10 @@ where
|
||||
|
||||
match &suggestion.baseline_snapshot {
|
||||
Some(bs) if !bs.is_null() => {
|
||||
let action_result = suggestion.action_result.as_ref().unwrap_or(&serde_json::Value::Null);
|
||||
let action_result = suggestion
|
||||
.action_result
|
||||
.as_ref()
|
||||
.unwrap_or(&serde_json::Value::Null);
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"suggestion_id": id,
|
||||
"baseline": bs,
|
||||
|
||||
@@ -156,13 +156,16 @@ impl KnowledgeSource for StructuredKnowledgeSource {
|
||||
|
||||
async fn health_check(&self) -> AiResult<bool> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
#[allow(dead_code)]
|
||||
struct HealthCheck {
|
||||
#[allow(dead_code)]
|
||||
ok: i32,
|
||||
}
|
||||
|
||||
let result: Option<HealthCheck> = HealthCheck::find_by_statement(
|
||||
Statement::from_string(sea_orm::DatabaseBackend::Postgres, "SELECT 1 AS ok".to_string()),
|
||||
)
|
||||
let result: Option<HealthCheck> = HealthCheck::find_by_statement(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT 1 AS ok".to_string(),
|
||||
))
|
||||
.one(&self.db)
|
||||
.await
|
||||
.ok()
|
||||
@@ -180,13 +183,14 @@ mod tests {
|
||||
let rules_empty: Vec<ai_knowledge_rules::Model> = vec![];
|
||||
let refs_empty: Vec<ai_knowledge_references::Model> = vec![];
|
||||
let guides_empty: Vec<ai_knowledge_guides::Model> = vec![];
|
||||
let confidence: f32 = if rules_empty.is_empty() && refs_empty.is_empty() && guides_empty.is_empty() {
|
||||
0.0
|
||||
} else if !rules_empty.is_empty() && !refs_empty.is_empty() {
|
||||
0.9
|
||||
} else {
|
||||
0.7
|
||||
};
|
||||
let confidence: f32 =
|
||||
if rules_empty.is_empty() && refs_empty.is_empty() && guides_empty.is_empty() {
|
||||
0.0
|
||||
} else if !rules_empty.is_empty() && !refs_empty.is_empty() {
|
||||
0.9
|
||||
} else {
|
||||
0.7
|
||||
};
|
||||
assert!((confidence - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,18 +88,28 @@ impl ErpModule for AiModule {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(event) if event.event_type == "ai.reanalysis.requested" => {
|
||||
let suggestion_id = event.payload.get("original_suggestion_id")
|
||||
let suggestion_id = event
|
||||
.payload
|
||||
.get("original_suggestion_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
let patient_id = event
|
||||
.payload
|
||||
.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
match (suggestion_id, patient_id) {
|
||||
(Some(sid), Some(pid)) => {
|
||||
if let Err(e) = crate::service::reanalysis::handle_reanalysis_requested(
|
||||
&db, event.tenant_id, sid, pid,
|
||||
).await {
|
||||
if let Err(e) =
|
||||
crate::service::reanalysis::handle_reanalysis_requested(
|
||||
&db,
|
||||
event.tenant_id,
|
||||
sid,
|
||||
pid,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
suggestion_id = %sid,
|
||||
error = %e,
|
||||
@@ -114,10 +124,14 @@ impl ErpModule for AiModule {
|
||||
}
|
||||
Some(event) if event.event_type == "ai.analysis.requested" => {
|
||||
let source_type = event.payload.get("source_type").and_then(|v| v.as_str());
|
||||
let source_id = event.payload.get("source_id")
|
||||
let source_id = event
|
||||
.payload
|
||||
.get("source_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
let patient_id = event
|
||||
.payload
|
||||
.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
@@ -131,10 +145,14 @@ impl ErpModule for AiModule {
|
||||
}
|
||||
// H4: 透析记录→KDIGO 自动风险评估
|
||||
Some(event) if event.event_type == "ai.dialysis.kdigo_requested" => {
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
let patient_id = event
|
||||
.payload
|
||||
.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
let record_id = event.payload.get("dialysis_record_id")
|
||||
let record_id = event
|
||||
.payload
|
||||
.get("dialysis_record_id")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
tracing::info!(
|
||||
|
||||
@@ -8,6 +8,12 @@ pub struct PromptRenderer {
|
||||
registry: Handlebars<'static>,
|
||||
}
|
||||
|
||||
impl Default for PromptRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptRenderer {
|
||||
pub fn new() -> Self {
|
||||
let mut registry = Handlebars::new();
|
||||
@@ -43,11 +49,12 @@ mod tests {
|
||||
#[test]
|
||||
fn render_multiple_variables() {
|
||||
let r = renderer();
|
||||
let result = r.render(
|
||||
"{{age_group}} {{sex}} 化验报告",
|
||||
&json!({"age_group": "中年", "sex": "男性"}),
|
||||
)
|
||||
.unwrap();
|
||||
let result = r
|
||||
.render(
|
||||
"{{age_group}} {{sex}} 化验报告",
|
||||
&json!({"age_group": "中年", "sex": "男性"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, "中年 男性 化验报告");
|
||||
}
|
||||
|
||||
@@ -57,7 +64,9 @@ mod tests {
|
||||
let data = json!({
|
||||
"report": { "date": "2026-05-01", "department": "内科" }
|
||||
});
|
||||
let result = r.render("科室: {{report.department}},日期: {{report.date}}", &data).unwrap();
|
||||
let result = r
|
||||
.render("科室: {{report.department}},日期: {{report.date}}", &data)
|
||||
.unwrap();
|
||||
assert_eq!(result, "科室: 内科,日期: 2026-05-01");
|
||||
}
|
||||
|
||||
@@ -65,7 +74,9 @@ mod tests {
|
||||
fn render_with_array_iteration() {
|
||||
let r = renderer();
|
||||
let data = json!({"items": ["WBC", "HGB", "PLT"]});
|
||||
let result = r.render("指标: {{#each items}}{{this}}, {{/each}}", &data).unwrap();
|
||||
let result = r
|
||||
.render("指标: {{#each items}}{{this}}, {{/each}}", &data)
|
||||
.unwrap();
|
||||
assert_eq!(result, "指标: WBC, HGB, PLT, ");
|
||||
}
|
||||
|
||||
@@ -98,11 +109,12 @@ mod tests {
|
||||
fn render_with_conditional() {
|
||||
let r = renderer();
|
||||
let data = json!({"is_abnormal": true, "value": "偏高"});
|
||||
let result = r.render(
|
||||
"{{#if is_abnormal}}异常: {{value}}{{else}}正常{{/if}}",
|
||||
&data,
|
||||
)
|
||||
.unwrap();
|
||||
let result = r
|
||||
.render(
|
||||
"{{#if is_abnormal}}异常: {{value}}{{else}}正常{{/if}}",
|
||||
&data,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, "异常: 偏高");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,15 +129,12 @@ impl AiProvider for ClaudeProvider {
|
||||
if data == "[DONE]" {
|
||||
return;
|
||||
}
|
||||
if let Ok(event) = serde_json::from_str::<ClaudeStreamEvent>(data) {
|
||||
if event.event_type == "content_block_delta" {
|
||||
if let Some(delta) = event.delta {
|
||||
if let Some(text) = delta.text {
|
||||
if let Ok(event) = serde_json::from_str::<ClaudeStreamEvent>(data)
|
||||
&& event.event_type == "content_block_delta"
|
||||
&& let Some(delta) = event.delta
|
||||
&& let Some(text) = delta.text {
|
||||
yield Ok(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,9 +176,7 @@ impl AiProvider for ClaudeProvider {
|
||||
.map_err(|e| AiError::ProviderError(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AiError::ProviderError(format!(
|
||||
"Claude {status}: {body}"
|
||||
)));
|
||||
return Err(AiError::ProviderError(format!("Claude {status}: {body}")));
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&body)
|
||||
|
||||
@@ -75,8 +75,10 @@ struct OllamaStreamChunk {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OllamaStreamMessage {
|
||||
content: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
thinking: Option<String>,
|
||||
}
|
||||
|
||||
@@ -87,7 +89,10 @@ fn strip_think_block(content: &str) -> String {
|
||||
if let Some(end) = content.find("</think") {
|
||||
// 跳过 </think 标签及其后的 > 或 \n
|
||||
let after_tag = &content[end + 7..]; // skip "</think"
|
||||
let actual = after_tag.trim_start_matches('\n').trim_start_matches('>').trim_start();
|
||||
let actual = after_tag
|
||||
.trim_start_matches('\n')
|
||||
.trim_start_matches('>')
|
||||
.trim_start();
|
||||
return actual.to_string();
|
||||
}
|
||||
content.to_string()
|
||||
@@ -193,13 +198,11 @@ impl AiProvider for OllamaProvider {
|
||||
if chunk.done {
|
||||
return;
|
||||
}
|
||||
if let Some(msg) = chunk.message {
|
||||
if let Some(content) = msg.content {
|
||||
if !content.is_empty() {
|
||||
if let Some(msg) = chunk.message
|
||||
&& let Some(content) = msg.content
|
||||
&& !content.is_empty() {
|
||||
yield Ok(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,9 +255,7 @@ impl AiProvider for OllamaProvider {
|
||||
.map_err(|e| AiError::ProviderError(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AiError::ProviderError(format!(
|
||||
"Ollama {status}: {body}"
|
||||
)));
|
||||
return Err(AiError::ProviderError(format!("Ollama {status}: {body}")));
|
||||
}
|
||||
|
||||
let parsed: OllamaChatResponse = serde_json::from_str(&body)
|
||||
@@ -307,10 +308,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ollama_provider_construction() {
|
||||
let provider = OllamaProvider::new(
|
||||
"http://localhost:11434".into(),
|
||||
"qwen2.5:7b".into(),
|
||||
);
|
||||
let provider = OllamaProvider::new("http://localhost:11434".into(), "qwen2.5:7b".into());
|
||||
assert_eq!(provider.name(), "ollama");
|
||||
assert_eq!(provider.default_model, "qwen2.5:7b");
|
||||
}
|
||||
@@ -367,10 +365,7 @@ mod tests {
|
||||
}"#;
|
||||
let chunk: OllamaStreamChunk = serde_json::from_str(json).unwrap();
|
||||
assert!(!chunk.done);
|
||||
assert_eq!(
|
||||
chunk.message.unwrap().content,
|
||||
Some("Hello".to_string())
|
||||
);
|
||||
assert_eq!(chunk.message.unwrap().content, Some("Hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -388,10 +383,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn base_url_preserved() {
|
||||
let provider = OllamaProvider::new(
|
||||
"http://192.168.1.100:11434".into(),
|
||||
"llama3.1:8b".into(),
|
||||
);
|
||||
let provider =
|
||||
OllamaProvider::new("http://192.168.1.100:11434".into(), "llama3.1:8b".into());
|
||||
assert_eq!(provider.base_url, "http://192.168.1.100:11434");
|
||||
}
|
||||
|
||||
|
||||
@@ -202,9 +202,7 @@ impl AiProvider for OpenAIProvider {
|
||||
.map_err(|e| AiError::ProviderError(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AiError::ProviderError(format!(
|
||||
"OpenAI {status}: {body}"
|
||||
)));
|
||||
return Err(AiError::ProviderError(format!("OpenAI {status}: {body}")));
|
||||
}
|
||||
|
||||
let parsed: ChatResponse = serde_json::from_str(&body)
|
||||
|
||||
@@ -9,9 +9,17 @@ use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ProviderHealth {
|
||||
Healthy { last_check: DateTime<Utc> },
|
||||
Degraded { last_check: DateTime<Utc>, error: String },
|
||||
Unavailable { since: DateTime<Utc>, error: String },
|
||||
Healthy {
|
||||
last_check: DateTime<Utc>,
|
||||
},
|
||||
Degraded {
|
||||
last_check: DateTime<Utc>,
|
||||
error: String,
|
||||
},
|
||||
Unavailable {
|
||||
since: DateTime<Utc>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProviderHealth {
|
||||
@@ -29,6 +37,12 @@ pub struct ProviderRegistry {
|
||||
entries: DashMap<String, ProviderEntry>,
|
||||
}
|
||||
|
||||
impl Default for ProviderRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -40,18 +54,19 @@ impl ProviderRegistry {
|
||||
let health = Arc::new(RwLock::new(ProviderHealth::Healthy {
|
||||
last_check: Utc::now(),
|
||||
}));
|
||||
self.entries.insert(name, ProviderEntry { provider, health });
|
||||
self.entries
|
||||
.insert(name, ProviderEntry { provider, health });
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, preferred: &str) -> crate::error::AiResult<ResolvedProvider> {
|
||||
// 1. 首选 Provider(实时健康检查)
|
||||
if let Some(entry) = self.entries.get(preferred) {
|
||||
if entry.provider.health_check().await.unwrap_or(false) {
|
||||
return Ok(ResolvedProvider {
|
||||
provider_name: preferred.to_string(),
|
||||
provider: entry.provider.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(entry) = self.entries.get(preferred)
|
||||
&& entry.provider.health_check().await.unwrap_or(false)
|
||||
{
|
||||
return Ok(ResolvedProvider {
|
||||
provider_name: preferred.to_string(),
|
||||
provider: entry.provider.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 任何可用 Provider
|
||||
@@ -72,7 +87,9 @@ impl ProviderRegistry {
|
||||
for entry in self.entries.iter() {
|
||||
let healthy = entry.value().provider.health_check().await.unwrap_or(false);
|
||||
let new_health = if healthy {
|
||||
ProviderHealth::Healthy { last_check: Utc::now() }
|
||||
ProviderHealth::Healthy {
|
||||
last_check: Utc::now(),
|
||||
}
|
||||
} else {
|
||||
ProviderHealth::Unavailable {
|
||||
since: Utc::now(),
|
||||
@@ -96,14 +113,22 @@ pub struct ResolvedProvider {
|
||||
}
|
||||
|
||||
impl ResolvedProvider {
|
||||
pub fn provider_name(&self) -> &str { &self.provider_name }
|
||||
pub fn provider(&self) -> &dyn AiProvider { self.provider.as_ref() }
|
||||
pub fn into_arc(self) -> Arc<dyn AiProvider> { self.provider }
|
||||
pub fn provider_name(&self) -> &str {
|
||||
&self.provider_name
|
||||
}
|
||||
pub fn provider(&self) -> &dyn AiProvider {
|
||||
self.provider.as_ref()
|
||||
}
|
||||
pub fn into_arc(self) -> Arc<dyn AiProvider> {
|
||||
self.provider
|
||||
}
|
||||
}
|
||||
|
||||
// === 测试桩 ===
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct MockProvider {
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
healthy: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
@@ -113,13 +138,18 @@ impl AiProvider for MockProvider {
|
||||
async fn stream_generate(
|
||||
&self,
|
||||
_req: crate::dto::GenerateRequest,
|
||||
) -> crate::error::AiResult<std::pin::Pin<Box<dyn futures::Stream<Item = crate::error::AiResult<String>> + Send>>> {
|
||||
) -> crate::error::AiResult<
|
||||
std::pin::Pin<Box<dyn futures::Stream<Item = crate::error::AiResult<String>> + Send>>,
|
||||
> {
|
||||
// 简单返回一个空流
|
||||
let s = async_stream::stream! { yield Ok("mock".to_string()); };
|
||||
Ok(Box::pin(s))
|
||||
}
|
||||
|
||||
async fn generate(&self, _req: crate::dto::GenerateRequest) -> crate::error::AiResult<crate::dto::GenerateResponse> {
|
||||
async fn generate(
|
||||
&self,
|
||||
_req: crate::dto::GenerateRequest,
|
||||
) -> crate::error::AiResult<crate::dto::GenerateResponse> {
|
||||
Ok(crate::dto::GenerateResponse {
|
||||
content: "mock".to_string(),
|
||||
model: "mock".to_string(),
|
||||
@@ -129,7 +159,9 @@ impl AiProvider for MockProvider {
|
||||
})
|
||||
}
|
||||
|
||||
fn name(&self) -> &str { &self.name }
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> crate::error::AiResult<bool> {
|
||||
Ok(self.healthy.load(std::sync::atomic::Ordering::Relaxed))
|
||||
|
||||
@@ -10,6 +10,12 @@ use crate::error::{AiError, AiResult};
|
||||
/// 此服务做二次检查和安全约束注入
|
||||
pub struct SanitizationService;
|
||||
|
||||
impl Default for SanitizationService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SanitizationService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
@@ -77,8 +83,8 @@ impl SanitizationService {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use erp_core::health_provider::{
|
||||
HealthReportDto, LabReportDto, PatientSummaryDto, ReportSectionDto,
|
||||
VitalSignDto, LabItemDto,
|
||||
HealthReportDto, LabItemDto, LabReportDto, PatientSummaryDto, ReportSectionDto,
|
||||
VitalSignDto,
|
||||
};
|
||||
|
||||
fn sanitizer() -> SanitizationService {
|
||||
@@ -172,13 +178,24 @@ mod tests {
|
||||
#[test]
|
||||
fn verify_no_pii_detects_all_pii_keys() {
|
||||
let svc = sanitizer();
|
||||
let pii_keys = ["name", "phone", "id_number", "address", "birth_date", "email"];
|
||||
let pii_keys = [
|
||||
"name",
|
||||
"phone",
|
||||
"id_number",
|
||||
"address",
|
||||
"birth_date",
|
||||
"email",
|
||||
];
|
||||
for key in pii_keys {
|
||||
let mut report_json = serde_json::to_value(&clean_lab_report()).unwrap();
|
||||
report_json[key] = serde_json::json!("test");
|
||||
let report: LabReportDto = serde_json::from_value(report_json).unwrap();
|
||||
let result = svc.sanitize_lab_report(&report);
|
||||
assert!(result.is_ok(), "LabReportDto 不包含 {} 字段,反序列化时被丢弃", key);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"LabReportDto 不包含 {} 字段,反序列化时被丢弃",
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +205,19 @@ mod tests {
|
||||
fn dto_serialization_contains_no_pii() {
|
||||
let report = clean_lab_report();
|
||||
let val = serde_json::to_value(&report).unwrap();
|
||||
for key in &["name", "phone", "id_number", "address", "birth_date", "email"] {
|
||||
assert!(!val.as_object().unwrap().contains_key(*key),
|
||||
"LabReportDto 不应包含 PII 字段: {}", key);
|
||||
for key in &[
|
||||
"name",
|
||||
"phone",
|
||||
"id_number",
|
||||
"address",
|
||||
"birth_date",
|
||||
"email",
|
||||
] {
|
||||
assert!(
|
||||
!val.as_object().unwrap().contains_key(*key),
|
||||
"LabReportDto 不应包含 PII 字段: {}",
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use erp_core::types::Pagination;
|
||||
use futures::Stream;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
|
||||
QuerySelect, Set,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::pin::Pin;
|
||||
use uuid::Uuid;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
use crate::dto::{AnalysisType, GenerateRequest};
|
||||
use crate::entity::ai_analysis;
|
||||
@@ -38,6 +41,7 @@ impl AnalysisService {
|
||||
}
|
||||
|
||||
/// 执行流式分析 — 返回 SSE 事件流
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn stream_analyze(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
@@ -64,7 +68,10 @@ impl AnalysisService {
|
||||
if let Some(cached) = self.find_cached(tenant_id, &input_hash, 1).await? {
|
||||
tracing::info!(analysis = %cached.id, "AI 分析缓存命中,复用已有结果");
|
||||
let content = cached.result_content.clone().unwrap_or_default();
|
||||
let metadata = cached.result_metadata.clone().unwrap_or(serde_json::json!({}));
|
||||
let metadata = cached
|
||||
.result_metadata
|
||||
.clone()
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
let stream = self.replay_cached(content, metadata);
|
||||
return Ok((stream, cached.id, provider_name));
|
||||
}
|
||||
@@ -86,7 +93,10 @@ impl AnalysisService {
|
||||
confidence = ctx.confidence,
|
||||
"知识库上下文注入"
|
||||
);
|
||||
format!("{}\n\n=== 知识库参考 ===\n{}", system_prompt, ctx.context_text)
|
||||
format!(
|
||||
"{}\n\n=== 知识库参考 ===\n{}",
|
||||
system_prompt, ctx.context_text
|
||||
)
|
||||
}
|
||||
Ok(_) => system_prompt,
|
||||
Err(e) => {
|
||||
@@ -234,11 +244,7 @@ impl AnalysisService {
|
||||
}
|
||||
|
||||
/// 获取单条分析记录
|
||||
pub async fn get_analysis(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_analysis::Model> {
|
||||
pub async fn get_analysis(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_analysis::Model> {
|
||||
let model = ai_analysis::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
@@ -249,6 +255,7 @@ impl AnalysisService {
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_analysis_record(
|
||||
&self,
|
||||
id: Uuid,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QueryOrder, Set, Statement};
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, FromQueryResult, Set, Statement};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_analysis_queue;
|
||||
use crate::error::{AiError, AiResult};
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
#[allow(dead_code)]
|
||||
struct QueueRow {
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
@@ -88,7 +89,10 @@ impl AnalysisQueue {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn claim_next(&self, tenant_id: Option<Uuid>) -> AiResult<Option<ai_analysis_queue::Model>> {
|
||||
pub async fn claim_next(
|
||||
&self,
|
||||
tenant_id: Option<Uuid>,
|
||||
) -> AiResult<Option<ai_analysis_queue::Model>> {
|
||||
let sql = match tenant_id {
|
||||
Some(tid) => format!(
|
||||
"SELECT * FROM ai_analysis_queue WHERE tenant_id = '{}' AND status = 'pending' AND deleted_at IS NULL AND scheduled_at <= NOW() ORDER BY priority DESC, scheduled_at ASC LIMIT 1",
|
||||
@@ -101,19 +105,22 @@ impl AnalysisQueue {
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY priority DESC, scheduled_at ASC
|
||||
LIMIT 1
|
||||
"#.to_string(),
|
||||
"#
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let row: Option<QueueRow> = QueueRow::find_by_statement(
|
||||
Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.to_string()),
|
||||
)
|
||||
let row: Option<QueueRow> = QueueRow::find_by_statement(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql.to_string(),
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let now = chrono::Utc::now();
|
||||
let mut active: ai_analysis_queue::ActiveModel = self.find_by_id(r.id).await?.into();
|
||||
let mut active: ai_analysis_queue::ActiveModel =
|
||||
self.find_by_id(r.id).await?.into();
|
||||
active.status = Set("running".to_string());
|
||||
active.started_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
@@ -125,11 +132,7 @@ impl AnalysisQueue {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_completed(
|
||||
&self,
|
||||
id: Uuid,
|
||||
result_analysis_id: Uuid,
|
||||
) -> AiResult<()> {
|
||||
pub async fn mark_completed(&self, id: Uuid, result_analysis_id: Uuid) -> AiResult<()> {
|
||||
let job = self.find_by_id(id).await?;
|
||||
let now = chrono::Utc::now();
|
||||
let mut active: ai_analysis_queue::ActiveModel = job.into();
|
||||
@@ -179,15 +182,14 @@ impl AnalysisQueue {
|
||||
GROUP BY status
|
||||
"#;
|
||||
|
||||
let rows: Vec<StatusCount> = StatusCount::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
let rows: Vec<StatusCount> =
|
||||
StatusCount::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
))
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
let mut pending = 0i64;
|
||||
let mut running = 0i64;
|
||||
|
||||
@@ -50,19 +50,13 @@ async fn run_auto_analysis(state: &AiState) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
total_analyzed,
|
||||
total_errors,
|
||||
"自动趋势分析任务完成"
|
||||
);
|
||||
tracing::info!(total_analyzed, total_errors, "自动趋势分析任务完成");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查找所有活跃租户 ID
|
||||
async fn find_active_tenants(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> Result<Vec<Uuid>, String> {
|
||||
async fn find_active_tenants(db: &sea_orm::DatabaseConnection) -> Result<Vec<Uuid>, String> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct TenantId {
|
||||
id: Uuid,
|
||||
|
||||
@@ -16,7 +16,12 @@ pub struct CacheKey {
|
||||
}
|
||||
|
||||
impl CacheKey {
|
||||
pub fn new(tenant_id: Uuid, analysis_type: &str, input: &serde_json::Value, prompt_version: i32) -> Self {
|
||||
pub fn new(
|
||||
tenant_id: Uuid,
|
||||
analysis_type: &str,
|
||||
input: &serde_json::Value,
|
||||
prompt_version: i32,
|
||||
) -> Self {
|
||||
let canonical = serde_json::to_string(input).unwrap_or_default();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(canonical.as_bytes());
|
||||
@@ -54,8 +59,16 @@ pub struct CacheService {
|
||||
}
|
||||
|
||||
impl CacheService {
|
||||
pub fn new(redis: redis::Client, db: sea_orm::DatabaseConnection, default_ttl: Duration) -> Self {
|
||||
Self { redis, db, default_ttl }
|
||||
pub fn new(
|
||||
redis: redis::Client,
|
||||
db: sea_orm::DatabaseConnection,
|
||||
default_ttl: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
redis,
|
||||
db,
|
||||
default_ttl,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self, key: &CacheKey) -> AiResult<Option<CachedAnalysis>> {
|
||||
@@ -127,12 +140,13 @@ impl CacheService {
|
||||
let data: Option<String> = conn.get(key).await?;
|
||||
match data {
|
||||
Some(json) => {
|
||||
let cached: CachedAnalysis = serde_json::from_str(&json)
|
||||
.map_err(|e| redis::RedisError::from((
|
||||
let cached: CachedAnalysis = serde_json::from_str(&json).map_err(|e| {
|
||||
redis::RedisError::from((
|
||||
redis::ErrorKind::TypeError,
|
||||
"反序列化失败",
|
||||
e.to_string(),
|
||||
)))?;
|
||||
))
|
||||
})?;
|
||||
Ok(Some(cached))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -141,12 +155,10 @@ impl CacheService {
|
||||
|
||||
async fn try_redis_set(&self, key: &str, value: &CachedAnalysis) -> redis::RedisResult<()> {
|
||||
let mut conn = self.redis.get_multiplexed_async_connection().await?;
|
||||
let json = serde_json::to_string(value).map_err(|e| redis::RedisError::from((
|
||||
redis::ErrorKind::TypeError,
|
||||
"序列化失败",
|
||||
e.to_string(),
|
||||
)))?;
|
||||
let (): () = conn.set_ex(key, json, self.default_ttl.as_secs() as u64).await?;
|
||||
let json = serde_json::to_string(value).map_err(|e| {
|
||||
redis::RedisError::from((redis::ErrorKind::TypeError, "序列化失败", e.to_string()))
|
||||
})?;
|
||||
let (): () = conn.set_ex(key, json, self.default_ttl.as_secs()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -155,15 +167,14 @@ impl CacheService {
|
||||
let mut count = 0u64;
|
||||
let mut cursor: u64 = 0;
|
||||
loop {
|
||||
let (new_cursor, keys): (u64, Vec<String>) =
|
||||
redis::cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg(pattern)
|
||||
.arg("COUNT")
|
||||
.arg(100)
|
||||
.query_async(&mut conn)
|
||||
.await?;
|
||||
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg(pattern)
|
||||
.arg("COUNT")
|
||||
.arg(100)
|
||||
.query_async(&mut conn)
|
||||
.await?;
|
||||
if !keys.is_empty() {
|
||||
let del_count: u64 = conn.del(&keys).await?;
|
||||
count += del_count;
|
||||
|
||||
@@ -38,32 +38,35 @@ pub fn generate_comparison(
|
||||
// 提取可比较的数值指标
|
||||
if let (Some(b_obj), Some(c_obj)) = (baseline.as_object(), current.as_object()) {
|
||||
for key in b_obj.keys() {
|
||||
if let (Some(b_val), Some(c_val)) = (b_obj.get(key), c_obj.get(key)) {
|
||||
if let (Some(b_num), Some(c_num)) = (b_val.as_f64(), c_val.as_f64()) {
|
||||
let change_pct = if b_num.abs() > 0.0001 {
|
||||
((c_num - b_num) / b_num.abs()) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let trend = if change_pct.abs() > 5.0 {
|
||||
TrendDirection::Worsening
|
||||
} else {
|
||||
TrendDirection::Stable
|
||||
};
|
||||
changes.push(MetricChange {
|
||||
metric: key.clone(),
|
||||
baseline_value: b_num,
|
||||
current_value: c_num,
|
||||
change_percent: change_pct,
|
||||
trend,
|
||||
});
|
||||
}
|
||||
if let (Some(b_val), Some(c_val)) = (b_obj.get(key), c_obj.get(key))
|
||||
&& let (Some(b_num), Some(c_num)) = (b_val.as_f64(), c_val.as_f64())
|
||||
{
|
||||
let change_pct = if b_num.abs() > 0.0001 {
|
||||
((c_num - b_num) / b_num.abs()) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let trend = if change_pct.abs() > 5.0 {
|
||||
TrendDirection::Worsening
|
||||
} else {
|
||||
TrendDirection::Stable
|
||||
};
|
||||
changes.push(MetricChange {
|
||||
metric: key.clone(),
|
||||
baseline_value: b_num,
|
||||
current_value: c_num,
|
||||
change_percent: change_pct,
|
||||
trend,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 综合趋势判断
|
||||
let changed = changes.iter().filter(|c| c.trend == TrendDirection::Worsening).count();
|
||||
let changed = changes
|
||||
.iter()
|
||||
.filter(|c| c.trend == TrendDirection::Worsening)
|
||||
.count();
|
||||
let overall = if changed > 0 {
|
||||
TrendDirection::Worsening
|
||||
} else {
|
||||
|
||||
@@ -74,9 +74,8 @@ impl CostService {
|
||||
pub fn estimate_cost(analysis_type: &str, model: &str) -> CostEstimate {
|
||||
let (input_tokens, output_tokens) = default_token_estimate(analysis_type);
|
||||
let (input_cost, output_cost) = model_cost_per_million(model);
|
||||
let estimated_cost_usd =
|
||||
(input_tokens as f64 * input_cost / 1_000_000.0)
|
||||
+ (output_tokens as f64 * output_cost / 1_000_000.0);
|
||||
let estimated_cost_usd = (input_tokens as f64 * input_cost / 1_000_000.0)
|
||||
+ (output_tokens as f64 * output_cost / 1_000_000.0);
|
||||
|
||||
CostEstimate {
|
||||
analysis_type: analysis_type.to_string(),
|
||||
@@ -143,13 +142,11 @@ impl CostService {
|
||||
AND created_at >= DATE_TRUNC('month', CURRENT_DATE)
|
||||
"#;
|
||||
|
||||
let row: Option<TokenSum> = TokenSum::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
let row: Option<TokenSum> = TokenSum::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
@@ -179,7 +176,10 @@ mod tests {
|
||||
#[test]
|
||||
fn budget_warning_levels() {
|
||||
assert_eq!(BudgetWarningLevel::Normal, BudgetWarningLevel::Normal);
|
||||
assert!(matches!(BudgetWarningLevel::Exceeded, BudgetWarningLevel::Exceeded));
|
||||
assert!(matches!(
|
||||
BudgetWarningLevel::Exceeded,
|
||||
BudgetWarningLevel::Exceeded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion};
|
||||
use crate::dto::suggestion::{RiskLevel, StructuredSuggestion, SuggestionType};
|
||||
use crate::service::local_rules::{CompareOp, LocalRule, LocalRulesEngine};
|
||||
|
||||
/// 透析患者实验室指标输入
|
||||
@@ -73,6 +73,12 @@ pub struct DialysisRiskScorer {
|
||||
engine: LocalRulesEngine,
|
||||
}
|
||||
|
||||
impl Default for DialysisRiskScorer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DialysisRiskScorer {
|
||||
pub fn new() -> Self {
|
||||
let rules = vec![
|
||||
@@ -113,8 +119,7 @@ impl DialysisRiskScorer {
|
||||
threshold: 7.0,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "血磷={value}mg/dL严重偏高(>7.0),需紧急评估钙磷代谢"
|
||||
.into(),
|
||||
message_template: "血磷={value}mg/dL严重偏高(>7.0),需紧急评估钙磷代谢".into(),
|
||||
},
|
||||
// 透前血钾 > 6.0 mEq/L:危急高钾
|
||||
LocalRule {
|
||||
@@ -161,8 +166,7 @@ impl DialysisRiskScorer {
|
||||
threshold: 5.0,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "透析间期体重增长{value}%(>5%干体重),容量超负荷风险高"
|
||||
.into(),
|
||||
message_template: "透析间期体重增长{value}%(>5%干体重),容量超负荷风险高".into(),
|
||||
},
|
||||
// 体重增长 > 3.5%:需关注
|
||||
LocalRule {
|
||||
@@ -199,8 +203,7 @@ impl DialysisRiskScorer {
|
||||
threshold: 3.0,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "白蛋白={value}g/dL严重偏低(<3.0),营养不良增加死亡风险"
|
||||
.into(),
|
||||
message_template: "白蛋白={value}g/dL严重偏低(<3.0),营养不良增加死亡风险".into(),
|
||||
},
|
||||
];
|
||||
Self {
|
||||
@@ -221,17 +224,14 @@ impl DialysisRiskScorer {
|
||||
|
||||
let suggestions = self.engine.evaluate(&metrics);
|
||||
|
||||
let mut risk_factors: Vec<String> = suggestions
|
||||
.iter()
|
||||
.map(|s| s.reason.clone())
|
||||
.collect();
|
||||
let mut risk_factors: Vec<String> = suggestions.iter().map(|s| s.reason.clone()).collect();
|
||||
|
||||
let kdigo_stage = input.egfr.map(KdigoStage::from_egfr);
|
||||
|
||||
if let Some(stage) = kdigo_stage {
|
||||
if matches!(stage, KdigoStage::G4 | KdigoStage::G5) {
|
||||
risk_factors.push(format!("KDIGO分期{},肾功能严重受损", stage.label()));
|
||||
}
|
||||
if let Some(stage) = kdigo_stage
|
||||
&& matches!(stage, KdigoStage::G4 | KdigoStage::G5)
|
||||
{
|
||||
risk_factors.push(format!("KDIGO分期{},肾功能严重受损", stage.label()));
|
||||
}
|
||||
|
||||
let overall_risk = if suggestions.iter().any(|s| s.priority == 1) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion};
|
||||
use crate::dto::suggestion::{RiskLevel, StructuredSuggestion, SuggestionType};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRule {
|
||||
@@ -120,9 +120,7 @@ impl LocalRulesEngine {
|
||||
RiskLevel::Medium => "2周内".into(),
|
||||
RiskLevel::Low => "1个月内".into(),
|
||||
},
|
||||
reason: rule
|
||||
.message_template
|
||||
.replace("{value}", &value.to_string()),
|
||||
reason: rule.message_template.replace("{value}", &value.to_string()),
|
||||
params: serde_json::json!({
|
||||
"metric": rule.metric,
|
||||
"value": value,
|
||||
@@ -156,7 +154,8 @@ mod tests {
|
||||
#[test]
|
||||
fn evaluate_all_normal_no_suggestions() {
|
||||
let rules = LocalRulesEngine::default_rules();
|
||||
let metrics = serde_json::json!({"systolic_bp": 120.0, "heart_rate": 72.0, "blood_sugar": 5.5});
|
||||
let metrics =
|
||||
serde_json::json!({"systolic_bp": 120.0, "heart_rate": 72.0, "blood_sugar": 5.5});
|
||||
let suggestions = rules.evaluate(&metrics);
|
||||
assert!(suggestions.is_empty());
|
||||
}
|
||||
@@ -166,9 +165,11 @@ mod tests {
|
||||
let rules = LocalRulesEngine::default_rules();
|
||||
let metrics = serde_json::json!({"heart_rate": 110.0});
|
||||
let suggestions = rules.evaluate(&metrics);
|
||||
assert!(suggestions
|
||||
.iter()
|
||||
.any(|s| s.suggestion_type == SuggestionType::Followup));
|
||||
assert!(
|
||||
suggestions
|
||||
.iter()
|
||||
.any(|s| s.suggestion_type == SuggestionType::Followup)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,11 +11,10 @@ pub fn parse_dual_channel(raw: &str) -> AiResult<ParsedOutput> {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER)
|
||||
.and_then(|json_str| {
|
||||
let parsed: Result<StructuredOutput, _> = serde_json::from_str(json_str.trim());
|
||||
parsed.ok()
|
||||
});
|
||||
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER).and_then(|json_str| {
|
||||
let parsed: Result<StructuredOutput, _> = serde_json::from_str(json_str.trim());
|
||||
parsed.ok()
|
||||
});
|
||||
|
||||
Ok(ParsedOutput {
|
||||
text_content,
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct PostProcessResult {
|
||||
}
|
||||
|
||||
/// 对完成的分析执行后处理:解析双通道输出、创建建议、发布事件
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn post_process_analysis(
|
||||
state: &AiState,
|
||||
analysis_id: Uuid,
|
||||
|
||||
@@ -34,6 +34,7 @@ impl PromptService {
|
||||
}
|
||||
|
||||
/// 新建 Prompt
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_prompt(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
@@ -95,6 +96,7 @@ impl PromptService {
|
||||
}
|
||||
|
||||
/// 更新 Prompt(创建新版本)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
@@ -122,7 +124,9 @@ impl PromptService {
|
||||
name: Set(entity.name.clone()),
|
||||
description: Set(description.unwrap_or(entity.description.clone())),
|
||||
system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.clone())),
|
||||
user_prompt_template: Set(user_prompt_template.unwrap_or(entity.user_prompt_template.clone())),
|
||||
user_prompt_template: Set(
|
||||
user_prompt_template.unwrap_or(entity.user_prompt_template.clone())
|
||||
),
|
||||
variables_schema: Set(entity.variables_schema.clone()),
|
||||
model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
|
||||
version: Set(entity.version + 1),
|
||||
@@ -140,11 +144,7 @@ impl PromptService {
|
||||
}
|
||||
|
||||
/// 激活指定 Prompt(停用同 name+category 的其他版本)
|
||||
pub async fn activate_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
@@ -179,11 +179,7 @@ impl PromptService {
|
||||
}
|
||||
|
||||
/// 回滚(= 激活指定旧版本)
|
||||
pub async fn rollback_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
self.activate_prompt(id, tenant_id).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,7 @@ impl QuotaService {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn check_quota(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Option<Uuid>,
|
||||
) -> AiResult<()> {
|
||||
pub async fn check_quota(&self, tenant_id: Uuid, patient_id: Option<Uuid>) -> AiResult<()> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -81,24 +77,18 @@ impl QuotaService {
|
||||
AND created_at >= date_trunc('month', CURRENT_DATE)
|
||||
"#;
|
||||
|
||||
let result: Option<TokenSum> = TokenSum::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
let result: Option<TokenSum> = TokenSum::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| r.total_tokens).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn get_daily_patient_count(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
) -> AiResult<i64> {
|
||||
async fn get_daily_patient_count(&self, tenant_id: Uuid, patient_id: Uuid) -> AiResult<i64> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct CountResult {
|
||||
count: i64,
|
||||
@@ -113,23 +103,19 @@ impl QuotaService {
|
||||
AND created_at >= CURRENT_DATE
|
||||
"#;
|
||||
|
||||
let result: Option<CountResult> = CountResult::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
let result: Option<CountResult> =
|
||||
CountResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into(), patient_id.into()],
|
||||
),
|
||||
)
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
pub async fn get_usage_summary(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<QuotaSummary> {
|
||||
pub async fn get_usage_summary(&self, tenant_id: Uuid) -> AiResult<QuotaSummary> {
|
||||
let config = self.get_tenant_config(tenant_id).await?;
|
||||
let budget = config
|
||||
.as_ref()
|
||||
@@ -142,10 +128,7 @@ impl QuotaService {
|
||||
tenant_id,
|
||||
monthly_budget: budget,
|
||||
monthly_used: used,
|
||||
daily_patient_limit: config
|
||||
.as_ref()
|
||||
.map(|c| c.daily_patient_limit)
|
||||
.unwrap_or(50),
|
||||
daily_patient_limit: config.as_ref().map(|c| c.daily_patient_limit).unwrap_or(50),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,15 +21,14 @@ pub async fn handle_reanalysis_requested(
|
||||
FROM ai_suggestion
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
"#;
|
||||
let original: Option<OriginalSuggestion> = OriginalSuggestion::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
let original: Option<OriginalSuggestion> =
|
||||
OriginalSuggestion::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[original_suggestion_id.into(), tenant_id.into()],
|
||||
),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
match original {
|
||||
Some(orig) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use uuid::Uuid;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use erp_core::error::AppResult;
|
||||
use crate::dto::suggestion::*;
|
||||
use crate::entity::ai_suggestion;
|
||||
use erp_core::error::AppResult;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SuggestionService;
|
||||
|
||||
@@ -85,9 +85,7 @@ impl SuggestionService {
|
||||
.filter(ai_suggestion::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::error::AiError::AnalysisNotFound("建议不存在".into())
|
||||
})?;
|
||||
.ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
|
||||
|
||||
let current_status = parse_status(&item.status);
|
||||
if !current_status.can_transition_to(new_status) {
|
||||
@@ -122,13 +120,14 @@ impl SuggestionService {
|
||||
.filter(ai_suggestion::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::error::AiError::AnalysisNotFound("建议不存在".into())
|
||||
})?;
|
||||
.ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
|
||||
|
||||
let current_status = parse_status(&item.status);
|
||||
// 允许从 Pending 或 Approved 直接执行(护士可能跳过审批)
|
||||
if !matches!(current_status, SuggestionStatus::Pending | SuggestionStatus::Approved) {
|
||||
if !matches!(
|
||||
current_status,
|
||||
SuggestionStatus::Pending | SuggestionStatus::Approved
|
||||
) {
|
||||
return Err(crate::error::AiError::Validation(format!(
|
||||
"建议状态为 {},无法执行(需要 pending 或 approved)",
|
||||
current_status.as_str()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_analysis;
|
||||
@@ -14,6 +16,7 @@ impl UsageService {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn log_usage(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
|
||||
Reference in New Issue
Block a user