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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
critical: '高风险',
|
||||||
|
high: '较高风险',
|
||||||
|
medium: '中等风险',
|
||||||
|
low: '低风险',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AiHealthSummaryCard: React.FC<AiHealthSummaryCardProps> = ({ patientId }) => {
|
||||||
|
const [summary, setSummary] = useState<HealthSummary | null>(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 (
|
||||||
|
<ContentCard>
|
||||||
|
<View className='ai-summary-loading'>
|
||||||
|
<Text className='ai-summary-loading-text'>AI 健康摘要加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</ContentCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
const riskColor = RISK_COLORS[summary.risk_level] || RISK_COLORS.low;
|
||||||
|
const riskLabel = RISK_LABELS[summary.risk_level] || '低风险';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentCard>
|
||||||
|
<View className='ai-summary-header'>
|
||||||
|
<Text className='ai-summary-title'>AI 健康摘要</Text>
|
||||||
|
<View className='ai-summary-risk' style={{ backgroundColor: riskColor }}>
|
||||||
|
<Text className='ai-summary-risk-text'>{riskLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{summary.latest_insight_title && (
|
||||||
|
<View className='ai-summary-insight'>
|
||||||
|
<Text className='ai-summary-insight-label'>最新洞察</Text>
|
||||||
|
<Text className='ai-summary-insight-text'>{summary.latest_insight_title}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='ai-summary-stats'>
|
||||||
|
<View className='ai-summary-stat'>
|
||||||
|
<Text className='ai-summary-stat-value'>{summary.active_insights_count}</Text>
|
||||||
|
<Text className='ai-summary-stat-label'>活跃洞察</Text>
|
||||||
|
</View>
|
||||||
|
<View className='ai-summary-stat'>
|
||||||
|
<Text className='ai-summary-stat-value'>{summary.recent_analyses_count}</Text>
|
||||||
|
<Text className='ai-summary-stat-label'>AI 分析</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{summary.summary_items.length > 0 && (
|
||||||
|
<View className='ai-summary-items'>
|
||||||
|
{summary.summary_items.slice(0, 3).map((item, idx) => (
|
||||||
|
<View key={idx} className='ai-summary-item'>
|
||||||
|
<View
|
||||||
|
className='ai-summary-item-dot'
|
||||||
|
style={{ backgroundColor: item.severity ? (RISK_COLORS[item.severity] || riskColor) : riskColor }}
|
||||||
|
/>
|
||||||
|
<Text className='ai-summary-item-title'>{item.title}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(AiHealthSummaryCard);
|
||||||
@@ -40,3 +40,29 @@ export async function listPendingSuggestions() {
|
|||||||
);
|
);
|
||||||
return resp.data || [];
|
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<HealthSummary>(
|
||||||
|
'/ai/health-summary',
|
||||||
|
{ patient_id: patientId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ where
|
|||||||
"AI Agent chat request"
|
"AI Agent chat request"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let provider_name = provider_arc.name().to_string();
|
||||||
|
|
||||||
// 执行 Agent ReAct 循环
|
// 执行 Agent ReAct 循环
|
||||||
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
|
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
|
||||||
let result = orchestrator
|
let result = orchestrator
|
||||||
@@ -170,6 +172,25 @@ where
|
|||||||
"AI Agent response sent"
|
"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 {
|
Ok(Json(ApiResponse::ok(ChatResponse {
|
||||||
reply: result.reply,
|
reply: result.reply,
|
||||||
message_id,
|
message_id,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use axum::Json;
|
|||||||
use axum::extract::{Extension, FromRef, Path, Query, State};
|
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||||
use erp_core::rbac::require_permission;
|
use erp_core::rbac::require_permission;
|
||||||
use erp_core::types::{ApiResponse, TenantContext};
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::dto::copilot::ListInsightsQuery;
|
use crate::dto::copilot::ListInsightsQuery;
|
||||||
use crate::state::AiState;
|
use crate::state::AiState;
|
||||||
@@ -112,3 +112,134 @@ where
|
|||||||
serde_json::json!({"dismissed": true}),
|
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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -880,6 +880,22 @@ fn build_sse_stream(
|
|||||||
let metadata = serde_json::json!({"analysis_type": analysis_type});
|
let metadata = serde_json::json!({"analysis_type": analysis_type});
|
||||||
let _ = state.analysis.complete_analysis(analysis_id, full_content.clone(), metadata.clone()).await;
|
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(
|
crate::service::post_process::post_process_analysis(
|
||||||
&state,
|
&state,
|
||||||
|
|||||||
@@ -490,6 +490,10 @@ impl AiModule {
|
|||||||
"/ai/quota/summary",
|
"/ai/quota/summary",
|
||||||
axum::routing::get(crate::handler::quota_summary),
|
axum::routing::get(crate::handler::quota_summary),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/ai/health-summary",
|
||||||
|
axum::routing::get(crate::handler::insight_handler::health_summary),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/ai/budget/status",
|
"/ai/budget/status",
|
||||||
axum::routing::get(crate::handler::budget_status),
|
axum::routing::get(crate::handler::budget_status),
|
||||||
|
|||||||
Reference in New Issue
Block a user