From 7e3d27ecf3501a5fa47fa9fc24963f3a5c1faa33 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 18 May 2026 23:20:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Phase=201A=20=E6=94=B6=E5=B0=BE=20?= =?UTF-8?q?=E2=80=94=20=E7=94=A8=E9=87=8F=E8=AE=B0=E5=BD=95=20+=20?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=91=98=E8=A6=81=E7=AB=AF=E7=82=B9=20+=20?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../ui/AiHealthSummaryCard/index.scss | 100 +++++++++++++ .../ui/AiHealthSummaryCard/index.tsx | 97 +++++++++++++ apps/miniprogram/src/services/ai-analysis.ts | 26 ++++ crates/erp-ai/src/handler/chat_handler.rs | 21 +++ crates/erp-ai/src/handler/insight_handler.rs | 135 +++++++++++++++++- crates/erp-ai/src/handler/mod.rs | 16 +++ crates/erp-ai/src/module.rs | 4 + 7 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 apps/miniprogram/src/components/ui/AiHealthSummaryCard/index.scss create mode 100644 apps/miniprogram/src/components/ui/AiHealthSummaryCard/index.tsx diff --git a/apps/miniprogram/src/components/ui/AiHealthSummaryCard/index.scss b/apps/miniprogram/src/components/ui/AiHealthSummaryCard/index.scss new file mode 100644 index 0000000..6f0793a --- /dev/null +++ b/apps/miniprogram/src/components/ui/AiHealthSummaryCard/index.scss @@ -0,0 +1,100 @@ +.ai-summary-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.ai-summary-title { + font-size: var(--tk-font-size-body-lg, 18px); + font-weight: 600; + color: var(--tk-color-text, #333); +} + +.ai-summary-risk { + padding: 4px 16px; + border-radius: 100px; + + &-text { + font-size: var(--tk-font-size-cap, 13px); + color: #fff; + font-weight: 500; + } +} + +.ai-summary-insight { + margin-bottom: 24px; + + &-label { + font-size: var(--tk-font-size-cap, 13px); + color: var(--tk-color-text-secondary, #999); + display: block; + margin-bottom: 8px; + } + + &-text { + font-size: var(--tk-font-size-body, 16px); + color: var(--tk-color-text, #333); + line-height: 1.5; + } +} + +.ai-summary-stats { + display: flex; + gap: 40px; + margin-bottom: 24px; +} + +.ai-summary-stat { + display: flex; + flex-direction: column; + align-items: center; + + &-value { + font-size: 28px; + font-weight: 700; + color: var(--tk-color-primary, #1890ff); + } + + &-label { + font-size: var(--tk-font-size-cap, 13px); + color: var(--tk-color-text-secondary, #999); + margin-top: 4px; + } +} + +.ai-summary-items { + border-top: 1px solid var(--tk-color-border, #f0f0f0); + padding-top: 20px; +} + +.ai-summary-item { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 0; + + &-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + &-title { + font-size: var(--tk-font-size-body-sm, 14px); + color: var(--tk-color-text, #333); + line-height: 1.4; + } +} + +.ai-summary-loading { + display: flex; + justify-content: center; + padding: 32px 0; + + &-text { + font-size: var(--tk-font-size-body-sm, 14px); + color: var(--tk-color-text-secondary, #999); + } +} diff --git a/apps/miniprogram/src/components/ui/AiHealthSummaryCard/index.tsx b/apps/miniprogram/src/components/ui/AiHealthSummaryCard/index.tsx new file mode 100644 index 0000000..6daef51 --- /dev/null +++ b/apps/miniprogram/src/components/ui/AiHealthSummaryCard/index.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text } from '@tarojs/components'; +import ContentCard from '../ContentCard'; +import { getHealthSummary, type HealthSummary } from '../../../services/ai-analysis'; +import './index.scss'; + +interface AiHealthSummaryCardProps { + patientId: string; +} + +const RISK_COLORS: Record = { + critical: 'var(--tk-color-danger, #ff4d4f)', + high: 'var(--tk-color-warning, #faad14)', + medium: 'var(--tk-color-info, #1890ff)', + low: 'var(--tk-color-success, #52c41a)', +}; + +const RISK_LABELS: Record = { + critical: '高风险', + high: '较高风险', + medium: '中等风险', + low: '低风险', +}; + +const AiHealthSummaryCard: React.FC = ({ patientId }) => { + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!patientId) return; + setLoading(true); + getHealthSummary(patientId) + .then((data) => setSummary(data)) + .catch(() => setSummary(null)) + .finally(() => setLoading(false)); + }, [patientId]); + + if (loading) { + return ( + + + AI 健康摘要加载中... + + + ); + } + + if (!summary) return null; + + const riskColor = RISK_COLORS[summary.risk_level] || RISK_COLORS.low; + const riskLabel = RISK_LABELS[summary.risk_level] || '低风险'; + + return ( + + + AI 健康摘要 + + {riskLabel} + + + + {summary.latest_insight_title && ( + + 最新洞察 + {summary.latest_insight_title} + + )} + + + + {summary.active_insights_count} + 活跃洞察 + + + {summary.recent_analyses_count} + AI 分析 + + + + {summary.summary_items.length > 0 && ( + + {summary.summary_items.slice(0, 3).map((item, idx) => ( + + + {item.title} + + ))} + + )} + + ); +}; + +export default React.memo(AiHealthSummaryCard); diff --git a/apps/miniprogram/src/services/ai-analysis.ts b/apps/miniprogram/src/services/ai-analysis.ts index b4c0125..4490eb7 100644 --- a/apps/miniprogram/src/services/ai-analysis.ts +++ b/apps/miniprogram/src/services/ai-analysis.ts @@ -40,3 +40,29 @@ export async function listPendingSuggestions() { ); return resp.data || []; } + +// === 健康摘要 === + +export interface SummaryItem { + category: string; + title: string; + severity: string | null; + created_at: string; +} + +export interface HealthSummary { + patient_id: string; + risk_level: string; + active_insights_count: number; + recent_analyses_count: number; + latest_insight_title: string | null; + latest_analysis_type: string | null; + summary_items: SummaryItem[]; +} + +export async function getHealthSummary(patientId: string) { + return api.get( + '/ai/health-summary', + { patient_id: patientId }, + ); +} diff --git a/crates/erp-ai/src/handler/chat_handler.rs b/crates/erp-ai/src/handler/chat_handler.rs index e48bca1..8c3b64c 100644 --- a/crates/erp-ai/src/handler/chat_handler.rs +++ b/crates/erp-ai/src/handler/chat_handler.rs @@ -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, diff --git a/crates/erp-ai/src/handler/insight_handler.rs b/crates/erp-ai/src/handler/insight_handler.rs index 7b38784..b0e533b 100644 --- a/crates/erp-ai/src/handler/insight_handler.rs +++ b/crates/erp-ai/src/handler/insight_handler.rs @@ -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, + pub latest_analysis_type: Option, + pub summary_items: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct SummaryItem { + pub category: String, + pub title: String, + pub severity: Option, + 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( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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 = 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 = 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 = 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, + }))) +} diff --git a/crates/erp-ai/src/handler/mod.rs b/crates/erp-ai/src/handler/mod.rs index c8bb4a8..ca68a8e 100644 --- a/crates/erp-ai/src/handler/mod.rs +++ b/crates/erp-ai/src/handler/mod.rs @@ -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, diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index b8b1f03..c604e24 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -490,6 +490,10 @@ impl AiModule { "/ai/quota/summary", axum::routing::get(crate::handler::quota_summary), ) + .route( + "/ai/health-summary", + axum::routing::get(crate::handler::insight_handler::health_summary), + ) .route( "/ai/budget/status", axum::routing::get(crate::handler::budget_status),