feat(ai): AnalysisService 核心编排 + PromptService + UsageService

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-25 13:57:23 +08:00
parent e0e4a7f9a1
commit 6d392ae2b5
5 changed files with 297 additions and 0 deletions

View File

@@ -4,5 +4,6 @@ pub mod error;
pub mod prompt;
pub mod provider;
pub mod sanitization;
pub mod service;
pub use error::{AiError, AiResult};

View File

@@ -0,0 +1,181 @@
use futures::Stream;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use sha2::{Digest, Sha256};
use std::pin::Pin;
use uuid::Uuid;
use crate::dto::{AnalysisType, GenerateRequest};
use crate::entity::ai_analysis;
use crate::error::{AiError, AiResult};
use crate::prompt::PromptRenderer;
use crate::provider::AiProvider;
use crate::sanitization::SanitizationService;
pub struct AnalysisService {
pub provider: Box<dyn AiProvider>,
pub sanitizer: SanitizationService,
pub renderer: PromptRenderer,
pub db: sea_orm::DatabaseConnection,
}
impl AnalysisService {
pub fn new(provider: Box<dyn AiProvider>, db: sea_orm::DatabaseConnection) -> Self {
Self {
provider,
sanitizer: SanitizationService::new(),
renderer: PromptRenderer::new(),
db,
}
}
/// 执行流式分析 — 返回 SSE 事件流
pub async fn stream_analyze(
&self,
tenant_id: Uuid,
_user_id: Uuid,
patient_id: Uuid,
analysis_type: AnalysisType,
source_ref: String,
system_prompt: String,
user_template: String,
sanitized_data: serde_json::Value,
model: String,
temperature: f32,
max_tokens: u32,
) -> AiResult<(
Pin<Box<dyn Stream<Item = AiResult<String>> + Send>>,
uuid::Uuid,
String,
)> {
let analysis_id = Uuid::now_v7();
let input_hash = self.compute_hash(&sanitized_data);
let provider_name = self.provider.name().to_string();
// 1. 渲染 Prompt
let user_prompt = self.renderer.render(&user_template, &sanitized_data)?;
// 2. 创建分析记录
self.create_analysis_record(
analysis_id,
tenant_id,
patient_id,
analysis_type.as_str(),
&source_ref,
&input_hash,
&provider_name,
&model,
)
.await?;
// 3. 调用 AI 流式生成
let req = GenerateRequest {
system_prompt,
user_prompt,
model,
temperature,
max_tokens,
};
let stream = self.provider.stream_generate(req).await?;
Ok((stream, analysis_id, provider_name))
}
/// 更新分析记录为完成
pub async fn complete_analysis(
&self,
analysis_id: Uuid,
content: String,
metadata: serde_json::Value,
) -> AiResult<()> {
let entity = ai_analysis::Entity::find_by_id(analysis_id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::AnalysisNotFound(analysis_id.to_string()))?;
let mut active: ai_analysis::ActiveModel = entity.into();
active.status = Set("completed".into());
active.result_content = Set(Some(content));
active.result_metadata = Set(Some(metadata));
active.updated_at = Set(chrono::Utc::now());
active.update(&self.db).await?;
Ok(())
}
/// 标记分析失败
pub async fn fail_analysis(&self, analysis_id: Uuid, error: String) -> AiResult<()> {
let entity = ai_analysis::Entity::find_by_id(analysis_id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::AnalysisNotFound(analysis_id.to_string()))?;
let mut active: ai_analysis::ActiveModel = entity.into();
active.status = Set("failed".into());
active.error_message = Set(Some(error));
active.updated_at = Set(chrono::Utc::now());
active.update(&self.db).await?;
Ok(())
}
/// 查找缓存
pub async fn find_cached(
&self,
tenant_id: Uuid,
input_hash: &str,
prompt_version: i32,
) -> AiResult<Option<ai_analysis::Model>> {
let result = ai_analysis::Entity::find()
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
.filter(ai_analysis::Column::InputDataHash.eq(input_hash))
.filter(ai_analysis::Column::PromptVersion.eq(prompt_version))
.filter(ai_analysis::Column::Status.eq("completed"))
.filter(ai_analysis::Column::DeletedAt.is_null())
.one(&self.db)
.await?;
Ok(result)
}
fn compute_hash(&self, data: &serde_json::Value) -> String {
let canonical = serde_json::to_string(data).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
hex::encode(hasher.finalize())
}
async fn create_analysis_record(
&self,
id: Uuid,
tenant_id: Uuid,
patient_id: Uuid,
analysis_type: &str,
source_ref: &str,
input_hash: &str,
_provider: &str,
model: &str,
) -> AiResult<()> {
let now = chrono::Utc::now();
let active = ai_analysis::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
analysis_type: Set(analysis_type.into()),
source_ref: Set(source_ref.into()),
prompt_id: Set(Uuid::nil()),
prompt_version: Set(1),
model_used: Set(model.into()),
input_data_hash: Set(input_hash.into()),
sanitized_input: Set(None),
result_content: Set(None),
result_metadata: Set(None),
status: Set("streaming".into()),
error_message: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(None),
updated_by: Set(None),
deleted_at: Set(None),
version_lock: Set(1),
};
active.insert(&self.db).await?;
Ok(())
}
}

