Compare commits
8 Commits
3c7b48b6f6
...
23c5bbdb40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23c5bbdb40 | ||
|
|
2ccf0801b7 | ||
|
|
86dbd74f3f | ||
|
|
0edb475638 | ||
|
|
a7526455b4 | ||
|
|
dda8be9079 | ||
|
|
af2484e63b | ||
|
|
10c28df152 |
31
crates/erp-ai/src/entity/ai_knowledge_bases.rs
Normal file
31
crates/erp-ai/src/entity/ai_knowledge_bases.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_knowledge_bases")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub kb_type: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub chunk_strategy: serde_json::Value,
|
||||
pub intent_keywords: serde_json::Value,
|
||||
pub embedding_model: Option<String>,
|
||||
pub is_enabled: bool,
|
||||
pub document_count: i32,
|
||||
pub chunk_count: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version_lock: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
34
crates/erp-ai/src/entity/ai_knowledge_chunks.rs
Normal file
34
crates/erp-ai/src/entity/ai_knowledge_chunks.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_knowledge_chunks")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub knowledge_base_id: Uuid,
|
||||
pub document_id: Uuid,
|
||||
pub chunk_index: i32,
|
||||
pub content: String,
|
||||
pub token_count: Option<i32>,
|
||||
// pgvector 字段 — SeaORM 不原生支持 vector 类型,查询时用 raw SQL
|
||||
#[sea_orm(ignore)]
|
||||
pub embedding: Option<Vec<f32>>,
|
||||
pub start_offset: Option<i32>,
|
||||
pub end_offset: Option<i32>,
|
||||
pub page_number: Option<i32>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub hit_count: i32,
|
||||
pub last_hit_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
36
crates/erp-ai/src/entity/ai_knowledge_documents.rs
Normal file
36
crates/erp-ai/src/entity/ai_knowledge_documents.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_knowledge_documents")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub knowledge_base_id: Uuid,
|
||||
pub title: String,
|
||||
pub doc_type: String,
|
||||
pub source_type: String,
|
||||
pub source_url: Option<String>,
|
||||
pub file_name: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub file_mime_type: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub status: String,
|
||||
pub chunk_count: i32,
|
||||
pub embedded_count: i32,
|
||||
pub error_message: Option<String>,
|
||||
pub processing_started_at: Option<DateTimeUtc>,
|
||||
pub processing_completed_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version_lock: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -3,6 +3,9 @@ pub mod ai_analysis_queue;
|
||||
pub mod ai_chat_message;
|
||||
pub mod ai_chat_session;
|
||||
pub mod ai_feature_flags;
|
||||
pub mod ai_knowledge_bases;
|
||||
pub mod ai_knowledge_chunks;
|
||||
pub mod ai_knowledge_documents;
|
||||
pub mod ai_knowledge_guides;
|
||||
pub mod ai_knowledge_references;
|
||||
pub mod ai_knowledge_rules;
|
||||
|
||||
172
crates/erp-ai/src/handler/knowledge_v2_handler.rs
Normal file
172
crates/erp-ai/src/handler/knowledge_v2_handler.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
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;
|
||||
|
||||
use crate::service::knowledge_v2::{
|
||||
CreateKnowledgeBaseReq, ListKnowledgeBasesQuery, UpdateKnowledgeBaseReq,
|
||||
};
|
||||
use crate::state::AiState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListKnowledgeBasesParams {
|
||||
pub kb_type: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/knowledge-bases",
|
||||
responses((status = 200, description = "知识库列表")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn list_knowledge_bases<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
axum::extract::Query(params): axum::extract::Query<ListKnowledgeBasesParams>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.list")?;
|
||||
|
||||
let query = ListKnowledgeBasesQuery {
|
||||
kb_type: params.kb_type,
|
||||
is_enabled: params.is_enabled,
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let (items, total) = state.knowledge_v2.list(ctx.tenant_id, &query).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": query.page.unwrap_or(1),
|
||||
"page_size": query.page_size.unwrap_or(20),
|
||||
}))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/knowledge-bases/{id}",
|
||||
responses((status = 200, description = "知识库详情")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn get_knowledge_base<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.list")?;
|
||||
let kb = state.knowledge_v2.get_by_id(ctx.tenant_id, id).await?;
|
||||
Ok(Json(ApiResponse::ok(
|
||||
serde_json::to_value(&kb).unwrap_or_default(),
|
||||
)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/knowledge-bases",
|
||||
request_body = CreateKnowledgeBaseReq,
|
||||
responses((status = 200, description = "创建知识库")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn create_knowledge_base<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreateKnowledgeBaseReq>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
if body.name.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"知识库名称不能为空".into(),
|
||||
));
|
||||
}
|
||||
if body.kb_type.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"知识库类型不能为空".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let id = state
|
||||
.knowledge_v2
|
||||
.create(ctx.tenant_id, ctx.user_id, body)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/ai/knowledge-bases/{id}",
|
||||
request_body = UpdateKnowledgeBaseReq,
|
||||
responses((status = 200, description = "更新知识库")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn update_knowledge_base<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
Json(body): Json<UpdateKnowledgeBaseReq>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
if let Some(ref name) = body.name
|
||||
&& name.trim().is_empty()
|
||||
{
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"知识库名称不能为空".into(),
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.knowledge_v2
|
||||
.update(ctx.tenant_id, ctx.user_id, id, body)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/ai/knowledge-bases/{id}",
|
||||
responses((status = 200, description = "删除知识库")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn delete_knowledge_base<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
state.knowledge_v2.delete(ctx.tenant_id, id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod chat_handler;
|
||||
pub mod config_handler;
|
||||
pub mod insight_handler;
|
||||
pub mod knowledge_handler;
|
||||
pub mod knowledge_v2_handler;
|
||||
pub mod risk_handler;
|
||||
pub mod rule_handler;
|
||||
pub mod suggestion_handler;
|
||||
|
||||
@@ -588,6 +588,27 @@ impl AiModule {
|
||||
"/ai/knowledge/guides/{id}/re-embed",
|
||||
axum::routing::post(crate::handler::knowledge_handler::re_embed_guide),
|
||||
)
|
||||
// 知识库 V2 路由
|
||||
.route(
|
||||
"/ai/knowledge-bases",
|
||||
axum::routing::get(crate::handler::knowledge_v2_handler::list_knowledge_bases),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge-bases",
|
||||
axum::routing::post(crate::handler::knowledge_v2_handler::create_knowledge_base),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge-bases/{id}",
|
||||
axum::routing::get(crate::handler::knowledge_v2_handler::get_knowledge_base),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge-bases/{id}",
|
||||
axum::routing::put(crate::handler::knowledge_v2_handler::update_knowledge_base),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge-bases/{id}",
|
||||
axum::routing::delete(crate::handler::knowledge_v2_handler::delete_knowledge_base),
|
||||
)
|
||||
.route(
|
||||
"/ai/dialysis/risk-assessment",
|
||||
axum::routing::post(crate::handler::assess_dialysis_risk),
|
||||
|
||||
258
crates/erp-ai/src/service/knowledge_v2.rs
Normal file
258
crates/erp-ai/src/service/knowledge_v2.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use sea_orm::{
|
||||
ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_knowledge_bases;
|
||||
use crate::error::{AiError, AiResult};
|
||||
|
||||
// ─── DTO ───
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateKnowledgeBaseReq {
|
||||
pub name: String,
|
||||
pub kb_type: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub chunk_strategy: Option<serde_json::Value>,
|
||||
pub intent_keywords: Option<serde_json::Value>,
|
||||
pub embedding_model: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct UpdateKnowledgeBaseReq {
|
||||
pub name: Option<String>,
|
||||
pub kb_type: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub chunk_strategy: Option<serde_json::Value>,
|
||||
pub intent_keywords: Option<serde_json::Value>,
|
||||
pub embedding_model: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ListKnowledgeBasesQuery {
|
||||
pub kb_type: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
// ─── Service ───
|
||||
|
||||
pub struct KnowledgeV2Service {
|
||||
db: sea_orm::DatabaseConnection,
|
||||
}
|
||||
|
||||
impl KnowledgeV2Service {
|
||||
pub fn new(db: sea_orm::DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
query: &ListKnowledgeBasesQuery,
|
||||
) -> AiResult<(Vec<ai_knowledge_bases::Model>, u64)> {
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
|
||||
let mut find = ai_knowledge_bases::Entity::find()
|
||||
.filter(ai_knowledge_bases::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_knowledge_bases::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref kb_type) = query.kb_type {
|
||||
find = find.filter(ai_knowledge_bases::Column::KbType.eq(kb_type.as_str()));
|
||||
}
|
||||
if let Some(is_enabled) = query.is_enabled {
|
||||
find = find.filter(ai_knowledge_bases::Column::IsEnabled.eq(is_enabled));
|
||||
}
|
||||
|
||||
let paginator = find
|
||||
.order_by_desc(ai_knowledge_bases::Column::CreatedAt)
|
||||
.paginate(&self.db, page_size);
|
||||
|
||||
let total = paginator.num_items().await?;
|
||||
let items = paginator.fetch_page(page - 1).await?;
|
||||
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
pub async fn get_by_id(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> AiResult<ai_knowledge_bases::Model> {
|
||||
ai_knowledge_bases::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| AiError::KnowledgeError("知识库不存在".into()))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
req: CreateKnowledgeBaseReq,
|
||||
) -> AiResult<Uuid> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let active = ai_knowledge_bases::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(req.name),
|
||||
kb_type: Set(req.kb_type),
|
||||
description: Set(req.description),
|
||||
icon: Set(req.icon),
|
||||
chunk_strategy: Set(req.chunk_strategy.unwrap_or(
|
||||
serde_json::json!({"strategy": "auto", "chunk_size": 500, "overlap": 50}),
|
||||
)),
|
||||
intent_keywords: Set(req.intent_keywords.unwrap_or(serde_json::json!([]))),
|
||||
embedding_model: Set(req.embedding_model),
|
||||
is_enabled: Set(req.is_enabled.unwrap_or(true)),
|
||||
document_count: Set(0),
|
||||
chunk_count: Set(0),
|
||||
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),
|
||||
};
|
||||
|
||||
ai_knowledge_bases::Entity::insert(active)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
id: Uuid,
|
||||
req: UpdateKnowledgeBaseReq,
|
||||
) -> AiResult<()> {
|
||||
let existing = self.get_by_id(tenant_id, id).await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let active = ai_knowledge_bases::ActiveModel {
|
||||
id: Set(existing.id),
|
||||
tenant_id: Set(existing.tenant_id),
|
||||
name: Set(req.name.unwrap_or(existing.name)),
|
||||
kb_type: Set(req.kb_type.unwrap_or(existing.kb_type)),
|
||||
description: Set(req.description.or(existing.description)),
|
||||
icon: Set(req.icon.or(existing.icon)),
|
||||
chunk_strategy: Set(req.chunk_strategy.unwrap_or(existing.chunk_strategy)),
|
||||
intent_keywords: Set(req.intent_keywords.unwrap_or(existing.intent_keywords)),
|
||||
embedding_model: Set(req.embedding_model.or(existing.embedding_model)),
|
||||
is_enabled: Set(req.is_enabled.unwrap_or(existing.is_enabled)),
|
||||
document_count: Set(existing.document_count),
|
||||
chunk_count: Set(existing.chunk_count),
|
||||
created_at: Set(existing.created_at),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(existing.created_by),
|
||||
updated_by: Set(Some(user_id)),
|
||||
deleted_at: Set(existing.deleted_at),
|
||||
version_lock: Set(existing.version_lock + 1),
|
||||
};
|
||||
|
||||
ai_knowledge_bases::Entity::update(active)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(&self, tenant_id: Uuid, id: Uuid) -> AiResult<()> {
|
||||
let existing = self.get_by_id(tenant_id, id).await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let active = ai_knowledge_bases::ActiveModel {
|
||||
id: Set(existing.id),
|
||||
tenant_id: Set(existing.tenant_id),
|
||||
name: Set(existing.name),
|
||||
kb_type: Set(existing.kb_type),
|
||||
description: Set(existing.description),
|
||||
icon: Set(existing.icon),
|
||||
chunk_strategy: Set(existing.chunk_strategy),
|
||||
intent_keywords: Set(existing.intent_keywords),
|
||||
embedding_model: Set(existing.embedding_model),
|
||||
is_enabled: Set(existing.is_enabled),
|
||||
document_count: Set(existing.document_count),
|
||||
chunk_count: Set(existing.chunk_count),
|
||||
created_at: Set(existing.created_at),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(existing.created_by),
|
||||
updated_by: Set(existing.updated_by),
|
||||
deleted_at: Set(Some(now)),
|
||||
version_lock: Set(existing.version_lock + 1),
|
||||
};
|
||||
|
||||
ai_knowledge_bases::Entity::update(active)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 原子递增文档计数(用于文档上传成功后)
|
||||
pub async fn increment_document_count(&self, kb_id: Uuid, delta: i32) -> AiResult<()> {
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_bases
|
||||
SET document_count = document_count + $2,
|
||||
updated_at = $3,
|
||||
version_lock = version_lock + 1
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(kb_id),
|
||||
sea_orm::Value::from(delta),
|
||||
sea_orm::Value::from(chrono::Utc::now()),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 原子递增切片计数(用于切片生成后)
|
||||
pub async fn increment_chunk_count(&self, kb_id: Uuid, delta: i32) -> AiResult<()> {
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_bases
|
||||
SET chunk_count = chunk_count + $2,
|
||||
updated_at = $3,
|
||||
version_lock = version_lock + 1
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(kb_id),
|
||||
sea_orm::Value::from(delta),
|
||||
sea_orm::Value::from(chrono::Utc::now()),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod embedding;
|
||||
pub mod feature_flag_service;
|
||||
pub mod insight_service;
|
||||
pub mod knowledge;
|
||||
pub mod knowledge_v2;
|
||||
pub mod local_rules;
|
||||
pub mod output_parser;
|
||||
pub mod post_process;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::service::chat_session::ChatSessionService;
|
||||
use crate::service::feature_flag_service::FeatureFlagService;
|
||||
use crate::service::insight_service::InsightService;
|
||||
use crate::service::knowledge::KnowledgeService;
|
||||
use crate::service::knowledge_v2::KnowledgeV2Service;
|
||||
use crate::service::prompt::PromptService;
|
||||
use crate::service::quota::QuotaService;
|
||||
use crate::service::risk_service::RiskService;
|
||||
@@ -34,6 +35,7 @@ pub struct AiState {
|
||||
pub insight_service: Arc<InsightService>,
|
||||
pub feature_flags: Arc<FeatureFlagService>,
|
||||
pub knowledge: Arc<KnowledgeService>,
|
||||
pub knowledge_v2: Arc<KnowledgeV2Service>,
|
||||
pub chat_session: Arc<ChatSessionService>,
|
||||
pub chat_message: Arc<ChatMessageService>,
|
||||
}
|
||||
|
||||
@@ -172,6 +172,8 @@ mod m20260522_000162_seed_patient_miniprogram_permissions;
|
||||
mod m20260526_000163_points_rule_unique_event_type;
|
||||
mod m20260526_000164_ai_prompt_add_analysis_type;
|
||||
mod m20260526_000165_ai_prompt_fix_analysis_type;
|
||||
mod m20260526_000166_create_ai_knowledge_bases;
|
||||
mod m20260526_000167_create_ai_knowledge_documents;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -351,6 +353,8 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260526_000163_points_rule_unique_event_type::Migration),
|
||||
Box::new(m20260526_000164_ai_prompt_add_analysis_type::Migration),
|
||||
Box::new(m20260526_000165_ai_prompt_fix_analysis_type::Migration),
|
||||
Box::new(m20260526_000166_create_ai_knowledge_bases::Migration),
|
||||
Box::new(m20260526_000167_create_ai_knowledge_documents::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(Iden)]
|
||||
enum AiKnowledgeBases {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Name,
|
||||
KbType,
|
||||
Description,
|
||||
Icon,
|
||||
ChunkStrategy,
|
||||
IntentKeywords,
|
||||
EmbeddingModel,
|
||||
IsEnabled,
|
||||
DocumentCount,
|
||||
ChunkCount,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
VersionLock,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(AiKnowledgeBases::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeBases::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::Name)
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::KbType)
|
||||
.string_len(50)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeBases::Description).text())
|
||||
.col(ColumnDef::new(AiKnowledgeBases::Icon).string_len(50))
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::ChunkStrategy)
|
||||
.json_binary()
|
||||
.not_null()
|
||||
.default(Expr::cust("'{}'")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::IntentKeywords)
|
||||
.json_binary()
|
||||
.not_null()
|
||||
.default(Expr::cust("'[]'")),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeBases::EmbeddingModel).string_len(100))
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::IsEnabled)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::DocumentCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::ChunkCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeBases::CreatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeBases::UpdatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeBases::DeletedAt).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::VersionLock)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_kb_tenant")
|
||||
.table(AiKnowledgeBases::Table)
|
||||
.col(AiKnowledgeBases::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_kb_type")
|
||||
.table(AiKnowledgeBases::Table)
|
||||
.col(AiKnowledgeBases::TenantId)
|
||||
.col(AiKnowledgeBases::KbType)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(AiKnowledgeBases::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(Iden)]
|
||||
enum AiKnowledgeDocuments {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
KnowledgeBaseId,
|
||||
Title,
|
||||
DocType,
|
||||
SourceType,
|
||||
SourceUrl,
|
||||
FileName,
|
||||
FileSize,
|
||||
FileMimeType,
|
||||
Content,
|
||||
Status,
|
||||
ChunkCount,
|
||||
EmbeddedCount,
|
||||
ErrorMessage,
|
||||
ProcessingStartedAt,
|
||||
ProcessingCompletedAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
VersionLock,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum AiKnowledgeChunks {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
KnowledgeBaseId,
|
||||
DocumentId,
|
||||
ChunkIndex,
|
||||
Content,
|
||||
TokenCount,
|
||||
StartOffset,
|
||||
EndOffset,
|
||||
PageNumber,
|
||||
Metadata,
|
||||
HitCount,
|
||||
LastHitAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// ai_knowledge_documents
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(AiKnowledgeDocuments::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::TenantId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::KnowledgeBaseId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::Title)
|
||||
.string_len(500)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::DocType)
|
||||
.string_len(30)
|
||||
.not_null()
|
||||
.default("manual"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::SourceType)
|
||||
.string_len(30)
|
||||
.not_null()
|
||||
.default("manual"),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::SourceUrl).text())
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::FileName).string_len(500))
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::FileSize).big_integer())
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::FileMimeType).string_len(100))
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::Content).text())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::Status)
|
||||
.string_len(20)
|
||||
.not_null()
|
||||
.default("pending"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::ChunkCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::EmbeddedCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::ErrorMessage).text())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::ProcessingStartedAt)
|
||||
.timestamp_with_time_zone(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::ProcessingCompletedAt)
|
||||
.timestamp_with_time_zone(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::CreatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::UpdatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::DeletedAt).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::VersionLock)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_doc_kb")
|
||||
.table(AiKnowledgeDocuments::Table)
|
||||
.col(AiKnowledgeDocuments::KnowledgeBaseId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_doc_status")
|
||||
.table(AiKnowledgeDocuments::Table)
|
||||
.col(AiKnowledgeDocuments::KnowledgeBaseId)
|
||||
.col(AiKnowledgeDocuments::Status)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ai_knowledge_chunks
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(AiKnowledgeChunks::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::TenantId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::KnowledgeBaseId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::DocumentId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::ChunkIndex)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::Content).text().not_null())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::TokenCount).integer())
|
||||
// embedding is vector(1536) — added via raw SQL below
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::StartOffset).integer())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::EndOffset).integer())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::PageNumber).integer())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::Metadata)
|
||||
.json_binary()
|
||||
.not_null()
|
||||
.default(Expr::cust("'{}'")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::HitCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::LastHitAt).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::CreatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::UpdatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::DeletedAt).timestamp_with_time_zone())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Add embedding column as vector type (SeaORM doesn't support this natively)
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
"ALTER TABLE ai_knowledge_chunks ADD COLUMN IF NOT EXISTS embedding vector(1536)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// HNSW index for vector similarity search
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chunk_embedding ON ai_knowledge_chunks USING hnsw (embedding vector_cosine_ops)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_chunk_document")
|
||||
.table(AiKnowledgeChunks::Table)
|
||||
.col(AiKnowledgeChunks::DocumentId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_chunk_kb")
|
||||
.table(AiKnowledgeChunks::Table)
|
||||
.col(AiKnowledgeChunks::KnowledgeBaseId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(AiKnowledgeChunks::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(AiKnowledgeDocuments::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -616,6 +616,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
erp_ai::service::embedding::EmbeddingService::from_settings(&db).await,
|
||||
),
|
||||
)),
|
||||
knowledge_v2: std::sync::Arc::new(
|
||||
erp_ai::service::knowledge_v2::KnowledgeV2Service::new(db.clone()),
|
||||
),
|
||||
chat_session: std::sync::Arc::new(
|
||||
erp_ai::service::chat_session::ChatSessionService::new(db.clone()),
|
||||
),
|
||||
|
||||
1184
docs/superpowers/plans/2026-05-26-ai-knowledge-base-v2-plan.md
Normal file
1184
docs/superpowers/plans/2026-05-26-ai-knowledge-base-v2-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1224
docs/superpowers/specs/2026-05-26-ai-knowledge-base-v2-design.md
Normal file
1224
docs/superpowers/specs/2026-05-26-ai-knowledge-base-v2-design.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user