feat(ai): Prompt 管理 Phase 2 — analysis_type 后端选择键 + 筛选修复
- 新增 ai_prompt.analysis_type 列作为后端按链路选择 Prompt 的唯一键 - name 回归显示标识符用途,不再承担选择键角色 - 迁移 000164: 新增 analysis_type 列 + 从 name 回填 + 索引 - 迁移 000165: 修复旧数据从 category 错误回填的问题 - AiPromptList 页面重构: 分析类型/调用链路列、详情抽屉、新建表单 - DrawerForm 组件新增 onValuesChange 回调支持跨字段联动 - 新建表单选择分析类型后自动填充标识符 - 筛选过滤器改为按 analysis_type 而非 category 过滤(后端+前端同步) - 停用/激活/回滚/删除操作完整可用
This commit is contained in:
@@ -16,6 +16,8 @@ pub struct Model {
|
||||
pub version: i32,
|
||||
pub is_active: bool,
|
||||
pub category: String,
|
||||
/// 后端选择键:与 AnalysisType::prompt_name() 对应,handler 按此字段查找激活 Prompt
|
||||
pub analysis_type: String,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
|
||||
@@ -95,7 +95,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "lab_report_interpretation")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::LabReport.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -190,7 +190,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "health_trend_analysis")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::Trends.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -262,7 +262,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "personalized_checkup_plan")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::CheckupPlan.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -341,7 +341,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "report_summary_generation")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::ReportSummary.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -417,7 +417,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "follow_up_summary_generation")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::FollowUpSummary.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -577,6 +577,7 @@ where
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ListPromptsQuery {
|
||||
pub category: Option<String>,
|
||||
pub analysis_type: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
@@ -605,7 +606,11 @@ where
|
||||
};
|
||||
let (items, total) = state
|
||||
.prompt
|
||||
.list_prompts(ctx.tenant_id, params.category, &pagination)
|
||||
.list_prompts(
|
||||
ctx.tenant_id,
|
||||
params.analysis_type.or(params.category),
|
||||
&pagination,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
@@ -623,6 +628,7 @@ pub struct CreatePromptBody {
|
||||
pub user_prompt_template: String,
|
||||
pub model_config: serde_json::Value,
|
||||
pub category: String,
|
||||
pub analysis_type: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -655,6 +661,7 @@ where
|
||||
body.user_prompt_template,
|
||||
body.model_config,
|
||||
body.category,
|
||||
body.analysis_type,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
@@ -702,6 +709,48 @@ where
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/prompts/{id}/deactivate",
|
||||
responses((status = 200, description = "停用 Prompt 模板")),
|
||||
tag = "AI Prompt",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn deactivate_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.deactivate_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/ai/prompts/{id}",
|
||||
responses((status = 200, description = "删除 Prompt 模板")),
|
||||
tag = "AI Prompt",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn delete_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
state.prompt.delete_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
// === 用量统计 ===
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -511,6 +511,14 @@ impl AiModule {
|
||||
"/ai/prompts/{id}/rollback",
|
||||
axum::routing::post(crate::handler::rollback_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts/{id}/deactivate",
|
||||
axum::routing::post(crate::handler::deactivate_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts/{id}",
|
||||
axum::routing::delete(crate::handler::delete_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/usage/overview",
|
||||
axum::routing::get(crate::handler::usage_overview),
|
||||
|
||||
@@ -17,20 +17,20 @@ impl PromptService {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 获取当前激活的 Prompt 模板
|
||||
/// 获取当前激活的 Prompt 模板(按 analysis_type 查找)
|
||||
pub async fn get_active_prompt(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
name: &str,
|
||||
analysis_type: &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::AnalysisType.eq(analysis_type))
|
||||
.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()))
|
||||
.ok_or_else(|| AiError::PromptNotFound(analysis_type.into()))
|
||||
}
|
||||
|
||||
/// 新建 Prompt
|
||||
@@ -44,6 +44,7 @@ impl PromptService {
|
||||
user_prompt_template: String,
|
||||
model_config: serde_json::Value,
|
||||
category: String,
|
||||
analysis_type: String,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
@@ -59,6 +60,7 @@ impl PromptService {
|
||||
version: Set(1),
|
||||
is_active: Set(true),
|
||||
category: Set(category),
|
||||
analysis_type: Set(analysis_type),
|
||||
tags: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
@@ -74,15 +76,15 @@ impl PromptService {
|
||||
pub async fn list_prompts(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
category: Option<String>,
|
||||
analysis_type: 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()));
|
||||
if let Some(at) = &analysis_type {
|
||||
query = query.filter(ai_prompt::Column::AnalysisType.eq(at.as_str()));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
@@ -132,6 +134,7 @@ impl PromptService {
|
||||
version: Set(entity.version + 1),
|
||||
is_active: Set(entity.is_active),
|
||||
category: Set(entity.category.clone()),
|
||||
analysis_type: Set(entity.analysis_type.clone()),
|
||||
tags: Set(entity.tags.clone()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
@@ -143,7 +146,7 @@ impl PromptService {
|
||||
Ok(active.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
/// 激活指定 Prompt(停用同 name+category 的其他版本)
|
||||
/// 激活指定 Prompt(停用同 analysis_type 的其他版本,原子操作)
|
||||
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)
|
||||
@@ -154,25 +157,23 @@ impl PromptService {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 停用同 name + category 的其他激活版本
|
||||
let siblings = ai_prompt::Entity::find()
|
||||
// 原子操作:停用同 analysis_type 的其他版本
|
||||
ai_prompt::Entity::update_many()
|
||||
.col_expr(
|
||||
ai_prompt::Column::IsActive,
|
||||
sea_orm::sea_query::Expr::value(false),
|
||||
)
|
||||
.col_expr(
|
||||
ai_prompt::Column::UpdatedAt,
|
||||
sea_orm::sea_query::Expr::value(chrono::Utc::now()),
|
||||
)
|
||||
.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())
|
||||
.filter(ai_prompt::Column::AnalysisType.eq(&entity.analysis_type))
|
||||
.filter(ai_prompt::Column::Id.ne(id))
|
||||
.all(&self.db)
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.exec(&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.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
active.update(&self.db).await?;
|
||||
}
|
||||
|
||||
// 激活目标
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(true);
|
||||
@@ -185,4 +186,41 @@ impl PromptService {
|
||||
pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
self.activate_prompt(id, tenant_id).await
|
||||
}
|
||||
|
||||
/// 停用 Prompt
|
||||
pub async fn deactivate_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()));
|
||||
}
|
||||
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(false);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
Ok(active.update(&self.db).await?)
|
||||
}
|
||||
|
||||
/// 删除 Prompt(软删除)
|
||||
pub async fn delete_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<()> {
|
||||
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 mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.deleted_at = Set(Some(chrono::Utc::now()));
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
active.update(&self.db).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user