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