feat(ai): Phase 1A 收尾 — 用量记录 + 健康摘要端点 + 小程序组件

- chat_handler 添加 log_usage 精确记录 token 消耗(provider + model)
- SSE build_sse_stream 添加估算 token 用量记录(4 字符 ≈ 1 token)
- 新增 GET /ai/health-summary 端点聚合患者洞察+分析记录
- 小程序 AiHealthSummaryCard 组件(风险等级+洞察统计+摘要列表)
- 小程序 services/ai-analysis 新增 getHealthSummary API
This commit is contained in:
iven
2026-05-18 23:20:06 +08:00
parent 281c71ebfc
commit 7e3d27ecf3
7 changed files with 397 additions and 2 deletions

View File

@@ -144,6 +144,8 @@ where
"AI Agent chat request"
);
let provider_name = provider_arc.name().to_string();
// 执行 Agent ReAct 循环
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
let result = orchestrator
@@ -170,6 +172,25 @@ where
"AI Agent response sent"
);
// 记录用量的 token 消耗
if let Err(e) = ai_state
.usage
.log_usage(
ctx.tenant_id,
&provider_name,
&run_params.model,
"chat",
result.total_input_tokens as u32,
result.total_output_tokens as u32,
0,
0,
false,
)
.await
{
tracing::warn!(error = %e, "Failed to log chat usage");
}
Ok(Json(ApiResponse::ok(ChatResponse {
reply: result.reply,
message_id,

View File

@@ -2,8 +2,8 @@ use axum::Json;
use axum::extract::{Extension, FromRef, Path, Query, State};
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use serde::{Deserialize, Serialize};
use crate::dto::copilot::ListInsightsQuery;
use crate::state::AiState;
@@ -112,3 +112,134 @@ where
serde_json::json!({"dismissed": true}),
)))
}
// === 健康摘要(小程序患者端) ===
#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct HealthSummaryQuery {
pub patient_id: uuid::Uuid,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct HealthSummaryResponse {
pub patient_id: uuid::Uuid,
pub risk_level: String,
pub active_insights_count: usize,
pub recent_analyses_count: i64,
pub latest_insight_title: Option<String>,
pub latest_analysis_type: Option<String>,
pub summary_items: Vec<SummaryItem>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct SummaryItem {
pub category: String,
pub title: String,
pub severity: Option<String>,
pub created_at: String,
}
#[utoipa::path(
get,
path = "/ai/health-summary",
params(HealthSummaryQuery),
responses((status = 200, description = "患者健康摘要")),
tag = "AI 健康摘要",
security(("bearer_auth" = [])),
)]
pub async fn health_summary<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<HealthSummaryQuery>,
) -> Result<Json<ApiResponse<HealthSummaryResponse>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.analysis.list")?;
let patient_id = params.patient_id;
// 查询该患者的活跃洞察(最近 7 天、未读)
let seven_days_ago = chrono::Utc::now() - chrono::Duration::days(7);
let insights_query = crate::entity::copilot_insights::Entity::find()
.filter(crate::entity::copilot_insights::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::copilot_insights::Column::PatientId.eq(patient_id))
.filter(crate::entity::copilot_insights::Column::DeletedAt.is_null())
.filter(crate::entity::copilot_insights::Column::CreatedAt.gte(seven_days_ago))
.order_by_desc(crate::entity::copilot_insights::Column::CreatedAt);
let insights: Vec<crate::entity::copilot_insights::Model> = insights_query
.limit(10)
.all(&state.db)
.await
.unwrap_or_default();
let active_insights_count = insights.len();
let latest_insight_title = insights.first().map(|i| i.title.clone());
// 判断整体风险等级:按最高 severity
let risk_level = insights
.iter()
.filter_map(|i| i.severity.as_deref())
.max_by(|a, b| {
let ord = |s: &str| match s {
"critical" => 4,
"high" => 3,
"medium" => 2,
_ => 1,
};
ord(a).cmp(&ord(b))
})
.unwrap_or("low")
.to_string();
// 查询该患者最近分析记录
let analyses_query = crate::entity::ai_analysis::Entity::find()
.filter(crate::entity::ai_analysis::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::ai_analysis::Column::PatientId.eq(patient_id))
.filter(crate::entity::ai_analysis::Column::Status.eq("completed"))
.filter(crate::entity::ai_analysis::Column::DeletedAt.is_null())
.order_by_desc(crate::entity::ai_analysis::Column::CreatedAt);
let analyses: Vec<crate::entity::ai_analysis::Model> = analyses_query
.limit(5)
.all(&state.db)
.await
.unwrap_or_default();
let recent_analyses_count = analyses.len() as i64;
let latest_analysis_type = analyses.first().map(|a| a.analysis_type.clone());
// 组装摘要条目
let mut summary_items: Vec<SummaryItem> = insights
.iter()
.map(|i| SummaryItem {
category: i.insight_type.clone(),
title: i.title.clone(),
severity: i.severity.clone(),
created_at: i.created_at.to_rfc3339(),
})
.collect();
for a in &analyses {
summary_items.push(SummaryItem {
category: a.analysis_type.clone(),
title: format!("AI {}分析", a.analysis_type),
severity: None,
created_at: a.created_at.to_rfc3339(),
});
}
// 按时间倒序排列
summary_items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
summary_items.truncate(10);
Ok(Json(ApiResponse::ok(HealthSummaryResponse {
patient_id,
risk_level,
active_insights_count,
recent_analyses_count,
latest_insight_title,
latest_analysis_type,
summary_items,
})))
}

View File

@@ -880,6 +880,22 @@ fn build_sse_stream(
let metadata = serde_json::json!({"analysis_type": analysis_type});
let _ = state.analysis.complete_analysis(analysis_id, full_content.clone(), metadata.clone()).await;
// 记录用量的估算 token 消耗SSE 模式无法获取精确 token 数,按 4 字符 ≈ 1 token 估算)
let est_output_tokens = (full_content.len() as u32) / 4;
if let Err(e) = state.usage.log_usage(
tenant_id,
"sse",
"",
analysis_type,
0,
est_output_tokens,
0,
0,
false,
).await {
tracing::warn!(error = %e, "Failed to log SSE analysis usage");
}
// 后处理:解析双通道输出、创建建议、发布事件
crate::service::post_process::post_process_analysis(
&state,