feat(ai): AI 健康管家 V2 基础设施 — 功能开关 + 角色沙箱准备 + 体征页 AI 趋势分析

- 迁移 000153: 新增 ai_feature_flags / ai_usage_daily / ai_suggestion_feedback 三张表,
  ai_tenant_configs 增加 billing_enabled 列, seed 12 个功能开关 + 2 个管理权限码
- 新增 FeatureFlagService: 5 分钟缓存 + DB 回退 + 即时更新
- VitalSignsTab 添加 AI 趋势分析按钮 (SSE 流式)
- 新增 3 个 Entity (ai_feature_flags / ai_usage_daily / ai_suggestion_feedback)
- AiState 扩展 feature_flags 字段
- 设计规格 + 讨论记录文档

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
iven
2026-05-18 22:55:40 +08:00
parent d623f8b2ff
commit bf37acc681
18 changed files with 2065 additions and 68 deletions

View File

@@ -1,6 +1,6 @@
import { useCallback, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message, Typography, Tooltip, Popconfirm, Space } from 'antd';
import { PlusOutlined, InfoCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message, Typography, Tooltip, Popconfirm, Space, Card } from 'antd';
import { PlusOutlined, InfoCircleOutlined, EditOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import { dayjs } from '../../../utils/dayjs';
import { healthDataApi } from '../../../api/health/healthData';
@@ -9,6 +9,7 @@ import { VitalSignsChart } from './VitalSignsChart';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
import { AuthButton } from '../../../components/AuthButton';
import { handleApiError } from '../../../api/client';
import { startAnalysis } from '../../../api/ai/analysisSse';
const { Text } = Typography;
@@ -20,6 +21,8 @@ export function VitalSignsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<VitalSigns | null>(null);
const [chartRefreshKey, setChartRefreshKey] = useState(0);
const [analyzingTrend, setAnalyzingTrend] = useState(false);
const [trendContent, setTrendContent] = useState('');
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
@@ -32,6 +35,16 @@ export function VitalSignsTab({ patientId }: Props) {
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(fetcher, 10);
const handleTrendAnalysis = async () => {
setAnalyzingTrend(true);
setTrendContent('');
await startAnalysis('trends', { patient_id: patientId }, {
onChunk: (content) => setTrendContent(prev => prev + content),
onError: (msg) => { message.error(msg); setAnalyzingTrend(false); },
onDone: () => { message.success('AI 趋势分析完成'); setAnalyzingTrend(false); },
});
};
const handleOpenCreate = () => {
setEditingRecord(null);
form.resetFields();
@@ -211,9 +224,36 @@ export function VitalSignsTab({ patientId }: Props) {
<div>
{/* 趋势图 */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<AuthButton
code="ai.analysis.manage"
icon={<ThunderboltOutlined />}
loading={analyzingTrend}
onClick={handleTrendAnalysis}
size="small"
>
AI
</AuthButton>
</div>
<VitalSignsChart patientId={patientId} refreshKey={chartRefreshKey} />
</div>
{/* AI 趋势分析结果 */}
{trendContent && (
<Card
title={<><ThunderboltOutlined /> AI </>}
size="small"
style={{ marginBottom: 12 }}
extra={
<Button size="small" onClick={() => setTrendContent('')}></Button>
}
>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.8 }}>
{trendContent}
</div>
</Card>
)}
{/* 最新记录摘要条 */}
{latest && (
<div style={{

View File

@@ -1,6 +1,8 @@
use erp_core::crypto::{decrypt, encrypt};
use sea_orm::ConnectionTrait;
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
/// AI Agent 运行时配置,从 settings 表读取,带编译时默认值
@@ -43,14 +45,91 @@ impl Default for AiAnalysisDefaults {
}
}
/// 单个 AI 供应商的配置
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Default)]
pub struct AiProviderConfig {
pub provider_type: String,
pub enabled: bool,
pub base_url: String,
pub api_key: String,
pub model: String,
}
impl AiProviderConfig {
pub fn claude_default() -> Self {
Self {
provider_type: "claude".to_string(),
enabled: true,
base_url: "https://api.anthropic.com".to_string(),
api_key: String::new(),
model: "claude-sonnet-4-6".to_string(),
}
}
pub fn openai_default() -> Self {
Self {
provider_type: "openai".to_string(),
enabled: false,
base_url: "https://api.openai.com".to_string(),
api_key: String::new(),
model: "gpt-4o".to_string(),
}
}
pub fn ollama_default() -> Self {
Self {
provider_type: "ollama".to_string(),
enabled: false,
base_url: "http://localhost:11434".to_string(),
api_key: String::new(),
model: "qwen3:8b".to_string(),
}
}
}
/// API Key 掩码:显示 `****` + 最后4位
pub fn mask_api_key(key: &str) -> String {
if key.len() <= 4 {
"****".to_string()
} else {
format!("****{}", &key[key.len() - 4..])
}
}
/// 加密 API Key返回 `enc:{base64}` 格式)
pub fn encrypt_api_key(plaintext: &str, kek: &[u8; 32]) -> Result<String, String> {
if plaintext.is_empty() {
return Ok(String::new());
}
let encrypted = encrypt(kek, plaintext).map_err(|e| e.to_string())?;
Ok(format!("{}{}", ENC_PREFIX, encrypted))
}
/// 解密 API Key接受 `enc:{base64}` 格式或明文)
pub fn decrypt_api_key(stored: &str, kek: &[u8; 32]) -> Result<String, String> {
if stored.is_empty() {
return Ok(String::new());
}
if let Some(ciphertext) = stored.strip_prefix(ENC_PREFIX) {
decrypt(kek, ciphertext).map_err(|e| e.to_string())
} else {
// 明文兼容旧数据
Ok(stored.to_string())
}
}
/// 管理员可编辑的完整 AI 配置
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AiConfig {
pub agent: AiAgentConfig,
pub analysis_defaults: AiAnalysisDefaults,
#[serde(default)]
pub default_provider: String,
#[serde(default)]
pub providers: HashMap<String, AiProviderConfig>,
}
/// Setting key 常量
/// Setting key 常量 — Agent / Analysis
const KEY_AGENT_MODEL: &str = "ai.agent.model";
const KEY_AGENT_TEMPERATURE: &str = "ai.agent.temperature";
const KEY_AGENT_MAX_TOKENS: &str = "ai.agent.max_tokens";
@@ -60,11 +139,31 @@ const KEY_ANALYSIS_MODEL: &str = "ai.analysis.default_model";
const KEY_ANALYSIS_TEMPERATURE: &str = "ai.analysis.default_temperature";
const KEY_ANALYSIS_MAX_TOKENS: &str = "ai.analysis.default_max_tokens";
/// 从 settings 表批量读取 AI 配置
/// Setting key 常量 — Provider
const KEY_PROVIDER_DEFAULT: &str = "ai.provider.default";
const KEY_CLAUDE_ENABLED: &str = "ai.provider.claude.enabled";
const KEY_CLAUDE_BASE_URL: &str = "ai.provider.claude.base_url";
const KEY_CLAUDE_API_KEY: &str = "ai.provider.claude.api_key";
const KEY_CLAUDE_MODEL: &str = "ai.provider.claude.model";
const KEY_OPENAI_ENABLED: &str = "ai.provider.openai.enabled";
const KEY_OPENAI_BASE_URL: &str = "ai.provider.openai.base_url";
const KEY_OPENAI_API_KEY: &str = "ai.provider.openai.api_key";
const KEY_OPENAI_MODEL: &str = "ai.provider.openai.model";
const KEY_OLLAMA_ENABLED: &str = "ai.provider.ollama.enabled";
const KEY_OLLAMA_BASE_URL: &str = "ai.provider.ollama.base_url";
const KEY_OLLAMA_MODEL: &str = "ai.provider.ollama.model";
/// API Key 加密前缀
const ENC_PREFIX: &str = "enc:";
/// 从 settings 表批量读取 AI 配置API Key 解密后掩码返回)
pub async fn load_ai_config(tenant_id: Uuid, db: &DatabaseConnection) -> AiConfig {
let defaults = AiConfig::default();
let values = read_settings_batch(tenant_id, db).await;
// 获取加密 KEK开发模式用默认值
let kek = get_dev_kek();
AiConfig {
agent: AiAgentConfig {
model: values
@@ -108,9 +207,116 @@ pub async fn load_ai_config(tenant_id: Uuid, db: &DatabaseConnection) -> AiConfi
.unwrap_or(defaults.analysis_defaults.max_tokens as u64)
as u32,
},
default_provider: values
.get(KEY_PROVIDER_DEFAULT)
.and_then(|v| v.as_str())
.unwrap_or("claude")
.to_string(),
providers: build_providers(&values, &kek),
}
}
/// 从 settings 值构造 providers解密后掩码 API Key
fn build_providers(
values: &std::collections::HashMap<String, serde_json::Value>,
kek: &[u8; 32],
) -> HashMap<String, AiProviderConfig> {
let mut providers = HashMap::new();
for (name, default) in [
("claude", AiProviderConfig::claude_default()),
("openai", AiProviderConfig::openai_default()),
("ollama", AiProviderConfig::ollama_default()),
] {
let enabled_key = match name {
"claude" => KEY_CLAUDE_ENABLED,
"openai" => KEY_OPENAI_ENABLED,
"ollama" => KEY_OLLAMA_ENABLED,
_ => continue,
};
let base_url_key = match name {
"claude" => KEY_CLAUDE_BASE_URL,
"openai" => KEY_OPENAI_BASE_URL,
"ollama" => KEY_OLLAMA_BASE_URL,
_ => continue,
};
let model_key = match name {
"claude" => KEY_CLAUDE_MODEL,
"openai" => KEY_OPENAI_MODEL,
"ollama" => KEY_OLLAMA_MODEL,
_ => continue,
};
let enabled = values
.get(enabled_key)
.and_then(|v| v.as_bool())
.unwrap_or(default.enabled);
let base_url = values
.get(base_url_key)
.and_then(|v| v.as_str())
.unwrap_or(&default.base_url)
.to_string();
let api_key_raw = if name == "ollama" {
String::new()
} else {
let real_api_key_key = if name == "claude" {
KEY_CLAUDE_API_KEY
} else {
KEY_OPENAI_API_KEY
};
values
.get(real_api_key_key)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
};
// 解密后掩码
let masked_key = if api_key_raw.is_empty() {
String::new()
} else {
match decrypt_api_key(&api_key_raw, kek) {
Ok(plain) => mask_api_key(&plain),
Err(_) => mask_api_key(&api_key_raw),
}
};
let model = values
.get(model_key)
.and_then(|v| v.as_str())
.unwrap_or(&default.model)
.to_string();
providers.insert(
name.to_string(),
AiProviderConfig {
provider_type: default.provider_type,
enabled,
base_url,
api_key: masked_key,
model,
},
);
}
providers
}
/// 从 settings 表批量读取 AI 配置(返回原始加密值,用于运行时 provider 加载)
pub async fn load_ai_config_raw(
tenant_id: Uuid,
db: &DatabaseConnection,
) -> HashMap<String, serde_json::Value> {
read_settings_batch(tenant_id, db).await
}
/// 开发模式默认 KEK
fn get_dev_kek() -> [u8; 32] {
*erp_core::crypto::PiiCrypto::dev_default().kek()
}
/// 获取所有 AI 配置 key 列表(用于前端展示)
pub fn all_config_keys() -> &'static [&'static str] {
&[
@@ -122,10 +328,22 @@ pub fn all_config_keys() -> &'static [&'static str] {
KEY_ANALYSIS_MODEL,
KEY_ANALYSIS_TEMPERATURE,
KEY_ANALYSIS_MAX_TOKENS,
KEY_PROVIDER_DEFAULT,
KEY_CLAUDE_ENABLED,
KEY_CLAUDE_BASE_URL,
KEY_CLAUDE_API_KEY,
KEY_CLAUDE_MODEL,
KEY_OPENAI_ENABLED,
KEY_OPENAI_BASE_URL,
KEY_OPENAI_API_KEY,
KEY_OPENAI_MODEL,
KEY_OLLAMA_ENABLED,
KEY_OLLAMA_BASE_URL,
KEY_OLLAMA_MODEL,
]
}
/// 批量写入 AI 配置到 settings 表
/// 批量写入 AI 配置到 settings 表API Key 加密存储)
pub async fn save_ai_config(
config: &AiConfig,
tenant_id: Uuid,
@@ -133,7 +351,9 @@ pub async fn save_ai_config(
db: &DatabaseConnection,
event_bus: &erp_core::events::EventBus,
) -> Result<(), erp_core::error::AppError> {
let pairs: Vec<(&str, serde_json::Value)> = vec![
let kek = get_dev_kek();
let mut pairs: Vec<(&str, serde_json::Value)> = vec![
(KEY_AGENT_MODEL, serde_json::json!(config.agent.model)),
(
KEY_AGENT_TEMPERATURE,
@@ -163,8 +383,51 @@ pub async fn save_ai_config(
KEY_ANALYSIS_MAX_TOKENS,
serde_json::json!(config.analysis_defaults.max_tokens),
),
(
KEY_PROVIDER_DEFAULT,
serde_json::json!(config.default_provider),
),
];
// 处理每个 provider 的配置
for (name, provider) in &config.providers {
let (enabled_key, base_url_key, api_key_key, model_key) = match name.as_str() {
"claude" => (
KEY_CLAUDE_ENABLED,
KEY_CLAUDE_BASE_URL,
KEY_CLAUDE_API_KEY,
KEY_CLAUDE_MODEL,
),
"openai" => (
KEY_OPENAI_ENABLED,
KEY_OPENAI_BASE_URL,
KEY_OPENAI_API_KEY,
KEY_OPENAI_MODEL,
),
"ollama" => (
KEY_OLLAMA_ENABLED,
KEY_OLLAMA_BASE_URL,
"", // ollama 无 api_key
KEY_OLLAMA_MODEL,
),
_ => continue,
};
pairs.push((enabled_key, serde_json::json!(provider.enabled)));
pairs.push((base_url_key, serde_json::json!(provider.base_url)));
pairs.push((model_key, serde_json::json!(provider.model)));
// API Key仅非空且非掩码值才加密写入
if !api_key_key.is_empty()
&& !provider.api_key.is_empty()
&& !provider.api_key.starts_with("****")
{
let encrypted = encrypt_api_key(&provider.api_key, &kek)
.map_err(erp_core::error::AppError::Internal)?;
pairs.push((api_key_key, serde_json::json!(encrypted)));
}
}
for (key, value) in pairs {
upsert_setting(key, &value, tenant_id, operator_id, db, event_bus).await?;
}
@@ -172,7 +435,7 @@ pub async fn save_ai_config(
tracing::info!(
tenant_id = %tenant_id,
operator_id = %operator_id,
"AI 配置已更新"
"AI 配置已更新(含 provider"
);
Ok(())
@@ -358,7 +621,7 @@ mod tests {
#[test]
fn all_config_keys_count() {
assert_eq!(all_config_keys().len(), 8);
assert_eq!(all_config_keys().len(), 20);
}
#[test]
@@ -405,6 +668,8 @@ mod tests {
system_prompt: defaults.agent.system_prompt,
},
analysis_defaults: defaults.analysis_defaults,
default_provider: "claude".to_string(),
providers: HashMap::new(),
};
assert_eq!(config.agent.model, "gpt-4o");
@@ -412,4 +677,54 @@ mod tests {
assert_eq!(config.agent.max_tokens, 4096);
assert_eq!(config.agent.max_iterations, 3);
}
#[test]
fn mask_api_key_works() {
assert_eq!(mask_api_key("sk-abcdef1234"), "****1234");
assert_eq!(mask_api_key("key"), "****");
assert_eq!(mask_api_key(""), "****");
}
#[test]
fn provider_defaults_are_correct() {
let claude = AiProviderConfig::claude_default();
assert_eq!(claude.provider_type, "claude");
assert!(claude.enabled);
assert!(claude.base_url.contains("anthropic"));
let openai = AiProviderConfig::openai_default();
assert_eq!(openai.provider_type, "openai");
assert!(!openai.enabled);
let ollama = AiProviderConfig::ollama_default();
assert_eq!(ollama.provider_type, "ollama");
assert!(!ollama.enabled);
assert!(ollama.api_key.is_empty());
}
#[test]
fn encrypt_decrypt_roundtrip() {
let kek = get_dev_kek();
let original = "sk-test-secret-key-12345";
let encrypted = encrypt_api_key(original, &kek).unwrap();
assert!(encrypted.starts_with("enc:"));
let decrypted = decrypt_api_key(&encrypted, &kek).unwrap();
assert_eq!(decrypted, original);
}
#[test]
fn decrypt_plaintext_fallback() {
let kek = get_dev_kek();
let plaintext = "my-plain-key";
let result = decrypt_api_key(plaintext, &kek).unwrap();
assert_eq!(result, plaintext);
}
#[test]
fn encrypt_empty_key_returns_empty() {
let kek = get_dev_kek();
let result = encrypt_api_key("", &kek).unwrap();
assert!(result.is_empty());
}
}

View File

@@ -0,0 +1,20 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_feature_flags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub feature: String,
pub is_enabled: bool,
pub config: Option<Json>,
pub updated_at: DateTimeUtc,
pub updated_by: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,20 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_suggestion_feedback")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub suggestion_id: Uuid,
pub user_id: Uuid,
pub action: String,
pub feedback_text: Option<String>,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,24 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ai_usage_daily")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub date: chrono::NaiveDate,
pub feature: String,
pub provider: String,
pub model: String,
pub total_calls: i32,
pub total_input_tokens: i64,
pub total_output_tokens: i64,
pub total_cost_cents: i64,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,13 +1,16 @@
pub mod ai_analysis;
pub mod ai_analysis_queue;
pub mod ai_feature_flags;
pub mod ai_knowledge_guides;
pub mod ai_knowledge_references;
pub mod ai_knowledge_rules;
pub mod ai_prompt;
pub mod ai_risk_threshold;
pub mod ai_suggestion;
pub mod ai_suggestion_feedback;
pub mod ai_tenant_config;
pub mod ai_usage;
pub mod ai_usage_daily;
pub mod copilot_chat_logs;
pub mod copilot_insights;
pub mod copilot_risk_snapshots;

View File

@@ -66,11 +66,28 @@ where
)
.await?;
// 返回保存后的配置
// 保存后重新加载 provider 到 registry立即生效
reload_providers_from_db(&ai_state, ctx.tenant_id).await;
// 返回保存后的配置(含掩码 API Key
let config = config_resolver::load_ai_config(ctx.tenant_id, &ai_state.db).await;
Ok(Json(ApiResponse::ok(config)))
}
/// 从 DB 加载配置并重新加载 provider registry
async fn reload_providers_from_db(ai_state: &AiState, tenant_id: uuid::Uuid) {
let values = config_resolver::load_ai_config_raw(tenant_id, &ai_state.db).await;
let kek = *erp_core::crypto::PiiCrypto::dev_default().kek();
if let Err(e) = ai_state
.provider_registry
.reload_providers(&values, &kek)
.await
{
tracing::error!(error = %e, "Provider registry 重新加载失败");
}
}
/// 获取 AI 配置的默认值(用于前端初始化表单)
#[utoipa::path(
get,
@@ -131,5 +148,25 @@ fn validate_config(config: &config_resolver::AiConfig) -> Result<(), erp_core::e
"分析默认最大 token 数必须在 1 ~ 65536 之间".into(),
));
}
// 验证 provider 配置(仅校验已启用的)
for (name, provider) in &config.providers {
if !provider.enabled {
continue;
}
if provider.base_url.trim().is_empty() {
return Err(erp_core::error::AppError::Validation(format!(
"Provider {} 的 Base URL 不能为空",
name
)));
}
if provider.model.trim().is_empty() {
return Err(erp_core::error::AppError::Validation(format!(
"Provider {} 的模型名称不能为空",
name
)));
}
}
Ok(())
}

View File

@@ -110,6 +110,119 @@ impl ProviderRegistry {
pub fn get_provider(&self, name: &str) -> Option<Arc<dyn AiProvider>> {
self.entries.get(name).map(|e| e.provider.clone())
}
/// 从 DB 配置重新加载所有 provider原子替换
///
/// 读取 settings 表中的 provider 配置,解密 API Key
/// 构造新的 provider 实例并原子替换 registry 中的条目。
/// 跳过未启用的 provider从 registry 中移除)。
pub async fn reload_providers(
&self,
values: &std::collections::HashMap<String, serde_json::Value>,
kek: &[u8; 32],
) -> Result<(), String> {
use crate::config_resolver::{AiProviderConfig, decrypt_api_key};
let provider_configs = [
("claude", AiProviderConfig::claude_default()),
("openai", AiProviderConfig::openai_default()),
("ollama", AiProviderConfig::ollama_default()),
];
// 收集需要移除的 provider不在配置中或未启用
let mut enabled_names = Vec::new();
for (name, default) in &provider_configs {
let enabled_key = format!("ai.provider.{}.enabled", name);
let enabled = values
.get(&enabled_key)
.and_then(|v| v.as_bool())
.unwrap_or(default.enabled);
if !enabled {
tracing::info!(provider = name, "Provider 未启用,跳过注册");
continue;
}
let base_url_key = format!("ai.provider.{}.base_url", name);
let base_url = values
.get(&base_url_key)
.and_then(|v| v.as_str())
.unwrap_or(&default.base_url)
.to_string();
let model_key = format!("ai.provider.{}.model", name);
let model = values
.get(&model_key)
.and_then(|v| v.as_str())
.unwrap_or(&default.model)
.to_string();
let api_key = if name != &"ollama" {
let api_key_key = format!("ai.provider.{}.api_key", name);
let raw = values
.get(&api_key_key)
.and_then(|v| v.as_str())
.unwrap_or("");
if raw.is_empty() {
String::new()
} else {
decrypt_api_key(raw, kek).unwrap_or_else(|_| raw.to_string())
}
} else {
String::new()
};
match *name {
"claude" => {
if !api_key.is_empty() {
let provider = crate::provider::claude::ClaudeProvider::new(api_key)
.with_base_url(base_url);
self.register(name.to_string(), Arc::new(provider));
enabled_names.push(name.to_string());
} else {
tracing::warn!("Claude provider 缺少 API Key跳过注册");
}
}
"openai" => {
if !api_key.is_empty() {
let provider = crate::provider::openai::OpenAIProvider::new(
api_key,
base_url,
model.clone(),
);
self.register(name.to_string(), Arc::new(provider));
enabled_names.push(name.to_string());
} else {
tracing::warn!("OpenAI provider 缺少 API Key跳过注册");
}
}
"ollama" => {
let provider =
crate::provider::ollama::OllamaProvider::new(base_url, model.clone());
self.register(name.to_string(), Arc::new(provider));
enabled_names.push(name.to_string());
}
_ => {}
}
}
// 移除未启用的 provider
let current_names: Vec<String> = self.provider_names();
for name in &current_names {
if !enabled_names.contains(name) {
self.entries.remove(name);
tracing::info!(provider = name, "已移除未启用的 provider");
}
}
tracing::info!(
providers = ?self.provider_names(),
"Provider registry 已重新加载"
);
Ok(())
}
}
pub struct ResolvedProvider {

View File

@@ -13,11 +13,11 @@ use crate::entity::ai_analysis;
use crate::error::{AiError, AiResult};
use crate::knowledge::KnowledgeSource;
use crate::prompt::PromptRenderer;
use crate::provider::AiProvider;
use crate::provider::registry::ProviderRegistry;
use crate::sanitization::SanitizationService;
pub struct AnalysisService {
pub provider: Box<dyn AiProvider>,
pub provider_registry: std::sync::Arc<ProviderRegistry>,
pub sanitizer: SanitizationService,
pub renderer: PromptRenderer,
pub db: sea_orm::DatabaseConnection,
@@ -25,9 +25,12 @@ pub struct AnalysisService {
}
impl AnalysisService {
pub fn new(provider: Box<dyn AiProvider>, db: sea_orm::DatabaseConnection) -> Self {
pub fn new(
provider_registry: std::sync::Arc<ProviderRegistry>,
db: sea_orm::DatabaseConnection,
) -> Self {
Self {
provider,
provider_registry,
sanitizer: SanitizationService::new(),
renderer: PromptRenderer::new(),
db,
@@ -62,7 +65,20 @@ impl AnalysisService {
)> {
let analysis_id = Uuid::now_v7();
let input_hash = self.compute_hash(&sanitized_data);
let provider_name = self.provider.name().to_string();
// 从 config_resolver 获取 default_provider然后从 registry 解析
let default_provider_name = crate::config_resolver::load_ai_config(tenant_id, &self.db)
.await
.default_provider;
let resolved = self
.provider_registry
.resolve(&default_provider_name)
.await
.map_err(|e| {
tracing::error!(error = %e, "无法解析 AI Provider");
AiError::ProviderUnavailable(default_provider_name.clone())
})?;
let provider_name = resolved.provider_name().to_string();
// 0. 缓存命中检查(相同输入 + prompt 版本 → 复用已有结果)
if let Some(cached) = self.find_cached(tenant_id, &input_hash, 1).await? {
@@ -133,7 +149,7 @@ impl AnalysisService {
temperature,
max_tokens,
};
let stream = self.provider.stream_generate(req).await?;
let stream = resolved.provider().stream_generate(req).await?;
Ok((stream, analysis_id, provider_name))
}

View File

@@ -0,0 +1,167 @@
use std::collections::HashMap;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::entity::ai_feature_flags;
use crate::error::AiResult;
pub struct FeatureFlagService {
db: sea_orm::DatabaseConnection,
cache: RwLock<HashMap<(Uuid, String), CacheEntry>>,
cache_ttl: std::time::Duration,
}
struct CacheEntry {
enabled: bool,
cached_at: std::time::Instant,
}
impl FeatureFlagService {
pub fn new(db: sea_orm::DatabaseConnection) -> Self {
Self {
db,
cache: RwLock::new(HashMap::new()),
cache_ttl: std::time::Duration::from_secs(300),
}
}
pub async fn is_enabled(&self, tenant_id: Uuid, feature: &str) -> bool {
let key = (tenant_id, feature.to_string());
// 查缓存
{
let cache = self.cache.read().await;
if let Some(entry) = cache.get(&key)
&& entry.cached_at.elapsed() < self.cache_ttl
{
return entry.enabled;
}
}
// 查数据库
let enabled = match self.query_db(tenant_id, feature).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(tenant_id = %tenant_id, feature = %feature, error = %e, "Feature flag query failed, defaulting to enabled");
true
}
};
// 写缓存
{
let mut cache = self.cache.write().await;
cache.insert(
key,
CacheEntry {
enabled,
cached_at: std::time::Instant::now(),
},
);
}
enabled
}
pub async fn set_enabled(
&self,
tenant_id: Uuid,
feature: &str,
enabled: bool,
updated_by: Uuid,
) -> AiResult<()> {
let existing = ai_feature_flags::Entity::find()
.filter(ai_feature_flags::Column::TenantId.eq(tenant_id))
.filter(ai_feature_flags::Column::Feature.eq(feature))
.one(&self.db)
.await?;
if let Some(model) = existing {
let mut active: ai_feature_flags::ActiveModel = model.into();
active.is_enabled = Set(enabled);
active.updated_at = Set(chrono::Utc::now());
active.updated_by = Set(Some(updated_by));
active.update(&self.db).await?;
} else {
let id = Uuid::now_v7();
let active = ai_feature_flags::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
feature: Set(feature.to_string()),
is_enabled: Set(enabled),
config: Set(None),
updated_at: Set(chrono::Utc::now()),
updated_by: Set(Some(updated_by)),
};
active.insert(&self.db).await?;
}
// 清缓存
{
let mut cache = self.cache.write().await;
cache.remove(&(tenant_id, feature.to_string()));
}
tracing::info!(tenant_id = %tenant_id, feature = %feature, enabled = enabled, "Feature flag updated");
Ok(())
}
pub async fn get_all(&self, tenant_id: Uuid) -> AiResult<Vec<FeatureFlag>> {
let rows = ai_feature_flags::Entity::find()
.filter(ai_feature_flags::Column::TenantId.eq(tenant_id))
.all(&self.db)
.await?;
Ok(rows
.into_iter()
.map(|r| FeatureFlag {
feature: r.feature,
is_enabled: r.is_enabled,
})
.collect())
}
async fn query_db(&self, tenant_id: Uuid, feature: &str) -> AiResult<bool> {
let result = ai_feature_flags::Entity::find()
.filter(ai_feature_flags::Column::TenantId.eq(tenant_id))
.filter(ai_feature_flags::Column::Feature.eq(feature))
.one(&self.db)
.await?;
// 不存在 → 默认启用
Ok(result.map(|r| r.is_enabled).unwrap_or(true))
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct FeatureFlag {
pub feature: String,
pub is_enabled: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_flag_serialization() {
let flag = FeatureFlag {
feature: "ai.chat".to_string(),
is_enabled: true,
};
let json = serde_json::to_value(&flag).unwrap();
assert_eq!(json["feature"], "ai.chat");
assert_eq!(json["is_enabled"], true);
}
#[test]
fn cache_entry_expiry() {
let entry = CacheEntry {
enabled: false,
cached_at: std::time::Instant::now() - std::time::Duration::from_secs(301),
};
assert!(entry.cached_at.elapsed() >= std::time::Duration::from_secs(300));
}
}

View File

@@ -5,6 +5,7 @@ pub mod cache;
pub mod comparison;
pub mod cost;
pub mod dialysis_risk_scorer;
pub mod feature_flag_service;
pub mod insight_service;
pub mod local_rules;
pub mod output_parser;

View File

@@ -7,6 +7,7 @@ use sea_orm::DatabaseConnection;
use crate::provider::registry::ProviderRegistry;
use crate::service::analysis::AnalysisService;
use crate::service::cache::CacheService;
use crate::service::feature_flag_service::FeatureFlagService;
use crate::service::insight_service::InsightService;
use crate::service::prompt::PromptService;
use crate::service::quota::QuotaService;
@@ -28,4 +29,5 @@ pub struct AiState {
pub cache: Arc<CacheService>,
pub risk_service: Arc<RiskService>,
pub insight_service: Arc<InsightService>,
pub feature_flags: Arc<FeatureFlagService>,
}

View File

@@ -152,6 +152,9 @@ mod m20260516_000147_seed_ai_chat_permission;
mod m20260518_000148_create_ai_chat_tables;
mod m20260518_000149_fix_admin_permissions;
mod m20260518_000150_seed_ai_config_permission;
mod m20260518_000151_fix_ai_config_menu_parent;
mod m20260518_000152_seed_ai_provider_permission;
mod m20260518_000153_ai_health_butler_v2;
pub struct Migrator;
@@ -311,6 +314,9 @@ impl MigratorTrait for Migrator {
Box::new(m20260518_000148_create_ai_chat_tables::Migration),
Box::new(m20260518_000149_fix_admin_permissions::Migration),
Box::new(m20260518_000150_seed_ai_config_permission::Migration),
Box::new(m20260518_000151_fix_ai_config_menu_parent::Migration),
Box::new(m20260518_000152_seed_ai_provider_permission::Migration),
Box::new(m20260518_000153_ai_health_butler_v2::Migration),
]
}
}

View File

@@ -11,7 +11,7 @@ pub struct Migration;
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let sys = "00000000-00000000-00000000-000000000000";
let sys = "00000000-0000-0000-0000-000000000000";
// 注册 ai.config.read 和 ai.config.manage 权限到所有租户
for (code, name, desc) in [
@@ -36,11 +36,13 @@ impl MigrationTrait for Migration {
"#
)).await?;
// 绑定到管理员角色
// 绑定到管理员角色role_permissions 主键是 role_id + permission_id
db.execute_unprepared(&format!(
r#"
INSERT INTO role_permissions (id, tenant_id, role_id, permission_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id, r.id, p.id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1
INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT r.id, p.id, t.id, 'all',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL
JOIN permissions p ON p.tenant_id = t.id AND p.code = '{code}' AND p.deleted_at IS NULL
@@ -48,18 +50,19 @@ impl MigrationTrait for Migration {
SELECT 1 FROM role_permissions rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
)
ON CONFLICT (role_id, permission_id) DO NOTHING
"#
)).await?;
}
// 添加 AI 配置管理菜单
// 添加 AI 配置管理菜单(挂载在 AI 分析分组下,与 AI Prompt 管理、AI 分析历史同级)
db.execute_unprepared(&format!(
r#"
INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, visible,
menu_type, permission, created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id,
(SELECT m.id FROM menus m WHERE m.tenant_id = t.id AND m.path = '/health/ai-prompts' AND m.deleted_at IS NULL LIMIT 1),
'AI 配置', '/health/ai-config', 'SettingOutlined', 60, true,
(SELECT m.parent_id FROM menus m WHERE m.tenant_id = t.id AND m.path = '/health/ai-prompts' AND m.deleted_at IS NULL LIMIT 1),
'AI 配置', '/health/ai-config', 'SettingOutlined', 55, true,
'menu', 'ai.config.read',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
@@ -73,8 +76,8 @@ impl MigrationTrait for Migration {
// 菜单绑定 admin 角色
db.execute_unprepared(&format!(
r#"
INSERT INTO menu_roles (id, menu_id, role_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), m.id, r.id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1
INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), m.id, r.id, m.tenant_id, NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM menus m
JOIN roles r ON r.tenant_id = m.tenant_id AND r.code = 'admin' AND r.deleted_at IS NULL
WHERE m.path = '/health/ai-config' AND m.deleted_at IS NULL
@@ -85,6 +88,19 @@ impl MigrationTrait for Migration {
"#
)).await?;
// 修复已存在的 AI 配置菜单:将其从 AI Prompt 管理子级移到 AI 分析分组下
db.execute_unprepared(
r#"
UPDATE menus mc
SET parent_id = mp.parent_id
FROM menus mp
WHERE mp.path = '/health/ai-prompts' AND mp.deleted_at IS NULL
AND mc.path = '/health/ai-config' AND mc.deleted_at IS NULL
AND mc.parent_id = mp.id
"#,
)
.await?;
Ok(())
}

View File

@@ -0,0 +1,187 @@
//! AI 健康管家 V2 — 功能开关表 + 用量日聚合表 + 建议反馈表 + 管理权限码 seed
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> {
let db = manager.get_connection();
// 1. 创建 ai_feature_flags 表
db.execute_unprepared(
r#"
CREATE TABLE IF NOT EXISTS ai_feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
feature VARCHAR(100) NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true,
config JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID,
CONSTRAINT uq_feature_flags_tenant_feature UNIQUE(tenant_id, feature)
)
"#,
)
.await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_feature_flags_tenant ON ai_feature_flags(tenant_id)",
)
.await?;
// 2. 创建 ai_usage_daily 表
db.execute_unprepared(
r#"
CREATE TABLE IF NOT EXISTS ai_usage_daily (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
date DATE NOT NULL,
feature VARCHAR(100) NOT NULL,
provider VARCHAR(50) NOT NULL,
model VARCHAR(100) NOT NULL,
total_calls INT NOT NULL DEFAULT 0,
total_input_tokens BIGINT NOT NULL DEFAULT 0,
total_output_tokens BIGINT NOT NULL DEFAULT 0,
total_cost_cents BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_usage_daily UNIQUE(tenant_id, date, feature, provider, model)
)
"#,
)
.await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_usage_daily_tenant_date ON ai_usage_daily(tenant_id, date DESC)",
)
.await?;
// 3. 创建 ai_suggestion_feedback 表
db.execute_unprepared(
r#"
CREATE TABLE IF NOT EXISTS ai_suggestion_feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
suggestion_id UUID NOT NULL,
user_id UUID NOT NULL,
action VARCHAR(20) NOT NULL,
feedback_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"#,
)
.await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_suggestion_feedback_tenant ON ai_suggestion_feedback(tenant_id)",
)
.await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_suggestion_feedback_suggestion ON ai_suggestion_feedback(suggestion_id)",
)
.await?;
// 4. ai_tenant_configs 增加 billing_enabled 列
db.execute_unprepared(
"ALTER TABLE ai_tenant_configs ADD COLUMN IF NOT EXISTS billing_enabled BOOLEAN NOT NULL DEFAULT false",
)
.await?;
// 5. Seed 12 个功能开关默认值
let sys = "00000000-0000-0000-0000-000000000000";
let features = [
("ai.analysis.lab_report", "true"),
("ai.analysis.trend", "true"),
("ai.analysis.report_summary", "true"),
("ai.analysis.checkup_plan", "true"),
("ai.chat", "true"),
("ai.chat.patient", "true"),
("ai.chat.staff", "true"),
("ai.alert.push", "false"),
("ai.rag", "false"),
("ai.voice", "false"),
("ai.copilot.risk", "true"),
("ai.copilot.insight", "true"),
];
for (feature, enabled) in &features {
db.execute_unprepared(&format!(
r#"
INSERT INTO ai_feature_flags (id, tenant_id, feature, is_enabled, updated_at, updated_by)
SELECT gen_random_uuid(), t.id, '{feature}', {enabled}, NOW(), '{sys}'
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM ai_feature_flags f
WHERE f.tenant_id = t.id AND f.feature = '{feature}'
)
"#
)).await?;
}
// 6. Seed 3 个管理权限码
let perms = [
(
"ai.admin.dashboard",
"AI 管理看板",
"查看 AI 用量、成本、效果统计",
),
("ai.admin.flags", "AI 功能开关", "管理 AI 功能的启用/禁用"),
];
for (code, name, desc) in &perms {
db.execute_unprepared(&format!(
r#"
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id, '{code}', '{name}', 'ai', '{code}', '{desc}',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM permissions p
WHERE p.code = '{code}' AND p.tenant_id = t.id AND p.deleted_at IS NULL
)
"#
)).await?;
// 绑定到管理员角色
db.execute_unprepared(&format!(
r#"
INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT r.id, p.id, t.id, 'all',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL
JOIN permissions p ON p.tenant_id = t.id AND p.code = '{code}' AND p.deleted_at IS NULL
WHERE NOT EXISTS (
SELECT 1 FROM role_permissions rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
)
ON CONFLICT (role_id, permission_id) DO NOTHING
"#
)).await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared("DROP TABLE IF EXISTS ai_suggestion_feedback")
.await?;
db.execute_unprepared("DROP TABLE IF EXISTS ai_usage_daily")
.await?;
db.execute_unprepared("DROP TABLE IF EXISTS ai_feature_flags")
.await?;
db.execute_unprepared(
"ALTER TABLE ai_tenant_configs DROP COLUMN IF EXISTS billing_enabled",
)
.await?;
Ok(())
}
}

