fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

功能修复:
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:
iven
2026-05-07 23:43:14 +08:00
parent 786f57c151
commit 6d5a711d2c
323 changed files with 15662 additions and 6603 deletions

View File

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

View File

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

View File

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

View File

@@ -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(&params.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(())

View File

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

View File

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

View File

@@ -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!(

View File

@@ -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, "异常: 偏高");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ pub struct PostProcessResult {
}
/// 对完成的分析执行后处理:解析双通道输出、创建建议、发布事件
#[allow(clippy::too_many_arguments)]
pub async fn post_process_analysis(
state: &AiState,
analysis_id: Uuid,

View File

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

View File

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

View File

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

View File

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

View File

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