diff --git a/crates/erp-ai/src/copilot/mod.rs b/crates/erp-ai/src/copilot/mod.rs new file mode 100644 index 0000000..a4b5b6c --- /dev/null +++ b/crates/erp-ai/src/copilot/mod.rs @@ -0,0 +1 @@ +pub mod rules; diff --git a/crates/erp-ai/src/copilot/rules.rs b/crates/erp-ai/src/copilot/rules.rs new file mode 100644 index 0000000..3fe59eb --- /dev/null +++ b/crates/erp-ai/src/copilot/rules.rs @@ -0,0 +1,201 @@ +use serde_json::Value; + +/// 评估 JSONLogic 表达式,支持子集:> >= < <= == != and or ! in var +/// 对畸形规则表达式返回 false 而非 panic(规则存储在数据库中,不应导致服务崩溃) +pub fn evaluate(expr: &Value, data: &Value) -> bool { + match expr { + Value::Object(map) => { + if let Some(op) = map.get(">") { + let args = match op.as_array() { + Some(a) if a.len() == 2 => a, + _ => return false, + }; + let a = resolve_value(&args[0], data); + let b = resolve_value(&args[1], data); + return compare_f64(&a, &b) == std::cmp::Ordering::Greater; + } + if let Some(op) = map.get(">=") { + let args = match op.as_array() { + Some(a) if a.len() == 2 => a, + _ => return false, + }; + let a = resolve_value(&args[0], data); + let b = resolve_value(&args[1], data); + return matches!( + compare_f64(&a, &b), + std::cmp::Ordering::Greater | std::cmp::Ordering::Equal + ); + } + if let Some(op) = map.get("<") { + let args = match op.as_array() { + Some(a) if a.len() == 2 => a, + _ => return false, + }; + let a = resolve_value(&args[0], data); + let b = resolve_value(&args[1], data); + return compare_f64(&a, &b) == std::cmp::Ordering::Less; + } + if let Some(op) = map.get("<=") { + let args = match op.as_array() { + Some(a) if a.len() == 2 => a, + _ => return false, + }; + let a = resolve_value(&args[0], data); + let b = resolve_value(&args[1], data); + return matches!( + compare_f64(&a, &b), + std::cmp::Ordering::Less | std::cmp::Ordering::Equal + ); + } + if let Some(op) = map.get("==") { + let args = match op.as_array() { + Some(a) if a.len() == 2 => a, + _ => return false, + }; + let a = resolve_value(&args[0], data); + let b = resolve_value(&args[1], data); + return a == b; + } + if let Some(op) = map.get("!=") { + let args = match op.as_array() { + Some(a) if a.len() == 2 => a, + _ => return false, + }; + let a = resolve_value(&args[0], data); + let b = resolve_value(&args[1], data); + return a != b; + } + if let Some(op) = map.get("and") { + return match op.as_array() { + Some(arr) => arr.iter().all(|e| evaluate(e, data)), + None => false, + }; + } + if let Some(op) = map.get("or") { + return match op.as_array() { + Some(arr) => arr.iter().any(|e| evaluate(e, data)), + None => false, + }; + } + if let Some(op) = map.get("!") { + return !evaluate(op, data); + } + if let Some(op) = map.get("in") { + let args = match op.as_array() { + Some(a) if a.len() == 2 => a, + _ => return false, + }; + let val = resolve_value(&args[0], data); + let collection = resolve_value(&args[1], data); + return match collection.as_array() { + Some(arr) => arr.contains(&val), + None => false, + }; + } + false + } + Value::Bool(b) => *b, + _ => false, + } +} + +/// 解析 {"var": "path.to.field"} 引用,支持点分路径 +fn resolve_value(expr: &Value, data: &Value) -> Value { + if let Value::Object(map) = expr + && let Some(var_path) = map.get("var").and_then(|v| v.as_str()) + { + return var_path.split('.').fold(data.clone(), |acc, key| { + acc.get(key).cloned().unwrap_or(Value::Null) + }); + } + expr.clone() +} + +fn compare_f64(a: &Value, b: &Value) -> std::cmp::Ordering { + let a_num = value_to_f64(a); + let b_num = value_to_f64(b); + a_num + .partial_cmp(&b_num) + .unwrap_or(std::cmp::Ordering::Equal) +} + +fn value_to_f64(v: &Value) -> f64 { + v.as_f64() + .or_else(|| v.as_i64().map(|n| n as f64)) + .unwrap_or(0.0) +} + +/// 规则数据:(id, name, condition_expr, score, severity, suggestion) +pub type RuleData = ( + uuid::Uuid, + String, + serde_json::Value, + i16, + String, + Option, +); + +/// 匹配结果:(id, name, score, severity, suggestion) +pub type MatchedRuleData = (uuid::Uuid, String, i16, String, Option); + +/// 对患者数据评估所有启用的规则,返回匹配的规则和总分 +pub fn evaluate_rules(rules: &[RuleData], patient_data: &Value) -> Vec { + rules + .iter() + .filter(|(_, _, cond, _, _, _)| evaluate(cond, patient_data)) + .map(|(id, name, _, score, severity, suggestion)| { + ( + *id, + name.clone(), + *score, + severity.clone(), + suggestion.clone(), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_comparison_gt() { + let expr = serde_json::json!({ ">": [{"var": "systolic"}, 140] }); + let data = serde_json::json!({"systolic": 155}); + assert!(evaluate(&expr, &data)); + } + + #[test] + fn test_simple_comparison_lt() { + let expr = serde_json::json!({ "<": [{"var": "egfr"}, 60] }); + let data = serde_json::json!({"egfr": 45}); + assert!(evaluate(&expr, &data)); + } + + #[test] + fn test_and_combination() { + let expr = serde_json::json!({ + "and": [ + { ">=": [{"var": "systolic.prev1"}, 140] }, + { ">=": [{"var": "systolic.prev2"}, 140] } + ] + }); + let data = serde_json::json!({"systolic": {"prev1": 145, "prev2": 150}}); + assert!(evaluate(&expr, &data)); + } + + #[test] + fn test_change_pct() { + let expr = serde_json::json!({ ">": [{"var": "creatinine.change_pct"}, 20] }); + let data = serde_json::json!({"creatinine": {"change_pct": 25}}); + assert!(evaluate(&expr, &data)); + } + + #[test] + fn test_not_matching() { + let expr = serde_json::json!({ "<": [{"var": "egfr"}, 60] }); + let data = serde_json::json!({"egfr": 75}); + assert!(!evaluate(&expr, &data)); + } +} diff --git a/crates/erp-ai/src/entity/copilot_chat_logs.rs b/crates/erp-ai/src/entity/copilot_chat_logs.rs new file mode 100644 index 0000000..7748587 --- /dev/null +++ b/crates/erp-ai/src/entity/copilot_chat_logs.rs @@ -0,0 +1,31 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "copilot_chat_logs")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub session_id: Uuid, + pub user_message: String, + pub intent_classification: Option, + pub ai_raw_response: Option, + pub layer1_result: Option, + pub layer2_result: Option, + pub violations_found: Option, + pub fix_strategy: Option, + pub final_response: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Option, + pub updated_by: Option, + pub deleted_at: Option, + pub version_lock: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-ai/src/entity/copilot_insights.rs b/crates/erp-ai/src/entity/copilot_insights.rs new file mode 100644 index 0000000..1fe7651 --- /dev/null +++ b/crates/erp-ai/src/entity/copilot_insights.rs @@ -0,0 +1,32 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "copilot_insights")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub insight_type: String, + pub source: String, + pub severity: Option, + pub title: String, + pub content: serde_json::Value, + pub rule_matches: Option, + pub llm_supplement: Option, + pub expires_at: DateTimeUtc, + pub is_read: bool, + pub is_dismissed: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Option, + pub updated_by: Option, + pub deleted_at: Option, + pub version_lock: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-ai/src/entity/copilot_risk_snapshots.rs b/crates/erp-ai/src/entity/copilot_risk_snapshots.rs new file mode 100644 index 0000000..8b25d19 --- /dev/null +++ b/crates/erp-ai/src/entity/copilot_risk_snapshots.rs @@ -0,0 +1,28 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "copilot_risk_snapshots")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub risk_score: i16, + pub risk_level: String, + pub rule_details: serde_json::Value, + pub llm_summary: Option, + pub computed_at: DateTimeUtc, + pub data_freshness: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Option, + pub updated_by: Option, + pub deleted_at: Option, + pub version_lock: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-ai/src/entity/copilot_rules.rs b/crates/erp-ai/src/entity/copilot_rules.rs new file mode 100644 index 0000000..a0f2b65 --- /dev/null +++ b/crates/erp-ai/src/entity/copilot_rules.rs @@ -0,0 +1,29 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "copilot_rules")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + pub category: String, + pub condition_expr: serde_json::Value, + pub score: i16, + pub severity: String, + pub suggestion: Option, + pub enabled: bool, + pub sort_order: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Option, + pub updated_by: Option, + pub deleted_at: Option, + pub version_lock: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-ai/src/entity/mod.rs b/crates/erp-ai/src/entity/mod.rs index ee3c490..d61b078 100644 --- a/crates/erp-ai/src/entity/mod.rs +++ b/crates/erp-ai/src/entity/mod.rs @@ -8,3 +8,7 @@ pub mod ai_risk_threshold; pub mod ai_suggestion; pub mod ai_tenant_config; pub mod ai_usage; +pub mod copilot_chat_logs; +pub mod copilot_insights; +pub mod copilot_risk_snapshots; +pub mod copilot_rules; diff --git a/crates/erp-ai/src/lib.rs b/crates/erp-ai/src/lib.rs index c0e0dfe..a4b460f 100644 --- a/crates/erp-ai/src/lib.rs +++ b/crates/erp-ai/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod copilot; pub mod dto; pub mod entity; pub mod error; diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 189e2d1..6561fe1 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -138,6 +138,11 @@ mod m20260510_000133_create_patient_role; mod m20260510_000134_create_media_folder; mod m20260510_000135_create_media_item; mod m20260510_000136_create_banner; +mod m20260510_000137_seed_media_banner_menus; +mod m20260512_000138_create_copilot_rules; +mod m20260512_000139_create_copilot_insights; +mod m20260512_000140_create_copilot_risk_snapshots; +mod m20260512_000141_create_copilot_chat_logs; pub struct Migrator; @@ -283,6 +288,11 @@ impl MigratorTrait for Migrator { Box::new(m20260510_000134_create_media_folder::Migration), Box::new(m20260510_000135_create_media_item::Migration), Box::new(m20260510_000136_create_banner::Migration), + Box::new(m20260510_000137_seed_media_banner_menus::Migration), + Box::new(m20260512_000138_create_copilot_rules::Migration), + Box::new(m20260512_000139_create_copilot_insights::Migration), + Box::new(m20260512_000140_create_copilot_risk_snapshots::Migration), + Box::new(m20260512_000141_create_copilot_chat_logs::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260512_000138_create_copilot_rules.rs b/crates/erp-server/migration/src/m20260512_000138_create_copilot_rules.rs new file mode 100644 index 0000000..23e59c4 --- /dev/null +++ b/crates/erp-server/migration/src/m20260512_000138_create_copilot_rules.rs @@ -0,0 +1,125 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CopilotRules::Table) + .col( + ColumnDef::new(CopilotRules::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(CopilotRules::TenantId).uuid().not_null()) + .col( + ColumnDef::new(CopilotRules::Name) + .string_len(200) + .not_null(), + ) + .col( + ColumnDef::new(CopilotRules::Category) + .string_len(50) + .not_null(), + ) + .col( + ColumnDef::new(CopilotRules::ConditionExpr) + .json() + .not_null(), + ) + .col( + ColumnDef::new(CopilotRules::Score) + .small_integer() + .not_null(), + ) + .col( + ColumnDef::new(CopilotRules::Severity) + .string_len(20) + .not_null(), + ) + .col(ColumnDef::new(CopilotRules::Suggestion).text().null()) + .col( + ColumnDef::new(CopilotRules::Enabled) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(CopilotRules::SortOrder) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(CopilotRules::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(CopilotRules::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(CopilotRules::CreatedBy).uuid().null()) + .col(ColumnDef::new(CopilotRules::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(CopilotRules::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(CopilotRules::VersionLock) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_copilot_rules_tenant_category") + .table(CopilotRules::Table) + .col(CopilotRules::TenantId) + .col(CopilotRules::Category) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CopilotRules::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum CopilotRules { + Table, + Id, + TenantId, + Name, + Category, + ConditionExpr, + Score, + Severity, + Suggestion, + Enabled, + SortOrder, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + VersionLock, +} diff --git a/crates/erp-server/migration/src/m20260512_000139_create_copilot_insights.rs b/crates/erp-server/migration/src/m20260512_000139_create_copilot_insights.rs new file mode 100644 index 0000000..f519c22 --- /dev/null +++ b/crates/erp-server/migration/src/m20260512_000139_create_copilot_insights.rs @@ -0,0 +1,141 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CopilotInsights::Table) + .col( + ColumnDef::new(CopilotInsights::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(CopilotInsights::TenantId).uuid().not_null()) + .col(ColumnDef::new(CopilotInsights::PatientId).uuid().not_null()) + .col( + ColumnDef::new(CopilotInsights::InsightType) + .string_len(50) + .not_null(), + ) + .col( + ColumnDef::new(CopilotInsights::Source) + .string_len(20) + .not_null(), + ) + .col( + ColumnDef::new(CopilotInsights::Severity) + .string_len(20) + .null(), + ) + .col( + ColumnDef::new(CopilotInsights::Title) + .string_len(500) + .not_null(), + ) + .col(ColumnDef::new(CopilotInsights::Content).json().not_null()) + .col(ColumnDef::new(CopilotInsights::RuleMatches).json().null()) + .col(ColumnDef::new(CopilotInsights::LlmSupplement).text().null()) + .col( + ColumnDef::new(CopilotInsights::ExpiresAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(CopilotInsights::IsRead) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(CopilotInsights::IsDismissed) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(CopilotInsights::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(CopilotInsights::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(CopilotInsights::CreatedBy).uuid().null()) + .col(ColumnDef::new(CopilotInsights::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(CopilotInsights::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(CopilotInsights::VersionLock) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_copilot_insights_tenant_patient") + .table(CopilotInsights::Table) + .col(CopilotInsights::TenantId) + .col(CopilotInsights::PatientId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_copilot_insights_expires") + .table(CopilotInsights::Table) + .col(CopilotInsights::ExpiresAt) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CopilotInsights::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum CopilotInsights { + Table, + Id, + TenantId, + PatientId, + InsightType, + Source, + Severity, + Title, + Content, + RuleMatches, + LlmSupplement, + ExpiresAt, + IsRead, + IsDismissed, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + VersionLock, +} diff --git a/crates/erp-server/migration/src/m20260512_000140_create_copilot_risk_snapshots.rs b/crates/erp-server/migration/src/m20260512_000140_create_copilot_risk_snapshots.rs new file mode 100644 index 0000000..71ce6d1 --- /dev/null +++ b/crates/erp-server/migration/src/m20260512_000140_create_copilot_risk_snapshots.rs @@ -0,0 +1,134 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CopilotRiskSnapshots::Table) + .col( + ColumnDef::new(CopilotRiskSnapshots::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::TenantId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::PatientId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::RiskScore) + .small_integer() + .not_null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::RiskLevel) + .string_len(20) + .not_null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::RuleDetails) + .json() + .not_null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::LlmSummary) + .text() + .null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::ComputedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::DataFreshness) + .json() + .null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::CreatedBy) + .uuid() + .null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::UpdatedBy) + .uuid() + .null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(CopilotRiskSnapshots::VersionLock) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_copilot_risk_snapshots_tenant_patient") + .table(CopilotRiskSnapshots::Table) + .col(CopilotRiskSnapshots::TenantId) + .col(CopilotRiskSnapshots::PatientId) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CopilotRiskSnapshots::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum CopilotRiskSnapshots { + Table, + Id, + TenantId, + PatientId, + RiskScore, + RiskLevel, + RuleDetails, + LlmSummary, + ComputedAt, + DataFreshness, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + VersionLock, +} diff --git a/crates/erp-server/migration/src/m20260512_000141_create_copilot_chat_logs.rs b/crates/erp-server/migration/src/m20260512_000141_create_copilot_chat_logs.rs new file mode 100644 index 0000000..b75fa11 --- /dev/null +++ b/crates/erp-server/migration/src/m20260512_000141_create_copilot_chat_logs.rs @@ -0,0 +1,130 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CopilotChatLogs::Table) + .col( + ColumnDef::new(CopilotChatLogs::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(CopilotChatLogs::TenantId).uuid().not_null()) + .col(ColumnDef::new(CopilotChatLogs::PatientId).uuid().not_null()) + .col(ColumnDef::new(CopilotChatLogs::SessionId).uuid().not_null()) + .col( + ColumnDef::new(CopilotChatLogs::UserMessage) + .text() + .not_null(), + ) + .col( + ColumnDef::new(CopilotChatLogs::IntentClassification) + .string_len(30) + .null(), + ) + .col(ColumnDef::new(CopilotChatLogs::AiRawResponse).text().null()) + .col(ColumnDef::new(CopilotChatLogs::Layer1Result).json().null()) + .col(ColumnDef::new(CopilotChatLogs::Layer2Result).json().null()) + .col( + ColumnDef::new(CopilotChatLogs::ViolationsFound) + .json() + .null(), + ) + .col( + ColumnDef::new(CopilotChatLogs::FixStrategy) + .string_len(30) + .null(), + ) + .col( + ColumnDef::new(CopilotChatLogs::FinalResponse) + .text() + .not_null(), + ) + .col( + ColumnDef::new(CopilotChatLogs::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(CopilotChatLogs::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(CopilotChatLogs::CreatedBy).uuid().null()) + .col(ColumnDef::new(CopilotChatLogs::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(CopilotChatLogs::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(CopilotChatLogs::VersionLock) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_copilot_chat_logs_session") + .table(CopilotChatLogs::Table) + .col(CopilotChatLogs::TenantId) + .col(CopilotChatLogs::SessionId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_copilot_chat_logs_patient") + .table(CopilotChatLogs::Table) + .col(CopilotChatLogs::TenantId) + .col(CopilotChatLogs::PatientId) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CopilotChatLogs::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum CopilotChatLogs { + Table, + Id, + TenantId, + PatientId, + SessionId, + UserMessage, + IntentClassification, + AiRawResponse, + Layer1Result, + Layer2Result, + ViolationsFound, + FixStrategy, + FinalResponse, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + VersionLock, +}