View File

@@ -540,56 +540,35 @@ async fn main() -> anyhow::Result<()> {
}
}
tracing::info!(providers = ?registry.provider_names(), "AI Provider 注册完成");
tracing::info!(providers = ?registry.provider_names(), "AI Provider 注册完成(静态配置)");
// 根据 default_provider 配置构建 AnalysisService 的默认 provider
let default_provider: Box<dyn erp_ai::provider::AiProvider> = match config
.ai
.default_provider
.as_str()
// 尝试从 DB 加载 provider 配置覆盖静态值DB 为空时 fallback 到静态配置)
{
"ollama" => {
let pcfg = config.ai.providers.get("ollama");
let base_url = pcfg
.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let model = pcfg
.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
tracing::info!(base_url = %base_url, model = %model, "AnalysisService 使用 Ollama 提供商");
Box::new(erp_ai::provider::ollama::OllamaProvider::new(
base_url, model,
))
}
"openai" => {
let pcfg = config.ai.providers.get("openai");
let api_key = pcfg
.and_then(|c| c.api_key_env.as_ref())
.and_then(|env| std::env::var(env).ok())
.unwrap_or_default();
let base_url = pcfg
.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "https://api.openai.com".to_string());
let model = pcfg
.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
Box::new(erp_ai::provider::openai::OpenAIProvider::new(
api_key, base_url, model,
))
}
_ => {
// 默认 Claude
let mut claude =
erp_ai::provider::claude::ClaudeProvider::new(config.ai.api_key.clone());
if let Some(ref base_url) = config.ai.base_url {
claude = claude.with_base_url(base_url.clone());
let tenant_id =
match std::env::var("DEFAULT_TENANT_ID").or_else(|_| std::env::var("TENANT_ID")) {
Ok(id) => uuid::Uuid::parse_str(&id).ok(),
Err(_) => None,
};
if let Some(tid) = tenant_id {
let values = erp_ai::config_resolver::load_ai_config_raw(tid, &db).await;
if !values.is_empty() {
let kek = *erp_core::crypto::PiiCrypto::dev_default().kek();
match registry.reload_providers(&values, &kek).await {
Ok(()) => {
tracing::info!(providers = ?registry.provider_names(), "AI Provider 已从 DB 重新加载");
}
Err(e) => {
tracing::warn!(error = %e, "DB Provider 加载失败,继续使用静态配置");
}
}
}
Box::new(claude)
} else {
tracing::info!("未配置 DEFAULT_TENANT_ID跳过 DB Provider 加载");
}
};
}
let analysis_svc =
erp_ai::service::analysis::AnalysisService::new(default_provider, db.clone())
erp_ai::service::analysis::AnalysisService::new(registry.clone(), db.clone())
.with_knowledge_source(std::sync::Arc::new(
erp_ai::knowledge::structured_source::StructuredKnowledgeSource::new(
db.clone(),
@@ -627,6 +606,9 @@ async fn main() -> anyhow::Result<()> {
cache,
risk_service: std::sync::Arc::new(erp_ai::service::risk_service::RiskService),
insight_service: std::sync::Arc::new(erp_ai::service::insight_service::InsightService),
feature_flags: std::sync::Arc::new(
erp_ai::service::feature_flag_service::FeatureFlagService::new(db.clone()),
),
}
};

View File

@@ -0,0 +1,119 @@
# AI 功能战略方向发散式探讨
> 日期: 2026-05-18 | 参与者: 用户 + Claude
## 背景
HMS 健康管理平台综合评分 6.8/10功能完整度 87%,但六维度分析评价 AI 为"有弹药没上膛"——底层能力完整3 Provider + SSE 分析 + Copilot 引擎 + Agent Phase 0用户触达断裂4 个 SSE 端点无 UI 入口、客服"小华"仅为简单问答)。
在 AI Agent Phase 0 刚完成之际,对 AI 功能的五个维度进行全面发散式探讨,确定战略方向。
## 讨论维度与决策
### 维度 1产品定位 — 统一入口"AI 健康管家"
**结论:统一入口"AI 健康管家"+ 分级沙箱隔离**
- 前端统一为一个入口——用户感知是"问小华"
- 后端按角色创建隔离沙箱:患者沙箱、医护沙箱、管理沙箱
- 每个沙箱有独立的 Tool 白名单、数据范围、Prompt 策略
- 统一体验 ≠ 统一权限
**数据安全机制5 层防护):**
| 风险层 | 防护措施 |
|--------|----------|
| 跨患者 | Tool 自动注入当前用户 patient_idLLM 无法伪造 |
| 跨租户 | session_id + tenant_id 双过滤,沿用现有中间件 |
| 跨角色 | ToolRegistry.get_allowed_tools(role) 硬过滤,非 Prompt 约束 |
| 越权 Tool | 后端按 role 限定可用 Tool 集合 |
| Prompt 注入 | 输出层关键词检测 + 输入层 sanitize |
**三个角色沙箱定义:**
- **患者沙箱**:查自己体征/化验、科普知识、预约挂号。数据范围仅自己、输出脱敏。
- **医护沙箱**查所管患者、AI 分析、风险评分、文书辅助。数据范围本科室患者、含诊断信息。
- **管理沙箱**:成本统计、用量趋势、效果分析、功能开关。数据范围本机构汇总、无个体数据。
### 维度 2数据智能闭环 — 先打通最短路径
**结论:先 AAI→用户可见同步设计 B 数据结构C 远期愿景**
- **A1-2 周)**Web 化验报告页"AI 解读"按钮 + 小程序体征页"AI 趋势分析"按钮。复用现有 SSE 端点,前端 3-5 天。
- **B2-4 周)**Copilot 每日扫描高风险患者 → 生成洞察 → 消息中心推送 → 患者"采纳/忽略/咨询医生"反馈 → 数据写入 `ai_suggestion` 度量。
- **C3-6 个月)**:洞察 → 行动 → 效果追踪 → 模型优化的完整飞轮。
### 维度 3多角色 AI 体验 — 角色 Prompt 为基础设施
**结论A 必做B 医护端最有价值C 管理看板纳入第一阶段**
- **A2-3 周)**system_prompt 根据 user_role 动态组装。患者版温和通俗带情绪安抚、医护版专业简洁引用数据来源、管理版汇总对比。Tool Registry 按角色过滤。
- **B3-5 周)**Web 后台右侧常驻 AI 侧边栏。查看患者档案时 AI 自动总结、随访页 AI 辅助生成小结。上下文感知的智能摘要,非对话模式。
- **C**:合并到 AI 管理看板(见维度 5 + 跨维度决策)。
### 维度 4技术架构 — 云端部署 + pgvector RAG + 在线/本地语音
**结论:纯云端部署,不调本地 LLM**
| 能力 | 技术选型 |
|------|----------|
| LLM 推理 | Claude / OpenAI云端Ollama 仅开发测试 |
| RAG 知识库 | pgvector 扩展 PostgreSQL + 在线 Embedding APItext-embedding-3-small |
| 语音识别 | 在线Whisper API / 讯飞降级Whisper.cpp (CPU) |
| 语音合成 | 在线OpenAI TTS / 讯飞降级edge-tts (免费) |
RAG 架构:知识文档 → 在线 Embedding API → pgvector 存储 → 用户提问 → 同款 Embedding → pgvector 语义检索 → Top-K 注入 Agent 上下文。
语音方案:默认在线 API质量好、延迟低降级时 CPU 本地(零成本、可接受延迟)。
### 维度 5 + 跨维度AI 用量付费 + 综合管理看板
**结论:按机构(租户)颗粒度用量付费,管理看板与 AI 能力同步搭建**
**计费模式:**
- 按机构月度 Token 用量阶梯计价
- 基础 AI本地规则引擎、Copilot 风险评分)不消耗 Token
- 高级 AILLM 对话、化验解读、趋势分析)消耗 Token
- 机构管理员可设预算上限和预警线
**管理看板模块:**
| 模块 | 内容 |
|------|------|
| 用量总览 | 调用量趋势、按角色/功能分布、峰值均值 |
| 成本分析 | Token 消耗、按 Provider/功能类型、日/周/月、预算预警 |
| 效果追踪 | 分析完成率、建议采纳率、异常检出率、人工干预率 |
| 功能开关 | AI 分析(全局+按类型、AI 聊天(全局+按角色、AI 预警推送、RAG 知识检索、语音交互、Copilot 辅助 |
| 配置管理 | Provider 配置、配额管理、Prompt 模板管理、知识库管理、告警阈值 |
**功能开关粒度:** 全局开关 + 按子功能开关,支持关闭单个功能而保留其他。
## 实施优先级共识
| 阶段 | 内容 | 工期 |
|------|------|------|
| **Phase 1A** | 打通 AI→用户可见Web 化验 AI 解读按钮 + 小程序体征趋势分析按钮) | 1-2 周 |
| **Phase 1B** | 角色分级 Prompt 策略 + Tool 权限沙箱 | 2-3 周 |
| **Phase 1C** | AI 管理看板(用量/成本/效果/开关) | 2-3 周 |
| Phase 1A/1B/1C 同步推进1C 与 1A/1B 并行开发 |
| **Phase 2A** | 医护端 AI 助手面板(侧边栏 + 患者摘要 + 随访辅助) | 3-5 周 |
| **Phase 2B** | 洞察→推送→反馈闭环Copilot 每日扫描 + 消息推送 + 采纳反馈) | 2-4 周 |
| **Phase 3A** | RAG 知识库pgvector + Embedding API + 语义检索 Tool | 3-5 周 |
| **Phase 3B** | 语音交互(在线为主 + CPU 降级) | 3-4 周 |
| **远期** | 主动关怀引擎飞轮、多 Agent 协作、领域微调 | 6-12 个月 |
## 与现有 Agent 计划的关系
本次讨论的方向在已有 AI Agent 突破口设计(`docs/superpowers/specs/2026-05-18-ai-agent-breakthrough-design.md`)基础上扩展:
- Agent Phase 1-3 的 Tool 扩展、会话持久化、行动类 Tool **按原计划推进**
- 新增角色沙箱机制、管理看板、AI→UI 触点打通、RAG 知识库
- 新增管理看板需要:`ai_feature_flags` 表(功能开关)、`ai_usage_daily` 聚合表(用量统计)
- 计费相关:`ai_billing_records`Token 消耗记录)、`ai_tenant_quotas` 扩展(月度预算)
## 待定事项
1. Embedding API 选型OpenAI text-embedding-3-small vs 国内智谱/阿里)
2. 语音服务商选型(讯飞 vs 阿里 Paraformer vs Whisper API
3. 管理看板是独立页面还是嵌入现有设置中心
4. AI 用量付费的阶梯定价具体方案

View File

@@ -0,0 +1,929 @@
# AI 健康管家 V2 设计规格
> **日期:** 2026-05-18 | **状态:** Draft | **范围:** erp-ai 角色沙箱 + 管理看板 + AI→UI 触点 + 计费 + 功能开关
> **前置依赖:** AI Agent Phase 0已完成、讨论记录 `docs/discussions/2026-05-18-ai-strategy-brainstorming.md`
## 1. 背景与目标
### 1.1 现状
HMS 的 AI 基础能力已具备(综合评分 6.8/10但产品维度被评为"有弹药没上膛"
**已完成的能力:**
| 能力 | 实现状态 | 问题 |
|------|----------|------|
| 3 ProviderClaude/OpenAI/Ollama | `AiProvider` trait + `ProviderRegistry` | 运行时每次 `get_provider("claude")` |
| SSE 流式分析(化验/趋势/报告) | 4 个 SSE 端点 | **无 UI 入口** |
| Copilot 引擎(风险评分/规则/洞察) | `CopilotEngine` + `InsightService` | **未触达用户** |
| ReAct Agent + Function Calling | `AgentOrchestrator` + `ToolRegistry` + 1 Tool | Phase 0 完成,无角色隔离 |
| 配额/成本管控 | `QuotaService` + `UsageService` + `CacheService` | 按月 Token 预算,无功能维度 |
| AI 配置管理 | `config_resolver` + `AiConfigPage.tsx` | Agent/Provider/分析配置,无功能开关 |
| 知识库框架 | KDIGO 规则 + 结构化源 | 无 RAG 语义检索 |
**核心断裂点:**
1. **AI 能力断裂** — 后端分析能力完整但用户无法触发4 个 SSE 端点无按钮)
2. **角色无区分**`chat_handler` 所有用户同一 Prompt、同一 Tool 集,无沙箱隔离
3. **管控无度量**`UsageService` 只有分析数量统计,无 Token 成本/功能维度/效果追踪
4. **功能无开关**`ai_tenant_configs` 有月度预算但无功能级启用/禁用
### 1.2 本次设计目标
基于 2026-05-18 五维度战略讨论的结论,本次设计覆盖 4 个模块:
| 模块 | 目标 | 优先级 |
|------|------|--------|
| **角色沙箱** | 统一入口"小华" + 按角色隔离的 Tool/Prompt/数据范围 | P0 |
| **AI→UI 触点** | Web 化验 AI 解读 + 小程序体征趋势分析 | P0 |
| **管理看板** | 用量/成本/效果/开关四位一体的 AI 管理中心 | P0 |
| **计费基础设施** | 按机构月度用量的 Token 消耗记录和聚合 | P1 |
**与已有 Agent Phase 1-3 的关系:**
- Agent Phase 1Tool 扩展)→ 本次设计为其增加角色权限过滤
- Agent Phase 2会话持久化→ 本次设计为其增加管理看板的数据查询
- Agent Phase 3行动 Tool→ 本次设计为其增加功能开关管控
### 1.3 设计原则
1. **安全优先** — 数据隔离通过后端硬约束实现,不依赖 Prompt 约束
2. **复用现有**`QuotaService`/`UsageService`/`HealthDataProvider` 全部复用
3. **渐进交付** — Phase 1A/1B/1C 可并行,不阻塞
4. **云端部署** — 生产环境只用云端 LLMOllama 仅开发测试
---
## 2. 角色沙箱架构
### 2.1 架构概览
当前 `chat_handler` 为所有用户构建相同的 `ToolRegistry``system_prompt`。改造为:
```
┌──────────────────┐
│ 统一入口 │
│ POST /ai/chat │
└────────┬─────────┘
│ TenantContext (JWT)
┌────────▼─────────┐
│ SessionRouter │ ← 新增
│ 根据 role 选择 │
│ 沙箱配置 │
└────────┬─────────┘
┌───────────────────┼───────────────────┐
│ │ │
┌────────▼────────┐ ┌───────▼────────┐ ┌───────▼────────┐
│ 患者沙箱 │ │ 医护沙箱 │ │ 管理沙箱 │
│ Prompt: 通俗温和 │ │ Prompt: 专业简洁│ │ Prompt: 汇总分析│
│ Tools: 4 个 │ │ Tools: 7 个 │ │ Tools: 3 个 │
│ 数据: 仅自己 │ │ 数据: 所管患者 │ │ 数据: 机构汇总 │
└─────────────────┘ └────────────────┘ └────────────────┘
```
### 2.2 SandboxConfig 定义
```rust
/// 角色沙箱配置
pub struct SandboxConfig {
pub role: UserRole,
pub system_prompt: String,
pub allowed_tools: Vec<String>,
pub data_scope: DataScope,
pub output_filters: Vec<OutputFilter>,
}
pub enum UserRole {
Patient,
MedicalStaff,
Admin,
}
pub enum DataScope {
/// 仅自己关联的患者数据
OwnOnly { patient_id: Uuid },
/// 本科室/机构下的患者数据
DepartmentScope { department_id: Option<Uuid> },
/// 机构级汇总数据,无个体数据
TenantAggregate,
}
pub enum OutputFilter {
/// 移除诊断术语(对患者隐藏)
RemoveDiagnosisTerms,
/// 追加"请以医生诊断为准"免责声明
AppendDisclaimer,
}
```
### 2.3 三级沙箱详细定义
#### 患者沙箱
```yaml
role: Patient
system_prompt: |
你是 HMS 健康管理平台的 AI 健康顾问"小华"。
你面对的是患者本人,请遵循以下原则:
1. 用通俗语言解释,避免医学术语
2. 先共情再给建议,患者可能焦虑或恐惧
3. 不要给出明确诊断,只解释数据含义
4. 鼓励患者配合治疗,分享积极案例
5. 任何建议后追加"具体请以医生诊断为准"
allowed_tools:
- query_patient_vitals # 查自己的体征数据
- query_lab_reports # 查自己的化验报告
- query_patient_profile # 查自己的基本信息
- search_medical_knowledge # 科普知识检索
data_scope: OwnOnly (patient_id from JWT)
output_filters: [RemoveDiagnosisTerms, AppendDisclaimer]
```
#### 医护沙箱
```yaml
role: MedicalStaff
system_prompt: |
你是 HMS 健康管理平台的 AI 医疗助手。
你面对的是医护人员,请遵循以下原则:
1. 使用专业术语,提供数据来源引用
2. 辅助决策但不替代诊断,标注置信度
3. 风险评估引用 KDIGO 等标准
4. 对异常值主动提示,给出临床意义
5. 支持随访小结生成和文书辅助
allowed_tools:
- query_patient_vitals
- query_lab_reports
- query_patient_profile
- query_appointments
- analyze_lab_report # 触发 AI 化验解读
- analyze_health_trends # 触发 AI 趋势分析
- get_health_insights # 获取 Copilot 洞察
data_scope: DepartmentScope (department_id from user profile)
output_filters: []
```
#### 管理沙箱
```yaml
role: Admin
system_prompt: |
你是 HMS 健康管理平台的 AI 运营助手。
你面对的是机构管理员,请遵循以下原则:
1. 提供数据驱动的运营建议
2. 对比分析不同时段/科室的指标变化
3. 标注异常波动和潜在问题
4. 建议优化措施并预估效果
allowed_tools:
- query_ai_usage_stats # AI 用量统计
- query_ai_cost_stats # AI 成本统计
- query_ai_effectiveness # AI 效果统计
data_scope: TenantAggregate
output_filters: []
```
### 2.4 Tool 权限矩阵
| Tool 名称 | 患者 | 医护 | 管理员 | 阶段 |
|-----------|------|------|--------|------|
| `query_patient_vitals` | ✅ (仅自己) | ✅ (所管患者) | ❌ | Phase 0 ✅ |
| `query_lab_reports` | ✅ (仅自己) | ✅ (所管患者) | ❌ | Phase 1 |
| `query_patient_profile` | ✅ (仅自己) | ✅ (所管患者) | ❌ | Phase 1 |
| `query_appointments` | ❌ | ✅ | ❌ | Phase 1 |
| `analyze_lab_report` | ❌ | ✅ | ❌ | Phase 1 |
| `analyze_health_trends` | ❌ | ✅ | ❌ | Phase 1 |
| `get_health_insights` | ❌ | ✅ | ❌ | Phase 2 |
| `search_medical_knowledge` | ✅ | ✅ | ❌ | Phase 3 |
| `query_ai_usage_stats` | ❌ | ❌ | ✅ | Phase 1 |
| `query_ai_cost_stats` | ❌ | ❌ | ✅ | Phase 1 |
| `query_ai_effectiveness` | ❌ | ❌ | ✅ | Phase 2 |
| `create_appointment` | ❌ | ❌ | ❌ | Phase 3 (行动类) |
| `transfer_to_human` | ❌ | ❌ | ❌ | Phase 3 (行动类) |
### 2.5 SessionRouter 实现
`SessionRouter` 负责根据 JWT 中的用户角色构建沙箱配置:
```rust
impl SessionRouter {
pub fn resolve(&self, ctx: &TenantContext) -> SandboxConfig {
match ctx.role.as_str() {
"patient" => self.build_patient_sandbox(ctx),
"doctor" | "nurse" => self.build_medical_sandbox(ctx),
"admin" | "tenant_admin" => self.build_admin_sandbox(ctx),
_ => self.build_patient_sandbox(ctx), // 默认最小权限
}
}
}
```
**关键安全约束:**
1. `ToolContext.patient_id` — 患者角色从 JWT 提取(不可伪造),医护角色从请求参数获取(后端验证所管范围)
2. `ToolRegistry::filter_by_role()` — 从全局注册表中过滤出角色允许的 Tool 子集
3. `system_prompt` — 不由前端传递,由后端根据角色硬编码生成
4. **输出过滤** — 患者角色追加免责声明、移除诊断术语(关键词替换,不依赖 LLM 自律)
### 2.6 chat_handler 改造
当前 `chat_handler` 在每次请求中内联创建 `ToolRegistry`。改造后:
```rust
// 改造前
let mut registry = ToolRegistry::new();
registry.register(Arc::new(QueryPatientVitalsTool));
// 改造后
let sandbox = session_router.resolve(&ctx);
let registry = global_tool_registry.filter_by_role(&sandbox.allowed_tools);
let system_prompt = sandbox.system_prompt.clone();
```
`global_tool_registry``AiState` 初始化时一次性注册所有 Tool后续请求只做过滤无需重复创建。
### 2.7 AiState 扩展
```rust
pub struct AiState {
// ... 现有字段 ...
pub global_tool_registry: Arc<ToolRegistry>, // 新增:全局 Tool 注册
pub session_router: Arc<SessionRouter>, // 新增:会话路由
}
```
---
## 3. AI→UI 触点打通
### 3.1 问题
当前系统有 4 个 SSE 流式分析端点化验解读、趋势分析、体检方案、报告摘要但前端没有任何按钮或入口触发它们。AI 分析能力完全沉睡在后端。
### 3.2 触点设计
#### Web 端触点2 个)
**触点 1化验报告详情页"AI 解读"按钮**
位置:`LabReportDetail.tsx` 化验报告详情页,报告基本信息下方。
交互流程:
```
用户打开化验报告详情 → 看到"AI 智能解读"按钮 → 点击 →
SSE 流式加载 → 逐字展示解读结果 → 完成后可折叠/展开
```
技术实现:
- 前端调用现有 `POST /api/v1/ai/analysis/stream` 端点(`analysis_type: "lab_report"`
- SSE 流复用现有 `AnalysisSseEvent` 协议
- 解读结果展示为卡片组件,包含异常指标高亮
- 调用前走 `QuotaService` 检查配额
**触点 2患者体征页"AI 趋势分析"按钮**
位置:`PatientVitals.tsx` 患者体征页,体征图表区域上方。
交互流程:
```
医护查看患者体征 → 点击"AI 趋势分析" → 选择时间范围 →
SSE 流式加载 → 展示趋势分析结果(异常模式/风险提示/建议)
```
技术实现:
- 调用 `POST /api/v1/ai/analysis/stream` 端点(`analysis_type: "trend"`
- 需传入 `patient_id` 和时间范围参数
- 趋势分析结果以 Markdown 渲染,异常值用红色标注
#### 小程序触点2 个)
**触点 3体征记录页"AI 健康摘要"**
位置:小程序体征录入/查看页,体征数据下方。
交互:
```
用户查看体征记录 → 页面底部"AI 健康摘要"卡片 →
非流式请求 → 展示最近 7 天体征的 AI 总结
```
技术实现:
- 新增端点 `GET /api/v1/ai/analysis/health-summary?patient_id=xxx`
- 走非流式(小程序 SSE 支持有限),一次返回完整结果
- 后端调用 `AnalysisService` 的趋势分析 + Copilot 风险评分
- 返回结构化 JSON包含 `summary_text` + `risk_level` + `suggestions[]`
**触点 4AI 聊天入口(复用已有 `/ai/chat`**
位置:小程序首页悬浮按钮或 Tab 页内的"问小华"入口。
交互:与 Agent 聊天接口集成,角色自动识别为患者沙箱。
### 3.3 触点与 Agent 的集成
所有 AI→UI 触点共用以下基础设施:
```
┌──────────────────────────────────────────────────┐
│ 前端触点层 │
│ Web 化验按钮 / Web 趋势按钮 / MP 摘要 / MP 聊天 │
└───────────────────┬──────────────────────────────┘
┌───────────────────▼──────────────────────────────┐
│ 调用量记录层 │
│ 每次调用 → UsageService.log_usage() │
│ 记录: tenant_id / user_id / feature / tokens │
└───────────────────┬──────────────────────────────┘
┌───────────────────▼──────────────────────────────┐
│ 功能开关检查层 │
│ FeatureFlagService.is_enabled(tenant, feature) │
│ 禁用时返回友好提示而非错误 │
└───────────────────┬──────────────────────────────┘
┌───────────────────▼──────────────────────────────┐
│ 配额检查层 │
│ QuotaService.check_quota(tenant_id, patient_id) │
│ 超限时返回降级提示 │
└───────────────────┬──────────────────────────────┘
┌───────────────────▼──────────────────────────────┐
│ AI 能力层 │
│ AnalysisService / AgentOrchestrator / Copilot │
└──────────────────────────────────────────────────┘
```
### 3.4 前端组件设计
**AiAnalysisCard 组件**Web 端通用):
```tsx
interface AiAnalysisCardProps {
analysisType: 'lab_report' | 'trend' | 'health_summary';
sourceRef: string; // 化验报告 ID / 患者 ID
patientId?: string;
triggerLabel?: string; // 按钮文字
collapsible?: boolean; // 是否可折叠
}
```
- 加载态:骨架屏 + "AI 正在分析..."
- 成功态Markdown 渲染 + 异常指标高亮
- 错误态:友好提示 + 重试按钮
- 配额耗尽:提示联系管理员
- 功能关闭:提示"该功能暂未开放"
---
## 4. AI 管理看板
### 4.1 页面布局
```
┌─────────────────────────────────────────────────────────┐
│ AI 管理中心 [AiConfigPage] │
├─────────┬───────────────────────────────────────────────┤
│ 侧边栏 │ 主内容区 │
│ │ │
│ 📊 总览 │ ┌─────────────────────────────────────────┐ │
│ 💰 成本 │ │ 用量总览 │ │
│ 📈 效果 │ │ ┌───────┐ ┌───────┐ ┌───────┐ ┌──────┐│ │
│ ⚙️ 开关 │ │ │ 今日 │ │ 本月 │ │ 峰值 │ │ 预算 ││ │
│ 🔧 配置 │ │ │ 1,234 │ │45,678 │ │ 3,200 │ │ 78% ││ │
│ │ │ └───────┘ └───────┘ └───────┘ └──────┘│ │
│ │ │ [调用量趋势图 — 近 30 天折线] │ │
│ │ │ [按功能分布饼图] │ │
│ │ └─────────────────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────────────────┐ │
│ │ │ 成本分析 │ │
│ │ │ Token 消耗趋势 / Provider 占比 │ │
│ │ │ 月度预算进度条 / 预警阈值 │ │
│ │ └─────────────────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────────────────┐ │
│ │ │ 效果追踪 │ │
│ │ │ 分析完成率 / 建议采纳率 │ │
│ │ │ 异常检出率 / 人工干预率 │ │
│ │ └─────────────────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────────────────┐ │
│ │ │ 功能开关 │ │
│ │ │ AI 分析 [ON] AI 聊天 [ON] 预警 [OFF] │ │
│ │ │ RAG 检索 [ON] 语音 [OFF] Copilot [ON] │ │
│ │ └─────────────────────────────────────────┘ │
└─────────┴───────────────────────────────────────────────┘
```
### 4.2 用量总览模块
**数据源:** `ai_usage` 表(现有)+ `ai_usage_daily` 聚合表(新增)
**API 端点:**
| 端点 | 说明 | 权限码 |
|------|------|--------|
| `GET /ai/admin/usage/overview` | 今日/本月/峰值/预算使用率 | `ai.admin.dashboard` |
| `GET /ai/admin/usage/trend?days=30` | 近 N 天调用量趋势 | `ai.admin.dashboard` |
| `GET /ai/admin/usage/by-feature` | 按功能分布 | `ai.admin.dashboard` |
| `GET /ai/admin/usage/by-role` | 按角色分布 | `ai.admin.dashboard` |
**用量概览响应:**
```typescript
interface UsageOverview {
today: {
totalCalls: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
};
thisMonth: {
totalCalls: number;
totalTokens: number;
budgetUsed: number; // 百分比
budgetRemaining: number;
};
peak: {
dailyCalls: number;
peakDate: string;
};
trend: Array<{
date: string;
calls: number;
tokens: number;
}>;
byFeature: Array<{
feature: string;
calls: number;
tokens: number;
}>;
}
```
### 4.3 成本分析模块
**数据源:** `ai_usage.cost_cents` 字段(现有)+ 按月聚合
**API 端点:**
| 端点 | 说明 |
|------|------|
| `GET /ai/admin/cost/summary` | 本月成本/日均/按 Provider 分布 |
| `GET /ai/admin/cost/trend?months=6` | 近 N 个月成本趋势 |
| `GET /ai/admin/cost/by-provider` | 按 Provider 成本分布 |
**成本聚合逻辑:**
`ai_usage` 表已有 `cost_cents` 字段。新增 `ai_usage_daily` 聚合表,每日凌晨定时任务聚合前一天的用量:
```sql
-- 每日聚合
INSERT INTO ai_usage_daily (tenant_id, date, feature, provider, model,
total_calls, total_input_tokens, total_output_tokens, total_cost_cents)
SELECT tenant_id, DATE(created_at), analysis_type, provider, model,
COUNT(*), SUM(input_tokens), SUM(output_tokens), SUM(cost_cents)
FROM ai_usage
WHERE created_at >= CURRENT_DATE - INTERVAL '1 day'
AND created_at < CURRENT_DATE
GROUP BY tenant_id, DATE(created_at), analysis_type, provider, model
ON CONFLICT (tenant_id, date, feature, provider, model)
DO UPDATE SET
total_calls = EXCLUDED.total_calls,
total_input_tokens = EXCLUDED.total_input_tokens,
total_output_tokens = EXCLUDED.total_output_tokens,
total_cost_cents = EXCLUDED.total_cost_cents;
```
**预算预警:** 当月度用量达到预算的 80%/90%/100% 时,通过消息中心推送预警。
### 4.4 效果追踪模块
**数据源:** `ai_analysis` 表 + `ai_suggestion`
**指标定义:**
| 指标 | 计算方式 | 说明 |
|------|----------|------|
| 分析完成率 | `completed / (completed + failed)` × 100% | AI 分析任务成功率 |
| 建议采纳率 | `adopted / (adopted + dismissed)` × 100% | 用户采纳 AI 建议的比例 |
| 异常检出率 | `有异常发现的分析 / 总分析` × 100% | AI 发现异常的能力 |
| 人工干预率 | `人工覆盖次数 / 总建议` × 100% | 医护对 AI 建议的修正频率 |
**API 端点:**
| 端点 | 说明 |
|------|------|
| `GET /ai/admin/effectiveness/summary` | 效果指标汇总 |
| `GET /ai/admin/effectiveness/trend?months=6` | 效果趋势 |
| `GET /ai/admin/effectiveness/by-feature` | 按功能的效果 |
### 4.5 功能开关系统
#### 数据模型
新增 `ai_feature_flags` 表(每个租户每个功能一条记录):
```sql
CREATE TABLE ai_feature_flags (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
feature VARCHAR(100) NOT NULL, -- 功能标识
is_enabled BOOLEAN NOT NULL DEFAULT true,
config JSONB, -- 功能级配置(如限制条件)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID,
UNIQUE(tenant_id, feature)
);
```
#### 功能标识定义
| feature | 说明 | 默认 | 影响范围 |
|---------|------|------|----------|
| `ai.analysis.lab_report` | 化验报告 AI 解读 | ON | Web 触点 1 |
| `ai.analysis.trend` | 趋势分析 | ON | Web 触点 2 |
| `ai.analysis.report_summary` | 报告摘要 | ON | SSE 端点 |
| `ai.analysis.checkup_plan` | 体检方案 | ON | SSE 端点 |
| `ai.chat` | AI 聊天(小华) | ON | 全平台聊天 |
| `ai.chat.patient` | 患者端聊天 | ON | 小程序聊天 |
| `ai.chat.staff` | 医护端聊天 | ON | Web 聊天 |
| `ai.alert.push` | AI 主动预警推送 | OFF | 消息中心 |
| `ai.rag` | RAG 知识检索 | OFF | Agent Tool |
| `ai.voice` | 语音交互 | OFF | 小程序 |
| `ai.copilot.risk` | Copilot 风险评分 | ON | 后台辅助 |
| `ai.copilot.insight` | Copilot 洞察服务 | ON | 后台辅助 |
#### 功能开关检查逻辑
```rust
impl FeatureFlagService {
/// 检查功能是否启用(缓存 5 分钟)
pub async fn is_enabled(&self, tenant_id: Uuid, feature: &str) -> bool {
// 1. 查缓存
// 2. 缓存未命中 → 查 ai_feature_flags 表
// 3. 记录不存在 → 默认 true功能默认启用
// 4. 记录存在 → 返回 is_enabled
}
}
```
**开关与触点的集成:** 每个 AI→UI 触点在调用前先检查功能开关:
```rust
// 触点层伪代码
if !feature_flag_service.is_enabled(ctx.tenant_id, "ai.analysis.lab_report").await {
return Err(AppError::FeatureDisabled("AI 化验解读功能暂未开放".into()));
}
```
前端也做一次检查(隐藏按钮/灰显),但后端为权威判断。
#### API 端点
| 端点 | 说明 | 权限码 |
|------|------|--------|
| `GET /ai/admin/flags` | 获取所有功能开关状态 | `ai.admin.flags` |
| `PUT /ai/admin/flags/{feature}` | 更新功能开关 | `ai.admin.flags` |
| `GET /ai/admin/flags/{feature}/public` | 前端查询(无敏感信息) | `ai.chat.send` |
### 4.6 管理看板权限
新增权限码:
| 权限码 | 说明 |
|--------|------|
| `ai.admin.dashboard` | 查看管理看板(用量/成本/效果) |
| `ai.admin.flags` | 管理功能开关 |
| `ai.admin.config` | 管理 AI 配置(已有,扩展) |
权限码分配:`tenant_admin` 角色默认拥有全部管理权限。
### 4.7 前端路由与组件
```
/apps/web/src/pages/ai/
├── AiAdminPage.tsx # 管理看板主页
├── components/
│ ├── UsageOverview.tsx # 用量总览
│ ├── CostAnalysis.tsx # 成本分析
│ ├── EffectivenessTracker.tsx # 效果追踪
│ ├── FeatureFlags.tsx # 功能开关
│ └── UsageTrendChart.tsx # 趋势图组件
```
路由注册:
```typescript
{
path: '/ai/admin',
component: AiAdminPage,
permissions: ['ai.admin.dashboard'],
}
```
菜单配置:在"AI 管理"菜单下新增"管理看板"子菜单。
---
## 5. 计费数据模型
### 5.1 计费模式
**按机构(租户)月度用量阶梯计价:**
- 每个 `tenant_id` 每月独立统计 Token 消耗
- 不同 AI 功能统一折算为 Token 消耗
- 基础 AICopilot 规则引擎、本地风险评分)不消耗 Token
- 高级 AILLM 对话、化验解读、趋势分析、报告摘要)消耗 Token
- 机构管理员可在看板中查看用量和预算进度
### 5.2 计费数据流
```
用户触发 AI 功能 → AI 执行 → UsageService.log_usage() → ai_usage 表
每日凌晨聚合 ↓
ai_usage_daily 表
管理看板查询 ↓
用量/成本/趋势
```
### 5.3 Token 成本计算
沿用现有 `ai_usage.cost_cents` 字段。成本计算公式:
```
cost_cents = (input_tokens × input_price + output_tokens × output_price) / 1000
```
价格表(每 1000 tokens单位
| Provider | 模型 | input_price | output_price |
|----------|------|-------------|--------------|
| Claude | claude-sonnet-4-6 | 0.3 | 1.5 |
| Claude | claude-opus-4-7 | 1.5 | 7.5 |
| OpenAI | gpt-4o | 0.25 | 1.0 |
| OpenAI | gpt-4o-mini | 0.015 | 0.06 |
价格可通过 `settings` 表配置(`ai.billing.{provider}.{model}.input_price`)。
### 5.4 与现有 quota 系统的关系
`QuotaService` 已有月度 Token 预算检查(`monthly_token_budget`)。本次扩展:
- 现有 `ai_tenant_configs` 表增加 `billing_enabled` 字段(是否启用计费追踪)
- 现有 `QuotaService.check_quota()` 逻辑不变
- 新增 `BillingService` 负责成本计算和聚合,不替换 `QuotaService`
```rust
pub struct BillingService {
db: DatabaseConnection,
}
impl BillingService {
/// 获取租户本月账单摘要
pub async fn get_monthly_summary(&self, tenant_id: Uuid) -> BillingSummary {
// 从 ai_usage_daily 聚合本月数据
}
/// 获取租户历史月度账单
pub async fn get_billing_history(
&self,
tenant_id: Uuid,
months: u32,
) -> Vec<MonthlyBill> {
// 从 ai_usage_daily 按月聚合
}
}
```
---
## 6. 数据模型变更
### 6.1 新增表
#### ai_feature_flags — AI 功能开关
```sql
CREATE TABLE ai_feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
feature VARCHAR(100) NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true,
config JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID,
CONSTRAINT uq_feature_flags_tenant_feature UNIQUE(tenant_id, feature)
);
CREATE INDEX idx_feature_flags_tenant ON ai_feature_flags(tenant_id);
```
Entity: `crates/erp-ai/src/entity/ai_feature_flags.rs`
#### ai_usage_daily — 用量日聚合
```sql
CREATE TABLE ai_usage_daily (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
date DATE NOT NULL,
feature VARCHAR(100) NOT NULL, -- analysis_type / chat / copilot
provider VARCHAR(50) NOT NULL,
model VARCHAR(100) NOT NULL,
total_calls INT NOT NULL DEFAULT 0,
total_input_tokens BIGINT NOT NULL DEFAULT 0,
total_output_tokens BIGINT NOT NULL DEFAULT 0,
total_cost_cents BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_usage_daily UNIQUE(tenant_id, date, feature, provider, model)
);
CREATE INDEX idx_usage_daily_tenant_date ON ai_usage_daily(tenant_id, date DESC);
CREATE INDEX idx_usage_daily_tenant_month ON ai_usage_daily(tenant_id, (date_trunc('month', date)));
```
Entity: `crates/erp-ai/src/entity/ai_usage_daily.rs`
#### ai_suggestion_feedback — AI 建议反馈(效果追踪用)
```sql
CREATE TABLE ai_suggestion_feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
suggestion_id UUID NOT NULL, -- 关联 ai_suggestion.id
user_id UUID NOT NULL,
action VARCHAR(20) NOT NULL, -- adopted / dismissed / modified
feedback_text TEXT, -- 用户反馈备注
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_suggestion_feedback_tenant ON ai_suggestion_feedback(tenant_id);
CREATE INDEX idx_suggestion_feedback_suggestion ON ai_suggestion_feedback(suggestion_id);
```
Entity: `crates/erp-ai/src/entity/ai_suggestion_feedback.rs`
### 6.2 现有表扩展
#### ai_tenant_configs 增加 billing_enabled 字段
```sql
ALTER TABLE ai_tenant_configs ADD COLUMN billing_enabled BOOLEAN NOT NULL DEFAULT false;
```
Entity: `ai_tenant_config.rs` 增加 `billing_enabled: bool` 字段。
### 6.3 迁移文件
单个迁移文件 `m20260518_000149_ai_health_butler_v2.rs`,包含:
1. 创建 `ai_feature_flags`
2. 创建 `ai_usage_daily`
3. 创建 `ai_suggestion_feedback`
4. `ai_tenant_configs` 增加 `billing_enabled`
5. Seed 12 个功能开关的默认值(全部 ON`ai.alert.push``ai.voice` 除外)
6. Seed 3 个新权限码(`ai.admin.dashboard` / `ai.admin.flags` / `ai.admin.config` 扩展)
### 6.4 新增 Rust 文件清单
| 文件路径 | 说明 |
|----------|------|
| `crates/erp-ai/src/entity/ai_feature_flags.rs` | 功能开关 Entity |
| `crates/erp-ai/src/entity/ai_usage_daily.rs` | 用量日聚合 Entity |
| `crates/erp-ai/src/entity/ai_suggestion_feedback.rs` | 建议反馈 Entity |
| `crates/erp-ai/src/service/feature_flag_service.rs` | 功能开关服务 |
| `crates/erp-ai/src/service/billing_service.rs` | 计费服务 |
| `crates/erp-ai/src/service/daily_aggregation.rs` | 日聚合定时任务 |
| `crates/erp-ai/src/agent/sandbox.rs` | 沙箱配置 |
| `crates/erp-ai/src/agent/session_router.rs` | 会话路由 |
| `crates/erp-ai/src/handler/admin_handler.rs` | 管理端点 |
| `apps/web/src/api/ai/admin.ts` | 管理端 API 模块 |
| `apps/web/src/pages/ai/AiAdminPage.tsx` | 管理看板页面 |
| `apps/web/src/pages/ai/components/UsageOverview.tsx` | 用量总览组件 |
| `apps/web/src/pages/ai/components/CostAnalysis.tsx` | 成本分析组件 |
| `apps/web/src/pages/ai/components/EffectivenessTracker.tsx` | 效果追踪组件 |
| `apps/web/src/pages/ai/components/FeatureFlags.tsx` | 功能开关组件 |
| `apps/web/src/components/ai/AiAnalysisCard.tsx` | AI 分析卡片组件(通用) |
---
## 7. 实施计划
### 7.1 Phase 1基础设施 + 首批触点3-4 周)
Phase 1 的三个子阶段可并行推进:
#### Phase 1AAI→UI 触点打通1-2 周)
| # | 任务 | 类型 | 文件 |
|---|------|------|------|
| 1A-1 | Web 化验报告页添加"AI 解读"按钮 | 前端 | `LabReportDetail.tsx` + `AiAnalysisCard.tsx` |
| 1A-2 | Web 患者体征页添加"AI 趋势分析"按钮 | 前端 | `PatientVitals.tsx` |
| 1A-3 | 小程序体征页"AI 健康摘要"卡片 | 前端 | MP 体征页 + 新增 `health-summary` 端点 |
| 1A-4 | 功能开关检查中间件集成 | 后端 | 各 AI handler 添加 `FeatureFlagService` 检查 |
| 1A-5 | 调用量统一记录(所有触点 → `UsageService` | 后端 | 各 AI handler 添加 `log_usage()` |
**验证标准:**
- Web 化验报告页点击"AI 解读"可触发 SSE 流式分析
- Web 体征页点击"AI 趋势分析"可获取趋势报告
- 所有 AI 调用记录到 `ai_usage`
- 功能开关关闭时按钮灰显/隐藏
#### Phase 1B角色沙箱2-3 周)
| # | 任务 | 类型 | 文件 |
|---|------|------|------|
| 1B-1 | `SandboxConfig` / `SessionRouter` 实现 | 后端 | `agent/sandbox.rs` + `agent/session_router.rs` |
| 1B-2 | `ToolRegistry::filter_by_role()` 方法 | 后端 | `agent/registry.rs` |
| 1B-3 | 三套角色 Prompt 模板 | 后端 | `config_resolver.rs` 扩展 |
| 1B-4 | `chat_handler` 改造(全局 Registry + 沙箱过滤) | 后端 | `handler/chat_handler.rs` |
| 1B-5 | `AiState` 增加 `global_tool_registry` + `session_router` | 后端 | `state.rs` + `main.rs` |
| 1B-6 | 输出过滤器(患者角色免责声明 + 诊断术语移除) | 后端 | `agent/output_filter.rs`(新文件) |
| 1B-7 | 沙箱集成测试3 角色 × Tool 权限 × 数据范围) | 测试 | `tests/agent_sandbox.rs` |
**验证标准:**
- 患者角色只能调用 `query_patient_vitals` / `query_lab_reports` / `query_patient_profile` / `search_medical_knowledge`
- 医护角色额外可调用 `analyze_lab_report` / `analyze_health_trends` / `get_health_insights`
- 管理角色只能调用统计类 Tool
- 患者角色输出自动追加免责声明
- 所有角色数据范围受 `tenant_id` + 角色约束
#### Phase 1C管理看板 + 功能开关2-3 周)
| # | 任务 | 类型 | 文件 |
|---|------|------|------|
| 1C-1 | 数据库迁移3 新表 + 1 扩展 + seed | 迁移 | `m20260518_000149_ai_health_butler_v2.rs` |
| 1C-2 | `FeatureFlagService` 实现(含缓存) | 后端 | `service/feature_flag_service.rs` |
| 1C-3 | `BillingService` 实现(月度汇总/历史) | 后端 | `service/billing_service.rs` |
| 1C-4 | `DailyAggregationTask` 定时任务 | 后端 | `service/daily_aggregation.rs` |
| 1C-5 | 管理 API 端点(用量/成本/效果/开关) | 后端 | `handler/admin_handler.rs` |
| 1C-6 | 权限码 seed`ai.admin.dashboard` / `ai.admin.flags` | 迁移 | 同 1C-1 |
| 1C-7 | Web 管理看板页面4 个子模块) | 前端 | `AiAdminPage.tsx` + 4 个子组件 |
| 1C-8 | 前端 API 模块 | 前端 | `api/ai/admin.ts` |
| 1C-9 | 菜单注册("AI 管理" → "管理看板" | 前端 | 路由配置 |
**验证标准:**
- 管理看板展示今日/本月调用量、Token 消耗、预算进度
- 功能开关可在看板中开启/关闭,实时生效
- 日聚合任务正确运行,`ai_usage_daily` 数据正确
- `ai.admin.dashboard` / `ai.admin.flags` 权限码生效
### 7.2 Phase 2医护助手 + 闭环推送4-6 周)
> 依赖 Phase 1 完成。
| # | 任务 | 说明 |
|---|------|------|
| 2A-1 | 医护端 AI 侧边栏组件 | Web 后台右侧常驻,查看患者时自动总结 |
| 2A-2 | 上下文感知智能摘要 | 读取当前页面患者数据,调用 Agent 生成摘要 |
| 2A-3 | 随访记录 AI 辅助生成 | 随访页面AI 辅助生成随访小结 |
| 2B-1 | Copilot 每日扫描定时任务 | 定时扫描高风险患者 |
| 2B-2 | 洞察→消息中心推送 | 生成洞察后推送到医护/患者 |
| 2B-3 | 建议反馈机制 | 患者"采纳/忽略/咨询医生"UI + 数据记录 |
| 2B-4 | 效果追踪指标计算 | 从 `ai_suggestion_feedback` 计算采纳率等指标 |
### 7.3 Phase 3RAG + 语音4-6 周)
> 依赖 Phase 1 完成,与 Phase 2 可部分并行。
| # | 任务 | 说明 |
|---|------|------|
| 3A-1 | pgvector 扩展安装 | PostgreSQL 扩展 |
| 3A-2 | Embedding API 集成 | 调用在线 Embedding 模型 |
| 3A-3 | 知识库管理 API | 上传/编辑/删除/向量化知识条目 |
| 3A-4 | `search_medical_knowledge` Tool 升级 | 从规则匹配升级为语义检索 |
| 3A-5 | 知识库管理 UI | 管理看板中的知识库 tab |
| 3B-1 | 语音识别集成 | Whisper API / 讯飞 |
| 3B-2 | 语音合成集成 | OpenAI TTS / edge-tts |
| 3B-3 | 小程序语音输入/输出 | 语音按钮 + 播放 |
### 7.4 总体时间线
```
Week 1-2 ████████ Phase 1A (AI→UI 触点)
Week 1-3 ██████████ Phase 1B (角色沙箱)
Week 2-4 ████████████ Phase 1C (管理看板)
Week 5-8 ████████████████ Phase 2 (医护助手 + 闭环)
Week 5-10 ████████████████████████ Phase 3 (RAG + 语音)
```
Phase 1 总工期 3-4 周Phase 2-3 可部分并行,整体 8-12 周完成。
---
## 8. 待定事项
| # | 待定项 | 决策时机 | 影响 |
|---|--------|----------|------|
| 1 | Embedding API 选型OpenAI vs 智谱 vs 阿里) | Phase 3 启动前 | RAG 知识库成本和质量 |
| 2 | 语音服务商选型(讯飞 vs 阿里 Paraformer vs Whisper | Phase 3 启动前 | 语音质量和成本 |
| 3 | 管理看板独立页面 vs 嵌入现有设置中心 | Phase 1C 启动前 | 前端路由结构 |
| 4 | 阶梯定价具体方案 | Phase 1C 启动前 | 计费逻辑 |
| 5 | `ai_usage_daily` 聚合用 cron 任务 vs应用内定时器 | Phase 1C 实现时 | 运维复杂度 |
| 6 | 诊断术语移除的关键词列表 | Phase 1B 实现时 | 输出过滤精度 |
| 7 | 医护端 AI 侧边栏与现有页面布局的冲突 | Phase 2 启动前 | UI 布局方案 |
| 8 | Copilot 每日扫描的频率和范围 | Phase 2B 实现时 | 推送频率控制 |