diff --git a/apps/web/src/pages/health/components/VitalSignsTab.tsx b/apps/web/src/pages/health/components/VitalSignsTab.tsx index 0276d5c..65f76ea 100644 --- a/apps/web/src/pages/health/components/VitalSignsTab.tsx +++ b/apps/web/src/pages/health/components/VitalSignsTab.tsx @@ -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(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(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) {
{/* 趋势图 */}
+
+ } + loading={analyzingTrend} + onClick={handleTrendAnalysis} + size="small" + > + AI 趋势分析 + +
+ {/* AI 趋势分析结果 */} + {trendContent && ( + AI 趋势分析结果} + size="small" + style={{ marginBottom: 12 }} + extra={ + + } + > +
+ {trendContent} +
+
+ )} + {/* 最新记录摘要条 */} {latest && (
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 { + 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 { + 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, } -/// 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, + kek: &[u8; 32], +) -> HashMap { + 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 { + 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()); + } } diff --git a/crates/erp-ai/src/entity/ai_feature_flags.rs b/crates/erp-ai/src/entity/ai_feature_flags.rs new file mode 100644 index 0000000..3097084 --- /dev/null +++ b/crates/erp-ai/src/entity/ai_feature_flags.rs @@ -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, + pub updated_at: DateTimeUtc, + pub updated_by: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-ai/src/entity/ai_suggestion_feedback.rs b/crates/erp-ai/src/entity/ai_suggestion_feedback.rs new file mode 100644 index 0000000..be3f137 --- /dev/null +++ b/crates/erp-ai/src/entity/ai_suggestion_feedback.rs @@ -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, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-ai/src/entity/ai_usage_daily.rs b/crates/erp-ai/src/entity/ai_usage_daily.rs new file mode 100644 index 0000000..99c6726 --- /dev/null +++ b/crates/erp-ai/src/entity/ai_usage_daily.rs @@ -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 {} diff --git a/crates/erp-ai/src/entity/mod.rs b/crates/erp-ai/src/entity/mod.rs index d61b078..fc3ccbe 100644 --- a/crates/erp-ai/src/entity/mod.rs +++ b/crates/erp-ai/src/entity/mod.rs @@ -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; diff --git a/crates/erp-ai/src/handler/config_handler.rs b/crates/erp-ai/src/handler/config_handler.rs index 15601a3..142f93e 100644 --- a/crates/erp-ai/src/handler/config_handler.rs +++ b/crates/erp-ai/src/handler/config_handler.rs @@ -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(()) } diff --git a/crates/erp-ai/src/provider/registry.rs b/crates/erp-ai/src/provider/registry.rs index 4802521..0fb7d86 100644 --- a/crates/erp-ai/src/provider/registry.rs +++ b/crates/erp-ai/src/provider/registry.rs @@ -110,6 +110,119 @@ impl ProviderRegistry { pub fn get_provider(&self, name: &str) -> Option> { 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, + 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 = self.provider_names(); + for name in ¤t_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 { diff --git a/crates/erp-ai/src/service/analysis.rs b/crates/erp-ai/src/service/analysis.rs index 21f15cc..36f7e21 100644 --- a/crates/erp-ai/src/service/analysis.rs +++ b/crates/erp-ai/src/service/analysis.rs @@ -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, + pub provider_registry: std::sync::Arc, 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, db: sea_orm::DatabaseConnection) -> Self { + pub fn new( + provider_registry: std::sync::Arc, + 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)) } diff --git a/crates/erp-ai/src/service/feature_flag_service.rs b/crates/erp-ai/src/service/feature_flag_service.rs new file mode 100644 index 0000000..148159c --- /dev/null +++ b/crates/erp-ai/src/service/feature_flag_service.rs @@ -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>, + 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> { + 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 { + 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)); + } +} diff --git a/crates/erp-ai/src/service/mod.rs b/crates/erp-ai/src/service/mod.rs index 877aeeb..4556949 100644 --- a/crates/erp-ai/src/service/mod.rs +++ b/crates/erp-ai/src/service/mod.rs @@ -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; diff --git a/crates/erp-ai/src/state.rs b/crates/erp-ai/src/state.rs index d5e2bff..b637ce2 100644 --- a/crates/erp-ai/src/state.rs +++ b/crates/erp-ai/src/state.rs @@ -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, pub risk_service: Arc, pub insight_service: Arc, + pub feature_flags: Arc, } diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 58472fa..ba2c228 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -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), ] } } diff --git a/crates/erp-server/migration/src/m20260518_000150_seed_ai_config_permission.rs b/crates/erp-server/migration/src/m20260518_000150_seed_ai_config_permission.rs index 51ae12e..5737c41 100644 --- a/crates/erp-server/migration/src/m20260518_000150_seed_ai_config_permission.rs +++ b/crates/erp-server/migration/src/m20260518_000150_seed_ai_config_permission.rs @@ -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(()) } diff --git a/crates/erp-server/migration/src/m20260518_000153_ai_health_butler_v2.rs b/crates/erp-server/migration/src/m20260518_000153_ai_health_butler_v2.rs new file mode 100644 index 0000000..6b59a3f --- /dev/null +++ b/crates/erp-server/migration/src/m20260518_000153_ai_health_butler_v2.rs @@ -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(()) + } +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 1e089b0..21df4f6 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -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 = 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()), + ), } }; diff --git a/docs/discussions/2026-05-18-ai-strategy-brainstorming.md b/docs/discussions/2026-05-18-ai-strategy-brainstorming.md new file mode 100644 index 0000000..d17c627 --- /dev/null +++ b/docs/discussions/2026-05-18-ai-strategy-brainstorming.md @@ -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_id,LLM 无法伪造 | +| 跨租户 | session_id + tenant_id 双过滤,沿用现有中间件 | +| 跨角色 | ToolRegistry.get_allowed_tools(role) 硬过滤,非 Prompt 约束 | +| 越权 Tool | 后端按 role 限定可用 Tool 集合 | +| Prompt 注入 | 输出层关键词检测 + 输入层 sanitize | + +**三个角色沙箱定义:** + +- **患者沙箱**:查自己体征/化验、科普知识、预约挂号。数据范围仅自己、输出脱敏。 +- **医护沙箱**:查所管患者、AI 分析、风险评分、文书辅助。数据范围本科室患者、含诊断信息。 +- **管理沙箱**:成本统计、用量趋势、效果分析、功能开关。数据范围本机构汇总、无个体数据。 + +### 维度 2:数据智能闭环 — 先打通最短路径 + +**结论:先 A(AI→用户可见),同步设计 B 数据结构,C 远期愿景** + +- **A(1-2 周)**:Web 化验报告页"AI 解读"按钮 + 小程序体征页"AI 趋势分析"按钮。复用现有 SSE 端点,前端 3-5 天。 +- **B(2-4 周)**:Copilot 每日扫描高风险患者 → 生成洞察 → 消息中心推送 → 患者"采纳/忽略/咨询医生"反馈 → 数据写入 `ai_suggestion` 度量。 +- **C(3-6 个月)**:洞察 → 行动 → 效果追踪 → 模型优化的完整飞轮。 + +### 维度 3:多角色 AI 体验 — 角色 Prompt 为基础设施 + +**结论:A 必做,B 医护端最有价值,C 管理看板纳入第一阶段** + +- **A(2-3 周)**:system_prompt 根据 user_role 动态组装。患者版温和通俗带情绪安抚、医护版专业简洁引用数据来源、管理版汇总对比。Tool Registry 按角色过滤。 +- **B(3-5 周)**:Web 后台右侧常驻 AI 侧边栏。查看患者档案时 AI 自动总结、随访页 AI 辅助生成小结。上下文感知的智能摘要,非对话模式。 +- **C**:合并到 AI 管理看板(见维度 5 + 跨维度决策)。 + +### 维度 4:技术架构 — 云端部署 + pgvector RAG + 在线/本地语音 + +**结论:纯云端部署,不调本地 LLM** + +| 能力 | 技术选型 | +|------|----------| +| LLM 推理 | Claude / OpenAI(云端),Ollama 仅开发测试 | +| RAG 知识库 | pgvector 扩展 PostgreSQL + 在线 Embedding API(text-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 +- 高级 AI(LLM 对话、化验解读、趋势分析)消耗 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 用量付费的阶梯定价具体方案 diff --git a/docs/superpowers/specs/2026-05-18-ai-health-butler-v2-design.md b/docs/superpowers/specs/2026-05-18-ai-health-butler-v2-design.md new file mode 100644 index 0000000..59efc7f --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-ai-health-butler-v2-design.md @@ -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 Provider(Claude/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 1(Tool 扩展)→ 本次设计为其增加角色权限过滤 +- Agent Phase 2(会话持久化)→ 本次设计为其增加管理看板的数据查询 +- Agent Phase 3(行动 Tool)→ 本次设计为其增加功能开关管控 + +### 1.3 设计原则 + +1. **安全优先** — 数据隔离通过后端硬约束实现,不依赖 Prompt 约束 +2. **复用现有** — `QuotaService`/`UsageService`/`HealthDataProvider` 全部复用 +3. **渐进交付** — Phase 1A/1B/1C 可并行,不阻塞 +4. **云端部署** — 生产环境只用云端 LLM,Ollama 仅开发测试 + +--- + +## 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, + pub data_scope: DataScope, + pub output_filters: Vec, +} + +pub enum UserRole { + Patient, + MedicalStaff, + Admin, +} + +pub enum DataScope { + /// 仅自己关联的患者数据 + OwnOnly { patient_id: Uuid }, + /// 本科室/机构下的患者数据 + DepartmentScope { department_id: Option }, + /// 机构级汇总数据,无个体数据 + 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, // 新增:全局 Tool 注册 + pub session_router: Arc, // 新增:会话路由 +} +``` + +--- + +## 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[]`) + +**触点 4:AI 聊天入口(复用已有 `/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 消耗 +- 基础 AI(Copilot 规则引擎、本地风险评分)不消耗 Token +- 高级 AI(LLM 对话、化验解读、趋势分析、报告摘要)消耗 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 { + // 从 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 1A:AI→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 3:RAG + 语音(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 实现时 | 推送频率控制 |