View File

@@ -0,0 +1,3 @@
pub mod analysis;
pub mod prompt;
pub mod usage;

View File

@@ -0,0 +1,67 @@
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::entity::ai_prompt;
use crate::error::{AiError, AiResult};
pub struct PromptService {
pub db: sea_orm::DatabaseConnection,
}
impl PromptService {
pub fn new(db: sea_orm::DatabaseConnection) -> Self {
Self { db }
}
/// 获取当前激活的 Prompt 模板
pub async fn get_active_prompt(
&self,
tenant_id: Uuid,
name: &str,
) -> AiResult<ai_prompt::Model> {
ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::Name.eq(name))
.filter(ai_prompt::Column::IsActive.eq(true))
.filter(ai_prompt::Column::DeletedAt.is_null())
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(name.into()))
}
/// 新建 Prompt
pub async fn create_prompt(
&self,
tenant_id: Uuid,
user_id: Uuid,
name: String,
system_prompt: String,
user_prompt_template: String,
model_config: serde_json::Value,
category: String,
) -> AiResult<ai_prompt::Model> {
let id = Uuid::now_v7();
let now = chrono::Utc::now();
let active = ai_prompt::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(name),
description: Set(String::new()),
system_prompt: Set(system_prompt),
user_prompt_template: Set(user_prompt_template),
variables_schema: Set(None),
model_config: Set(model_config),
version: Set(1),
is_active: Set(true),
category: Set(category),
tags: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(Some(user_id)),
updated_by: Set(Some(user_id)),
deleted_at: Set(None),
version_lock: Set(1),
};
Ok(active.insert(&self.db).await?)
}
}

View File

@@ -0,0 +1,45 @@
use sea_orm::ActiveModelTrait;
use sea_orm::Set;
use uuid::Uuid;
use crate::entity::ai_usage;
use crate::error::AiResult;
pub struct UsageService {
pub db: sea_orm::DatabaseConnection,
}
impl UsageService {
pub fn new(db: sea_orm::DatabaseConnection) -> Self {
Self { db }
}
pub async fn log_usage(
&self,
tenant_id: Uuid,
provider: &str,
model: &str,
analysis_type: &str,
input_tokens: u32,
output_tokens: u32,
duration_ms: u64,
cost_cents: i32,
is_cache_hit: bool,
) -> AiResult<ai_usage::Model> {
let id = Uuid::now_v7();
let active = ai_usage::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
provider: Set(provider.into()),
model: Set(model.into()),
analysis_type: Set(analysis_type.into()),
input_tokens: Set(input_tokens as i32),
output_tokens: Set(output_tokens as i32),
duration_ms: Set(duration_ms as i32),
cost_cents: Set(cost_cents),
is_cache_hit: Set(is_cache_hit),
created_at: Set(chrono::Utc::now()),
};
Ok(active.insert(&self.db).await?)
}
}