Compare commits
4 Commits
eb79424305
...
876308596a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
876308596a | ||
|
|
48d1a84c77 | ||
|
|
2a8c707f6d | ||
|
|
b2b64ec15d |
@@ -270,29 +270,180 @@ pub struct ListAnalysisQuery {
|
||||
}
|
||||
|
||||
pub async fn list_analysis<S>(
|
||||
State(_state): State<AiState>,
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<ListAnalysisQuery>,
|
||||
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
|
||||
Query(params): Query<ListAnalysisQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
let pagination = erp_core::types::Pagination {
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let (items, total) = state
|
||||
.analysis
|
||||
.list_analysis(ctx.tenant_id, params.patient_id, params.analysis_type, &pagination)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": pagination.page.unwrap_or(1),
|
||||
"page_size": pagination.limit(),
|
||||
}))))
|
||||
}
|
||||
|
||||
pub async fn get_analysis<S>(
|
||||
State(_state): State<AiState>,
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<crate::entity::ai_analysis::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
let analysis = state.analysis.get_analysis(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(analysis)))
|
||||
}
|
||||
|
||||
// === Prompt 管理 ===
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListPromptsQuery {
|
||||
pub category: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn list_prompts<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListPromptsQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.list")?;
|
||||
let pagination = erp_core::types::Pagination {
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let (items, total) = state
|
||||
.prompt
|
||||
.list_prompts(ctx.tenant_id, params.category, &pagination)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": pagination.page.unwrap_or(1),
|
||||
"page_size": pagination.limit(),
|
||||
}))))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePromptBody {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub system_prompt: String,
|
||||
pub user_prompt_template: String,
|
||||
pub model_config: serde_json::Value,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
pub async fn create_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreatePromptBody>,
|
||||
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state
|
||||
.prompt
|
||||
.create_prompt(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
body.name,
|
||||
body.system_prompt,
|
||||
body.user_prompt_template,
|
||||
body.model_config,
|
||||
body.category,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
pub async fn activate_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.activate_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
pub async fn rollback_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.rollback_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
// === 用量统计 ===
|
||||
|
||||
pub async fn usage_overview<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let overview = state.usage.get_overview(ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"total_count": overview.total_count,
|
||||
}))))
|
||||
}
|
||||
|
||||
pub async fn usage_by_type<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let types = state.usage.get_by_type(ctx.tenant_id).await?;
|
||||
let result: Vec<serde_json::Value> = types
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
serde_json::json!({
|
||||
"analysis_type": t.analysis_type,
|
||||
"count": t.count,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// === SSE 流构建辅助 ===
|
||||
|
||||
@@ -104,5 +104,29 @@ impl AiModule {
|
||||
"/ai/analysis/{id}",
|
||||
axum::routing::get(crate::handler::get_analysis),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts",
|
||||
axum::routing::get(crate::handler::list_prompts),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts",
|
||||
axum::routing::post(crate::handler::create_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts/{id}/activate",
|
||||
axum::routing::post(crate::handler::activate_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts/{id}/rollback",
|
||||
axum::routing::post(crate::handler::rollback_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/usage/overview",
|
||||
axum::routing::get(crate::handler::usage_overview),
|
||||
)
|
||||
.route(
|
||||
"/ai/usage/by-type",
|
||||
axum::routing::get(crate::handler::usage_by_type),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use futures::Stream;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, 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;
|
||||
@@ -142,6 +143,51 @@ impl AnalysisService {
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// 分页查询分析记录
|
||||
pub async fn list_analysis(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Option<Uuid>,
|
||||
analysis_type: Option<String>,
|
||||
pagination: &Pagination,
|
||||
) -> AiResult<(Vec<ai_analysis::Model>, u64)> {
|
||||
let mut query = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(pid) = patient_id {
|
||||
query = query.filter(ai_analysis::Column::PatientId.eq(pid));
|
||||
}
|
||||
if let Some(at) = &analysis_type {
|
||||
query = query.filter(ai_analysis::Column::AnalysisType.eq(at.as_str()));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let items = query
|
||||
.order_by_desc(ai_analysis::Column::CreatedAt)
|
||||
.offset(pagination.offset())
|
||||
.limit(pagination.limit())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
/// 获取单条分析记录
|
||||
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?
|
||||
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))?;
|
||||
if model.tenant_id != tenant_id {
|
||||
return Err(AiError::AnalysisNotFound(id.to_string()));
|
||||
}
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
async fn create_analysis_record(
|
||||
&self,
|
||||
id: Uuid,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use sea_orm::ActiveModelTrait;
|
||||
use sea_orm::QuerySelect;
|
||||
use sea_orm::Set;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_prompt;
|
||||
use crate::error::{AiError, AiResult};
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
pub struct PromptService {
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
@@ -64,4 +68,122 @@ impl PromptService {
|
||||
};
|
||||
Ok(active.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
/// 分页列出 Prompt 模板
|
||||
pub async fn list_prompts(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
category: Option<String>,
|
||||
pagination: &Pagination,
|
||||
) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
|
||||
let mut query = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(cat) = &category {
|
||||
query = query.filter(ai_prompt::Column::Category.eq(cat.as_str()));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let items = query
|
||||
.order_by_desc(ai_prompt::Column::UpdatedAt)
|
||||
.offset(pagination.offset())
|
||||
.limit(pagination.limit())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
/// 更新 Prompt(创建新版本)
|
||||
pub async fn update_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
system_prompt: Option<String>,
|
||||
user_prompt_template: Option<String>,
|
||||
model_config: Option<serde_json::Value>,
|
||||
description: Option<String>,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
let new_id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
let active = ai_prompt::ActiveModel {
|
||||
id: Set(new_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
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())),
|
||||
variables_schema: Set(entity.variables_schema.clone()),
|
||||
model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
|
||||
version: Set(entity.version + 1),
|
||||
is_active: Set(entity.is_active),
|
||||
category: Set(entity.category.clone()),
|
||||
tags: Set(entity.tags.clone()),
|
||||
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?)
|
||||
}
|
||||
|
||||
/// 激活指定 Prompt(停用同 name+category 的其他版本)
|
||||
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?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 停用同 name + category 的其他激活版本
|
||||
let siblings = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::Name.eq(&entity.name))
|
||||
.filter(ai_prompt::Column::Category.eq(&entity.category))
|
||||
.filter(ai_prompt::Column::IsActive.eq(true))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
for sibling in siblings {
|
||||
let mut active: ai_prompt::ActiveModel = sibling.into();
|
||||
active.is_active = Set(false);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.update(&self.db).await?;
|
||||
}
|
||||
|
||||
// 激活目标
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(true);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
Ok(active.update(&self.db).await?)
|
||||
}
|
||||
|
||||
/// 回滚(= 激活指定旧版本)
|
||||
pub async fn rollback_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
self.activate_prompt(id, tenant_id).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use sea_orm::ActiveModelTrait;
|
||||
use sea_orm::Set;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_analysis;
|
||||
use crate::entity::ai_usage;
|
||||
use crate::error::AiResult;
|
||||
|
||||
@@ -42,4 +42,46 @@ impl UsageService {
|
||||
};
|
||||
Ok(active.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
/// 用量概览
|
||||
pub async fn get_overview(&self, tenant_id: Uuid) -> AiResult<UsageOverview> {
|
||||
let result = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::Status.eq("completed"))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null())
|
||||
.select_only()
|
||||
.column_as(ai_analysis::Column::Id.count(), "total_count")
|
||||
.into_model::<UsageOverview>()
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.unwrap_or(UsageOverview { total_count: 0 });
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 按分析类型统计
|
||||
pub async fn get_by_type(&self, tenant_id: Uuid) -> AiResult<Vec<TypeCount>> {
|
||||
let result = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::Status.eq("completed"))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null())
|
||||
.select_only()
|
||||
.column(ai_analysis::Column::AnalysisType)
|
||||
.column_as(ai_analysis::Column::Id.count(), "count")
|
||||
.group_by(ai_analysis::Column::AnalysisType)
|
||||
.into_model::<TypeCount>()
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
pub struct UsageOverview {
|
||||
pub total_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
pub struct TypeCount {
|
||||
pub analysis_type: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user