Compare commits
9 Commits
2fb0535164
...
4b3193fcd6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3193fcd6 | ||
|
|
415d7617c8 | ||
|
|
6e761ae22b | ||
|
|
b30897119b | ||
|
|
3b6f72d5c0 | ||
|
|
92e6cf0c43 | ||
|
|
9b8307fbba | ||
|
|
577d2a32b1 | ||
|
|
7789a5e227 |
219
crates/erp-ai/src/dto/mod.rs
Normal file
219
crates/erp-ai/src/dto/mod.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
pub mod suggestion;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// === 分析请求 ===
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalyzeRequest {
|
||||
pub analysis_type: AnalysisType,
|
||||
pub source_ref: String,
|
||||
pub options: AnalyzeOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AnalysisType {
|
||||
LabReport,
|
||||
Trends,
|
||||
CheckupPlan,
|
||||
ReportSummary,
|
||||
}
|
||||
|
||||
impl AnalysisType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::LabReport => "lab_report",
|
||||
Self::Trends => "trend",
|
||||
Self::CheckupPlan => "checkup_plan",
|
||||
Self::ReportSummary => "report_summary",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt_name(&self) -> &str {
|
||||
match self {
|
||||
Self::LabReport => "lab_report_interpretation",
|
||||
Self::Trends => "health_trend_analysis",
|
||||
Self::CheckupPlan => "personalized_checkup_plan",
|
||||
Self::ReportSummary => "report_summary_generation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalyzeOptions {
|
||||
pub detail_level: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AnalyzeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
detail_level: Some("patient_friendly".into()),
|
||||
language: Some("zh-CN".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === AI Provider 请求/响应 ===
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GenerateRequest {
|
||||
pub system_prompt: String,
|
||||
pub user_prompt: String,
|
||||
pub model: String,
|
||||
pub temperature: f32,
|
||||
pub max_tokens: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GenerateResponse {
|
||||
pub content: String,
|
||||
pub model: String,
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
// === SSE 事件 ===
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub input: u32,
|
||||
pub output: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AnalysisSseEvent {
|
||||
#[serde(rename = "chunk")]
|
||||
Chunk { content: String, index: u32 },
|
||||
#[serde(rename = "metadata")]
|
||||
Metadata {
|
||||
model: String,
|
||||
tokens: TokenUsage,
|
||||
duration_ms: u64,
|
||||
},
|
||||
#[serde(rename = "done")]
|
||||
Done {
|
||||
analysis_id: uuid::Uuid,
|
||||
status: String,
|
||||
},
|
||||
#[serde(rename = "error")]
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---- AnalysisType::as_str ----
|
||||
|
||||
#[test]
|
||||
fn analysis_type_as_str() {
|
||||
assert_eq!(AnalysisType::LabReport.as_str(), "lab_report");
|
||||
assert_eq!(AnalysisType::Trends.as_str(), "trend");
|
||||
assert_eq!(AnalysisType::CheckupPlan.as_str(), "checkup_plan");
|
||||
assert_eq!(AnalysisType::ReportSummary.as_str(), "report_summary");
|
||||
}
|
||||
|
||||
// ---- AnalysisType::prompt_name ----
|
||||
|
||||
#[test]
|
||||
fn analysis_type_prompt_name() {
|
||||
assert_eq!(AnalysisType::LabReport.prompt_name(), "lab_report_interpretation");
|
||||
assert_eq!(AnalysisType::Trends.prompt_name(), "health_trend_analysis");
|
||||
assert_eq!(AnalysisType::CheckupPlan.prompt_name(), "personalized_checkup_plan");
|
||||
assert_eq!(AnalysisType::ReportSummary.prompt_name(), "report_summary_generation");
|
||||
}
|
||||
|
||||
// ---- AnalysisType serde round-trip ----
|
||||
|
||||
#[test]
|
||||
fn analysis_type_serde_roundtrip() {
|
||||
let types = vec![
|
||||
AnalysisType::LabReport,
|
||||
AnalysisType::Trends,
|
||||
AnalysisType::CheckupPlan,
|
||||
AnalysisType::ReportSummary,
|
||||
];
|
||||
for t in types {
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
let back: AnalysisType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(t, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_type_deserialize_snake_case() {
|
||||
let t: AnalysisType = serde_json::from_str("\"lab_report\"").unwrap();
|
||||
assert_eq!(t, AnalysisType::LabReport);
|
||||
|
||||
let t: AnalysisType = serde_json::from_str("\"trends\"").unwrap();
|
||||
assert_eq!(t, AnalysisType::Trends);
|
||||
}
|
||||
|
||||
// ---- AnalyzeOptions::default ----
|
||||
|
||||
#[test]
|
||||
fn analyze_options_default() {
|
||||
let opts = AnalyzeOptions::default();
|
||||
assert_eq!(opts.detail_level, Some("patient_friendly".to_string()));
|
||||
assert_eq!(opts.language, Some("zh-CN".to_string()));
|
||||
}
|
||||
|
||||
// ---- AnalysisSseEvent serde round-trip ----
|
||||
|
||||
#[test]
|
||||
fn sse_event_chunk_roundtrip() {
|
||||
let event = AnalysisSseEvent::Chunk {
|
||||
content: "血红蛋白偏低".to_string(),
|
||||
index: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"type\":\"chunk\""));
|
||||
let back: AnalysisSseEvent = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
AnalysisSseEvent::Chunk { content, index } => {
|
||||
assert_eq!(content, "血红蛋白偏低");
|
||||
assert_eq!(index, 0);
|
||||
}
|
||||
_ => panic!("期望 Chunk 变体"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_event_done_roundtrip() {
|
||||
let id = {
|
||||
let ts = uuid::Timestamp::now(uuid::NoContext);
|
||||
uuid::Uuid::new_v7(ts)
|
||||
};
|
||||
let event = AnalysisSseEvent::Done {
|
||||
analysis_id: id,
|
||||
status: "completed".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let back: AnalysisSseEvent = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
AnalysisSseEvent::Done { analysis_id, status } => {
|
||||
assert_eq!(analysis_id, id);
|
||||
assert_eq!(status, "completed");
|
||||
}
|
||||
_ => panic!("期望 Done 变体"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_event_error_roundtrip() {
|
||||
let event = AnalysisSseEvent::Error {
|
||||
message: "超时".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"type\":\"error\""));
|
||||
let back: AnalysisSseEvent = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
AnalysisSseEvent::Error { message } => assert_eq!(message, "超时"),
|
||||
_ => panic!("期望 Error 变体"),
|
||||
}
|
||||
}
|
||||
}
|
||||
196
crates/erp-ai/src/dto/suggestion.rs
Normal file
196
crates/erp-ai/src/dto/suggestion.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 建议类型:随访 / 预约 / 预警
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SuggestionType {
|
||||
Followup,
|
||||
Appointment,
|
||||
Alert,
|
||||
}
|
||||
|
||||
impl SuggestionType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Followup => "followup",
|
||||
Self::Appointment => "appointment",
|
||||
Self::Alert => "alert",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险等级
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RiskLevel {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
impl RiskLevel {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
}
|
||||
}
|
||||
|
||||
/// 低风险可自动执行,其他需人工确认
|
||||
pub fn is_auto_executable(&self) -> bool {
|
||||
matches!(self, Self::Low)
|
||||
}
|
||||
}
|
||||
|
||||
/// 建议状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SuggestionStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
Executed,
|
||||
Expired,
|
||||
ParseFailed,
|
||||
}
|
||||
|
||||
impl SuggestionStatus {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Approved => "approved",
|
||||
Self::Rejected => "rejected",
|
||||
Self::Executed => "executed",
|
||||
Self::Expired => "expired",
|
||||
Self::ParseFailed => "parse_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 输出的单条结构化建议
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StructuredSuggestion {
|
||||
pub id: Option<Uuid>,
|
||||
#[serde(rename = "type")]
|
||||
pub suggestion_type: SuggestionType,
|
||||
pub priority: u32,
|
||||
pub timing: String,
|
||||
pub reason: String,
|
||||
pub params: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub auto_executable: bool,
|
||||
}
|
||||
|
||||
/// AI 双通道输出的结构化部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StructuredOutput {
|
||||
pub risk_level: RiskLevel,
|
||||
pub risk_factors: Vec<String>,
|
||||
pub suggestions: Vec<StructuredSuggestion>,
|
||||
pub baseline_summary: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 解析后的双通道结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedOutput {
|
||||
pub text_content: String,
|
||||
pub structured: Option<StructuredOutput>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn suggestion_type_serde_roundtrip() {
|
||||
let types = vec![
|
||||
SuggestionType::Followup,
|
||||
SuggestionType::Appointment,
|
||||
SuggestionType::Alert,
|
||||
];
|
||||
for t in types {
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
let back: SuggestionType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(t, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestion_type_as_str() {
|
||||
assert_eq!(SuggestionType::Followup.as_str(), "followup");
|
||||
assert_eq!(SuggestionType::Appointment.as_str(), "appointment");
|
||||
assert_eq!(SuggestionType::Alert.as_str(), "alert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_level_serde_roundtrip() {
|
||||
for level in [RiskLevel::Low, RiskLevel::Medium, RiskLevel::High] {
|
||||
let json = serde_json::to_string(&level).unwrap();
|
||||
let back: RiskLevel = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(level, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_level_auto_executable() {
|
||||
assert!(RiskLevel::Low.is_auto_executable());
|
||||
assert!(!RiskLevel::Medium.is_auto_executable());
|
||||
assert!(!RiskLevel::High.is_auto_executable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestion_status_serde_roundtrip() {
|
||||
let statuses = vec![
|
||||
SuggestionStatus::Pending,
|
||||
SuggestionStatus::Approved,
|
||||
SuggestionStatus::Rejected,
|
||||
SuggestionStatus::Executed,
|
||||
SuggestionStatus::Expired,
|
||||
SuggestionStatus::ParseFailed,
|
||||
];
|
||||
for s in statuses {
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
let back: SuggestionStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(s, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_suggestion_deserialize() {
|
||||
let json = r#"{
|
||||
"type": "followup",
|
||||
"priority": 1,
|
||||
"timing": "14天内",
|
||||
"reason": "血压异常",
|
||||
"params": {"metric": "systolic_bp"},
|
||||
"auto_executable": false
|
||||
}"#;
|
||||
let s: StructuredSuggestion = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(s.suggestion_type, SuggestionType::Followup);
|
||||
assert_eq!(s.priority, 1);
|
||||
assert!(!s.auto_executable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_output_deserialize() {
|
||||
let json = r#"{
|
||||
"risk_level": "medium",
|
||||
"risk_factors": ["收缩压偏高"],
|
||||
"suggestions": [{
|
||||
"type": "followup",
|
||||
"priority": 1,
|
||||
"timing": "14天内",
|
||||
"reason": "血压异常",
|
||||
"params": {},
|
||||
"auto_executable": false
|
||||
}],
|
||||
"baseline_summary": {"systolic_bp": 148}
|
||||
}"#;
|
||||
let output: StructuredOutput = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(output.risk_level, RiskLevel::Medium);
|
||||
assert_eq!(output.suggestions.len(), 1);
|
||||
assert_eq!(output.risk_factors.len(), 1);
|
||||
}
|
||||
}
|
||||
25
crates/erp-ai/src/entity/ai_risk_threshold.rs
Normal file
25
crates/erp-ai/src/entity/ai_risk_threshold.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_risk_threshold")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub metric_name: String,
|
||||
pub low_threshold: Option<serde_json::Value>,
|
||||
pub medium_threshold: Option<serde_json::Value>,
|
||||
pub high_threshold: Option<serde_json::Value>,
|
||||
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 {}
|
||||
30
crates/erp-ai/src/entity/ai_suggestion.rs
Normal file
30
crates/erp-ai/src/entity/ai_suggestion.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_suggestion")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub analysis_id: Uuid,
|
||||
pub suggestion_type: String,
|
||||
pub risk_level: String,
|
||||
pub params: serde_json::Value,
|
||||
pub status: String,
|
||||
pub workflow_instance_id: Option<Uuid>,
|
||||
pub action_result: Option<serde_json::Value>,
|
||||
pub baseline_snapshot: Option<serde_json::Value>,
|
||||
pub reanalysis_id: Option<Uuid>,
|
||||
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 {}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod ai_analysis;
|
||||
pub mod ai_prompt;
|
||||
pub mod ai_risk_threshold;
|
||||
pub mod ai_suggestion;
|
||||
pub mod ai_usage;
|
||||
|
||||
@@ -9,8 +9,11 @@ use serde::Deserialize;
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::dto::{AnalysisSseEvent, AnalysisType};
|
||||
use crate::service::suggestion::SuggestionService;
|
||||
use crate::state::AiState;
|
||||
|
||||
pub mod suggestion_handler;
|
||||
|
||||
// === 分析请求 Body ===
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -71,8 +74,10 @@ where
|
||||
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
let patient_id_clone = uuid::Uuid::nil(); // lab report 场景 patient_id 从 report 关联
|
||||
let doctor_id_clone = ctx.user_id;
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "lab_report", ctx.tenant_id);
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "lab_report", ctx.tenant_id, patient_id_clone, doctor_id_clone);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -140,7 +145,7 @@ where
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "trend", ctx.tenant_id);
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "trend", ctx.tenant_id, uuid::Uuid::nil(), ctx.user_id);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -197,7 +202,7 @@ where
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "checkup_plan", ctx.tenant_id);
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "checkup_plan", ctx.tenant_id, uuid::Uuid::nil(), ctx.user_id);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -254,7 +259,7 @@ where
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "report_summary", ctx.tenant_id);
|
||||
let sse_stream = build_sse_stream(stream, analysis_id_clone, state_clone, "report_summary", ctx.tenant_id, uuid::Uuid::nil(), ctx.user_id);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -453,6 +458,8 @@ fn build_sse_stream(
|
||||
state: AiState,
|
||||
analysis_type: &'static str,
|
||||
tenant_id: uuid::Uuid,
|
||||
patient_id: uuid::Uuid,
|
||||
doctor_id: uuid::Uuid,
|
||||
) -> impl futures::Stream<Item = Result<Event, Infallible>> {
|
||||
async_stream::stream! {
|
||||
let mut full_content = String::new();
|
||||
@@ -491,14 +498,45 @@ fn build_sse_stream(
|
||||
let metadata = serde_json::json!({"analysis_type": analysis_type});
|
||||
let _ = state.analysis.complete_analysis(analysis_id, full_content.clone(), metadata).await;
|
||||
|
||||
// 解析双通道输出并创建建议记录
|
||||
let parsed = crate::service::output_parser::parse_dual_channel(&full_content).unwrap_or(
|
||||
crate::dto::suggestion::ParsedOutput {
|
||||
text_content: full_content.clone(),
|
||||
structured: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut event_payload = serde_json::json!({
|
||||
"analysis_id": analysis_id,
|
||||
"analysis_type": analysis_type,
|
||||
"patient_id": patient_id,
|
||||
"doctor_id": doctor_id,
|
||||
});
|
||||
|
||||
if let Some(ref structured) = parsed.structured {
|
||||
event_payload["risk_level"] = serde_json::json!(structured.risk_level.as_str());
|
||||
event_payload["suggestion_count"] = serde_json::json!(structured.suggestions.len());
|
||||
|
||||
if !structured.suggestions.is_empty() {
|
||||
let _ = SuggestionService::create_suggestions(
|
||||
&state.db,
|
||||
tenant_id,
|
||||
analysis_id,
|
||||
&structured.suggestions,
|
||||
structured.risk_level,
|
||||
&structured.baseline_summary,
|
||||
Some(doctor_id),
|
||||
).await;
|
||||
}
|
||||
} else {
|
||||
let _ = SuggestionService::mark_parse_failed(&state.db, analysis_id).await;
|
||||
}
|
||||
|
||||
// 发布 AI 分析完成事件
|
||||
let event = erp_core::events::DomainEvent::new(
|
||||
"ai.analysis.completed",
|
||||
tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({
|
||||
"analysis_id": analysis_id,
|
||||
"analysis_type": analysis_type,
|
||||
})),
|
||||
erp_core::events::build_event_payload(event_payload),
|
||||
);
|
||||
state.event_bus.publish(event, &state.db).await;
|
||||
|
||||
|
||||
89
crates/erp-ai/src/handler/suggestion_handler.rs
Normal file
89
crates/erp-ai/src/handler/suggestion_handler.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||
use axum::Json;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::dto::suggestion::SuggestionStatus;
|
||||
use crate::service::suggestion::SuggestionService;
|
||||
use crate::state::AiState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListSuggestionsQuery {
|
||||
pub analysis_id: Option<uuid::Uuid>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_suggestions<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListSuggestionsQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.suggestion.list")?;
|
||||
|
||||
if let Some(analysis_id) = params.analysis_id {
|
||||
let items = SuggestionService::list_by_analysis(
|
||||
&state.db,
|
||||
ctx.tenant_id,
|
||||
analysis_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": items.len(),
|
||||
}))))
|
||||
} else {
|
||||
let items =
|
||||
SuggestionService::list_pending(&state.db, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": items.len(),
|
||||
}))))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApproveBody {
|
||||
pub action: String, // "approve" or "reject"
|
||||
}
|
||||
|
||||
pub async fn approve_suggestion<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
Json(body): Json<ApproveBody>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.suggestion.manage")?;
|
||||
|
||||
let new_status = match body.action.as_str() {
|
||||
"approve" => SuggestionStatus::Approved,
|
||||
"reject" => SuggestionStatus::Rejected,
|
||||
_ => {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"action 必须为 approve 或 reject".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
SuggestionService::update_status(
|
||||
&state.db,
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
new_status,
|
||||
Some(ctx.user_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"id": id,
|
||||
"status": new_status.as_str(),
|
||||
}))))
|
||||
}
|
||||
@@ -57,6 +57,18 @@ impl ErpModule for AiModule {
|
||||
description: "管理 AI 提供商配置".into(),
|
||||
module: "ai".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "ai.suggestion.list".into(),
|
||||
name: "查看 AI 建议".into(),
|
||||
description: "查看 AI 分析生成的建议列表".into(),
|
||||
module: "ai".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "ai.suggestion.manage".into(),
|
||||
name: "审批 AI 建议".into(),
|
||||
description: "批准或拒绝 AI 建议".into(),
|
||||
module: "ai".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -128,5 +140,13 @@ impl AiModule {
|
||||
"/ai/usage/by-type",
|
||||
axum::routing::get(crate::handler::usage_by_type),
|
||||
)
|
||||
.route(
|
||||
"/ai/suggestions",
|
||||
axum::routing::get(crate::handler::suggestion_handler::list_suggestions),
|
||||
)
|
||||
.route(
|
||||
"/ai/suggestions/{id}/approve",
|
||||
axum::routing::post(crate::handler::suggestion_handler::approve_suggestion),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
192
crates/erp-ai/src/service/local_rules.rs
Normal file
192
crates/erp-ai/src/service/local_rules.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRule {
|
||||
pub metric: String,
|
||||
pub operator: CompareOp,
|
||||
pub threshold: f64,
|
||||
pub risk_level: RiskLevel,
|
||||
pub suggestion_type: SuggestionType,
|
||||
pub message_template: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CompareOp {
|
||||
GreaterThan,
|
||||
LessThan,
|
||||
}
|
||||
|
||||
pub struct LocalRulesEngine {
|
||||
rules: Vec<LocalRule>,
|
||||
}
|
||||
|
||||
impl LocalRulesEngine {
|
||||
pub fn new(rules: Vec<LocalRule>) -> Self {
|
||||
Self { rules }
|
||||
}
|
||||
|
||||
pub fn default_rules() -> Self {
|
||||
Self::new(vec![
|
||||
// 收缩压
|
||||
LocalRule {
|
||||
metric: "systolic_bp".into(),
|
||||
operator: CompareOp::GreaterThan,
|
||||
threshold: 160.0,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "收缩压异常偏高({value}mmHg),请立即就医".into(),
|
||||
},
|
||||
LocalRule {
|
||||
metric: "systolic_bp".into(),
|
||||
operator: CompareOp::GreaterThan,
|
||||
threshold: 140.0,
|
||||
risk_level: RiskLevel::Medium,
|
||||
suggestion_type: SuggestionType::Followup,
|
||||
message_template: "收缩压偏高({value}mmHg),建议2周内复查".into(),
|
||||
},
|
||||
LocalRule {
|
||||
metric: "systolic_bp".into(),
|
||||
operator: CompareOp::LessThan,
|
||||
threshold: 90.0,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "收缩压偏低({value}mmHg),请立即就医".into(),
|
||||
},
|
||||
// 心率
|
||||
LocalRule {
|
||||
metric: "heart_rate".into(),
|
||||
operator: CompareOp::GreaterThan,
|
||||
threshold: 100.0,
|
||||
risk_level: RiskLevel::Medium,
|
||||
suggestion_type: SuggestionType::Followup,
|
||||
message_template: "心率偏快({value}bpm),建议随访".into(),
|
||||
},
|
||||
LocalRule {
|
||||
metric: "heart_rate".into(),
|
||||
operator: CompareOp::LessThan,
|
||||
threshold: 60.0,
|
||||
risk_level: RiskLevel::Medium,
|
||||
suggestion_type: SuggestionType::Followup,
|
||||
message_template: "心率偏慢({value}bpm),建议随访".into(),
|
||||
},
|
||||
// 血糖
|
||||
LocalRule {
|
||||
metric: "blood_sugar".into(),
|
||||
operator: CompareOp::GreaterThan,
|
||||
threshold: 11.1,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "血糖异常偏高({value}mmol/L),请立即就医".into(),
|
||||
},
|
||||
LocalRule {
|
||||
metric: "blood_sugar".into(),
|
||||
operator: CompareOp::LessThan,
|
||||
threshold: 3.9,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "血糖偏低({value}mmol/L),有低血糖风险".into(),
|
||||
},
|
||||
// SpO2
|
||||
LocalRule {
|
||||
metric: "spo2".into(),
|
||||
operator: CompareOp::LessThan,
|
||||
threshold: 95.0,
|
||||
risk_level: RiskLevel::High,
|
||||
suggestion_type: SuggestionType::Alert,
|
||||
message_template: "血氧饱和度偏低({value}%),请立即就医".into(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
pub fn evaluate(&self, metrics: &serde_json::Value) -> Vec<StructuredSuggestion> {
|
||||
let mut suggestions = Vec::new();
|
||||
for rule in &self.rules {
|
||||
if let Some(value) = metrics.get(&rule.metric).and_then(|v| v.as_f64()) {
|
||||
let triggered = match rule.operator {
|
||||
CompareOp::GreaterThan => value > rule.threshold,
|
||||
CompareOp::LessThan => value < rule.threshold,
|
||||
};
|
||||
if triggered {
|
||||
suggestions.push(StructuredSuggestion {
|
||||
id: None,
|
||||
suggestion_type: rule.suggestion_type,
|
||||
priority: match rule.risk_level {
|
||||
RiskLevel::High => 1,
|
||||
RiskLevel::Medium => 2,
|
||||
RiskLevel::Low => 3,
|
||||
},
|
||||
timing: match rule.risk_level {
|
||||
RiskLevel::High => "立即".into(),
|
||||
RiskLevel::Medium => "2周内".into(),
|
||||
RiskLevel::Low => "1个月内".into(),
|
||||
},
|
||||
reason: rule
|
||||
.message_template
|
||||
.replace("{value}", &value.to_string()),
|
||||
params: serde_json::json!({
|
||||
"metric": rule.metric,
|
||||
"value": value,
|
||||
"threshold": rule.threshold,
|
||||
}),
|
||||
auto_executable: rule.risk_level.is_auto_executable(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
suggestions.sort_by_key(|s| s.priority);
|
||||
suggestions
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn evaluate_systolic_bp_high() {
|
||||
let rules = LocalRulesEngine::default_rules();
|
||||
let metrics = serde_json::json!({"systolic_bp": 165.0});
|
||||
let suggestions = rules.evaluate(&metrics);
|
||||
assert!(!suggestions.is_empty());
|
||||
// 收缩压 > 160 触发 High 级别 Alert 规则
|
||||
assert_eq!(suggestions[0].suggestion_type, SuggestionType::Alert);
|
||||
assert_eq!(suggestions[0].priority, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_all_normal_no_suggestions() {
|
||||
let rules = LocalRulesEngine::default_rules();
|
||||
let metrics = serde_json::json!({"systolic_bp": 120.0, "heart_rate": 72.0, "blood_sugar": 5.5});
|
||||
let suggestions = rules.evaluate(&metrics);
|
||||
assert!(suggestions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_missing_metric_skipped() {
|
||||
let rules = LocalRulesEngine::default_rules();
|
||||
let metrics = serde_json::json!({"heart_rate": 110.0});
|
||||
let suggestions = rules.evaluate(&metrics);
|
||||
assert!(suggestions
|
||||
.iter()
|
||||
.any(|s| s.suggestion_type == SuggestionType::Followup));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_spo2_low() {
|
||||
let rules = LocalRulesEngine::default_rules();
|
||||
let metrics = serde_json::json!({"spo2": 92.0});
|
||||
let suggestions = rules.evaluate(&metrics);
|
||||
assert_eq!(suggestions.len(), 1);
|
||||
assert_eq!(suggestions[0].suggestion_type, SuggestionType::Alert);
|
||||
assert!(suggestions[0].reason.contains("92"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_sorted_by_priority() {
|
||||
let rules = LocalRulesEngine::default_rules();
|
||||
let metrics = serde_json::json!({"systolic_bp": 165.0, "heart_rate": 110.0});
|
||||
let suggestions = rules.evaluate(&metrics);
|
||||
// High (priority 1) should come before Medium (priority 2)
|
||||
assert!(suggestions[0].priority <= suggestions.last().unwrap().priority);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
pub mod analysis;
|
||||
pub mod auto_analysis;
|
||||
pub mod local_rules;
|
||||
pub mod output_parser;
|
||||
pub mod prompt;
|
||||
pub mod suggestion;
|
||||
pub mod usage;
|
||||
|
||||
82
crates/erp-ai/src/service/output_parser.rs
Normal file
82
crates/erp-ai/src/service/output_parser.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use crate::dto::suggestion::{ParsedOutput, StructuredOutput};
|
||||
use crate::error::AiResult;
|
||||
|
||||
const TEXT_MARKER: &str = "===PATIENT_TEXT===";
|
||||
const JSON_MARKER: &str = "===STRUCTURED_JSON===";
|
||||
|
||||
/// 解析 AI 双通道输出。JSON 解析失败时降级为纯文本。
|
||||
pub fn parse_dual_channel(raw: &str) -> AiResult<ParsedOutput> {
|
||||
let text_content = extract_section(raw, TEXT_MARKER, JSON_MARKER)
|
||||
.unwrap_or(raw)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER)
|
||||
.and_then(|json_str| {
|
||||
let parsed: Result<StructuredOutput, _> = serde_json::from_str(json_str.trim());
|
||||
parsed.ok()
|
||||
});
|
||||
|
||||
Ok(ParsedOutput {
|
||||
text_content,
|
||||
structured,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_section<'a>(raw: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||
let start_idx = raw.find(start)?;
|
||||
let content_start = start_idx + start.len();
|
||||
let content_end = raw[content_start..]
|
||||
.find(end)
|
||||
.map(|i| content_start + i)
|
||||
.unwrap_or(raw.len());
|
||||
Some(&raw[content_start..content_end])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::suggestion::RiskLevel;
|
||||
|
||||
#[test]
|
||||
fn parse_dual_channel_output_success() {
|
||||
let raw = "===PATIENT_TEXT===\n张三的收缩压呈上升趋势\n===STRUCTURED_JSON===\n{\"risk_level\":\"medium\",\"risk_factors\":[\"收缩压偏高\"],\"suggestions\":[{\"type\":\"followup\",\"priority\":1,\"timing\":\"14天内\",\"reason\":\"血压异常\",\"params\":{},\"auto_executable\":false}],\"baseline_summary\":{}}";
|
||||
let result = parse_dual_channel(raw).unwrap();
|
||||
assert_eq!(result.text_content, "张三的收缩压呈上升趋势");
|
||||
assert!(result.structured.is_some());
|
||||
let s = result.structured.unwrap();
|
||||
assert_eq!(s.risk_level, RiskLevel::Medium);
|
||||
assert_eq!(s.suggestions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_text_only_fallback() {
|
||||
let raw = "纯文本分析结果,没有结构化部分";
|
||||
let result = parse_dual_channel(raw).unwrap();
|
||||
assert_eq!(result.text_content, "纯文本分析结果,没有结构化部分");
|
||||
assert!(result.structured.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_json_falls_back() {
|
||||
let raw = "===PATIENT_TEXT===\n分析内容\n===STRUCTURED_JSON===\n{invalid json}";
|
||||
let result = parse_dual_channel(raw).unwrap();
|
||||
assert_eq!(result.text_content, "分析内容");
|
||||
assert!(result.structured.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_suggestions_is_valid() {
|
||||
let raw = "===PATIENT_TEXT===\n指标正常\n===STRUCTURED_JSON===\n{\"risk_level\":\"low\",\"risk_factors\":[],\"suggestions\":[],\"baseline_summary\":{}}";
|
||||
let result = parse_dual_channel(raw).unwrap();
|
||||
let s = result.structured.unwrap();
|
||||
assert!(s.suggestions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_level_auto_executable() {
|
||||
assert!(RiskLevel::Low.is_auto_executable());
|
||||
assert!(!RiskLevel::Medium.is_auto_executable());
|
||||
assert!(!RiskLevel::High.is_auto_executable());
|
||||
}
|
||||
}
|
||||
112
crates/erp-ai/src/service/suggestion.rs
Normal file
112
crates/erp-ai/src/service/suggestion.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use uuid::Uuid;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use erp_core::error::AppResult;
|
||||
use crate::dto::suggestion::*;
|
||||
use crate::entity::ai_suggestion;
|
||||
|
||||
pub struct SuggestionService;
|
||||
|
||||
impl SuggestionService {
|
||||
/// 批量创建建议记录
|
||||
pub async fn create_suggestions(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
analysis_id: Uuid,
|
||||
suggestions: &[StructuredSuggestion],
|
||||
risk_level: RiskLevel,
|
||||
baseline_snapshot: &serde_json::Value,
|
||||
created_by: Option<Uuid>,
|
||||
) -> AppResult<Vec<uuid::Uuid>> {
|
||||
let mut ids = Vec::new();
|
||||
for s in suggestions {
|
||||
let id = Uuid::now_v7();
|
||||
let model = ai_suggestion::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
analysis_id: Set(analysis_id),
|
||||
suggestion_type: Set(s.suggestion_type.as_str().to_string()),
|
||||
risk_level: Set(risk_level.as_str().to_string()),
|
||||
params: Set(s.params.clone()),
|
||||
status: Set(SuggestionStatus::Pending.as_str().to_string()),
|
||||
workflow_instance_id: Set(None),
|
||||
action_result: Set(None),
|
||||
baseline_snapshot: Set(Some(baseline_snapshot.clone())),
|
||||
reanalysis_id: Set(None),
|
||||
created_by: Set(created_by),
|
||||
updated_by: Set(created_by),
|
||||
..Default::default()
|
||||
};
|
||||
model.insert(db).await?;
|
||||
ids.push(id);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// 查询某次分析的所有建议(带 tenant_id 过滤 + 软删除)
|
||||
pub async fn list_by_analysis(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
analysis_id: Uuid,
|
||||
) -> AppResult<Vec<ai_suggestion::Model>> {
|
||||
let items = ai_suggestion::Entity::find()
|
||||
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_suggestion::Column::AnalysisId.eq(analysis_id))
|
||||
.filter(ai_suggestion::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// 查询待审批建议(带 tenant_id 过滤)
|
||||
pub async fn list_pending(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<Vec<ai_suggestion::Model>> {
|
||||
let items = ai_suggestion::Entity::find()
|
||||
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_suggestion::Column::Status.eq(SuggestionStatus::Pending.as_str()))
|
||||
.filter(ai_suggestion::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// 更新建议状态(带乐观锁 + tenant_id 过滤)
|
||||
pub async fn update_status(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
suggestion_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
new_status: SuggestionStatus,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> AppResult<()> {
|
||||
let item = ai_suggestion::Entity::find()
|
||||
.filter(ai_suggestion::Column::Id.eq(suggestion_id))
|
||||
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_suggestion::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::error::AiError::AnalysisNotFound("建议不存在".into())
|
||||
})?;
|
||||
|
||||
let current_version = item.version_lock;
|
||||
let mut active: ai_suggestion::ActiveModel = item.into();
|
||||
active.status = Set(new_status.as_str().to_string());
|
||||
active.updated_by = Set(updated_by);
|
||||
active.version_lock = Set(current_version + 1);
|
||||
active.update(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 标记为解析失败(仅记录日志,不创建建议记录)
|
||||
pub async fn mark_parse_failed(
|
||||
_db: &sea_orm::DatabaseConnection,
|
||||
analysis_id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
tracing::warn!(
|
||||
analysis_id = %analysis_id,
|
||||
"AI 结构化输出解析失败,降级为纯文本"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::service::analysis::AnalysisService;
|
||||
use crate::service::prompt::PromptService;
|
||||
use crate::service::suggestion::SuggestionService;
|
||||
use crate::service::usage::UsageService;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -15,5 +16,6 @@ pub struct AiState {
|
||||
pub analysis: Arc<AnalysisService>,
|
||||
pub prompt: Arc<PromptService>,
|
||||
pub usage: Arc<UsageService>,
|
||||
pub suggestion: Arc<SuggestionService>,
|
||||
pub health_provider: Arc<dyn HealthDataProvider>,
|
||||
}
|
||||
|
||||
@@ -97,6 +97,8 @@ mod m20260429_000094_device_readings_unique_constraint;
|
||||
mod m20260429_000095_seed_alert_device_menus;
|
||||
mod m20260430_000096_create_medication_reminder;
|
||||
mod m20260501_000097_seed_menu_permissions;
|
||||
mod m20260501_000098_create_ai_suggestion;
|
||||
mod m20260501_000099_create_ai_risk_threshold;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -201,6 +203,8 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260429_000095_seed_alert_device_menus::Migration),
|
||||
Box::new(m20260430_000096_create_medication_reminder::Migration),
|
||||
Box::new(m20260501_000097_seed_menu_permissions::Migration),
|
||||
Box::new(m20260501_000098_create_ai_suggestion::Migration),
|
||||
Box::new(m20260501_000099_create_ai_risk_threshold::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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(Alias::new("ai_suggestion"))
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key()
|
||||
.default(Expr::cust("gen_random_uuid()")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("analysis_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("suggestion_type"))
|
||||
.string_len(20)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("risk_level"))
|
||||
.string_len(10)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("params")).json_binary().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("status"))
|
||||
.string_len(20)
|
||||
.not_null()
|
||||
.default("pending"),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("workflow_instance_id")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("action_result")).json_binary())
|
||||
.col(ColumnDef::new(Alias::new("baseline_snapshot")).json_binary())
|
||||
.col(ColumnDef::new(Alias::new("reanalysis_id")).uuid())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.default(Expr::cust("NOW()")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.default(Expr::cust("NOW()")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version_lock"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_ai_suggestion_tenant_analysis")
|
||||
.table(Alias::new("ai_suggestion"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("analysis_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_ai_suggestion_tenant_status")
|
||||
.table(Alias::new("ai_suggestion"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("status"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("ai_suggestion")).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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(Alias::new("ai_risk_threshold"))
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key()
|
||||
.default(Expr::cust("gen_random_uuid()")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("metric_name"))
|
||||
.string_len(50)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("low_threshold")).json_binary())
|
||||
.col(ColumnDef::new(Alias::new("medium_threshold")).json_binary())
|
||||
.col(ColumnDef::new(Alias::new("high_threshold")).json_binary())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.default(Expr::cust("NOW()")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.default(Expr::cust("NOW()")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version_lock"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_ai_risk_threshold_tenant_metric")
|
||||
.table(Alias::new("ai_risk_threshold"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("metric_name"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("ai_risk_threshold")).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -454,6 +454,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
let prompt = std::sync::Arc::new(erp_ai::service::prompt::PromptService::new(db.clone()));
|
||||
let usage = std::sync::Arc::new(erp_ai::service::usage::UsageService::new(db.clone()));
|
||||
let suggestion = std::sync::Arc::new(erp_ai::service::suggestion::SuggestionService);
|
||||
let health_provider = std::sync::Arc::new(erp_health::HealthDataProviderImpl {
|
||||
db: db.clone(),
|
||||
});
|
||||
@@ -463,6 +464,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
analysis,
|
||||
prompt,
|
||||
usage,
|
||||
suggestion,
|
||||
health_provider,
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user