Compare commits

...

9 Commits

Author SHA1 Message Date
iven
4b3193fcd6 feat(server): 集成 SuggestionService 到 AiState 初始化
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-01 08:14:41 +08:00
iven
415d7617c8 feat(ai): 建议查询/审批 API 端点 + 权限注册
- GET /ai/suggestions?analysis_id=xxx — 查看建议列表(ai.suggestion.list)
- POST /ai/suggestions/{id}/approve — 批准/拒绝建议(ai.suggestion.manage)
- 新增 ai.suggestion.list 和 ai.suggestion.manage 权限码
2026-05-01 08:12:29 +08:00
iven
6e761ae22b feat(ai): 集成双通道输出解析到 SSE handler — 自动创建建议记录
在 build_sse_stream 完成回调中:
- 调用 parse_dual_channel 解析 AI 输出
- 有结构化建议时调用 SuggestionService::create_suggestions 创建记录
- 解析失败时调用 mark_parse_failed 记录日志
- 扩展 ai.analysis.completed 事件 payload 含 risk_level + suggestion_count
2026-05-01 08:11:23 +08:00
iven
b30897119b feat(ai): SuggestionService — 建议记录 CRUD + 状态流转
- create_suggestions: 批量创建建议记录,关联分析 ID 和 baseline 快照
- list_by_analysis: 按 analysis_id 查询建议列表(带 tenant_id 过滤 + 软删除)
- list_pending: 查询待审批建议
- update_status: 更新状态(带乐观锁 + tenant_id 过滤)
- mark_parse_failed: 解析失败时记录日志
- AiState 新增 suggestion 字段
2026-05-01 08:09:59 +08:00
iven
3b6f72d5c0 feat(ai): 本地临床规则引擎 — AI 不可用时的回退方案
- LocalRulesEngine: 预定义 8 条临床规则(收缩压/心率/血糖/血氧)
- CompareOp: GreaterThan/LessThan 比较运算
- evaluate(): 输入指标 JSON,输出 StructuredSuggestion 列表(按优先级排序)
- 5 个单元测试覆盖:高值触发、正常无建议、缺失指标跳过、SpO2 低、优先级排序
2026-05-01 08:08:48 +08:00
iven
92e6cf0c43 feat(ai): 双通道输出解析器 — 文本/JSON 分割 + 降级策略
- parse_dual_channel: 分割 ===PATIENT_TEXT=== / ===STRUCTURED_JSON=== 标记
- JSON 解析失败时降级为纯文本,structured 为 None
- 5 个单元测试覆盖:正常解析、纯文本、无效 JSON、空建议、风险等级
2026-05-01 08:07:26 +08:00
iven
9b8307fbba feat(ai): 添加 ai_suggestion 和 ai_risk_threshold SeaORM Entity 2026-05-01 08:05:42 +08:00
iven
577d2a32b1 feat(db): 添加 ai_suggestion 和 ai_risk_threshold 表迁移
- ai_suggestion: AI 建议记录表,含 tenant_id、analysis_id、suggestion_type、
  risk_level、status、params、baseline_snapshot 等字段
- ai_risk_threshold: 租户级风险阈值配置表,按 metric_name + tenant_id 唯一索引
- 两表均包含标准审计字段和 version_lock 乐观锁
2026-05-01 08:04:51 +08:00
iven
7789a5e227 feat(ai): 新增 Suggestion/RiskLevel/SuggestionStatus 枚举和结构化输出 DTO
重构 dto.rs 为 dto/ 目录模块,新增 suggestion.rs 包含:
- SuggestionType (Followup/Appointment/Alert)
- RiskLevel (Low/Medium/High) + is_auto_executable
- SuggestionStatus (6 种状态)
- StructuredSuggestion / StructuredOutput / ParsedOutput DTO
- 7 个单元测试覆盖序列化往返
2026-05-01 08:02:53 +08:00
17 changed files with 1196 additions and 8 deletions

View 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 变体"),
}
}
}

View 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);
}
}

View 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 {}

View 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 {}

View File

@@ -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;

View File

@@ -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;

View 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(),
}))))
}

View File

@@ -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),
)
}
}

View 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);
}
}

View File

@@ -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;

View 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());
}
}

View 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(())
}
}

View File

@@ -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>,
}

View File

@@ -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),
]
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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,
}
};