diff --git a/docs/superpowers/plans/2026-04-25-erp-ai-phase1-mvp.md b/docs/superpowers/plans/2026-04-25-erp-ai-phase1-mvp.md index 993eb65..67839fd 100644 --- a/docs/superpowers/plans/2026-04-25-erp-ai-phase1-mvp.md +++ b/docs/superpowers/plans/2026-04-25-erp-ai-phase1-mvp.md @@ -1964,3 +1964,254 @@ git commit -m "feat(server): erp-ai 模块集成 — Config/State/路由注册" ``` --- + +## Chunk 6: 完善 SSE Handler + 端到端验证 + +### Task 11: 实现 SSE 分析 Handler + +**Files:** +- Rewrite: `crates/erp-ai/src/handler/mod.rs` + +- [ ] **Step 1: 完善 handler/mod.rs — SSE 流式分析** + +```rust +// crates/erp-ai/src/handler/mod.rs +use axum::{ + extract::{Extension, Path, Query, State}, + response::sse::{Event, KeepAlive, Sse}, + Json, +}; +use axum::extract::FromRef; +use erp_core::rbac::require_permission; +use erp_core::tenant::TenantContext; +use futures::StreamExt; +use serde::Deserialize; +use std::convert::Infallible; + +use crate::dto::{AnalyzeOptions, AnalysisSseEvent, AnalysisType, TokenUsage}; +use crate::state::AiState; + +// === 分析请求 Query/Body === + +#[derive(Debug, Deserialize)] +pub struct AnalyzeBody { + pub report_id: Option, + pub patient_id: Option, + pub metrics: Option>, + pub options: Option, +} + +// === SSE 分析端点 === + +pub async fn stream_lab_report( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>>, erp_core::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.analysis.manage")?; + let report_id = body.report_id.ok_or_else(|| erp_core::AppError::Validation("report_id 必填".into()))?; + + let prompt = state.prompt.get_active_prompt(ctx.tenant_id(), "lab_report_interpretation").await?; + let model_config: serde_json::Value = serde_json::from_str(&serde_json::to_string(&prompt.model_config).unwrap_or_default()).unwrap_or_default(); + + let model = model_config["model"].as_str().unwrap_or("claude-sonnet-4-6").to_string(); + let temperature = model_config["temperature"].as_f32().unwrap_or(0.3); + let max_tokens = model_config["max_tokens"].as_u64().unwrap_or(2048) as u32; + + let source_ref = report_id.to_string(); + let (stream, analysis_id, provider_name) = state.analysis.stream_analyze( + ctx.tenant_id(), ctx.user_id(), uuid::Uuid::nil(), + AnalysisType::LabReport, source_ref, + prompt.system_prompt, prompt.user_prompt_template, + serde_json::json!({"placeholder": true}), + model, temperature, max_tokens, + ).await?; + + let analysis_id_clone = analysis_id; + let provider_name_clone = provider_name; + let model_clone = model.clone(); + let state_clone = state.clone(); + + let sse_stream = async_stream::stream! { + let mut full_content = String::new(); + let mut index: u32 = 0; + + let mut stream = std::pin::pin!(stream); + while let Some(result) = stream.next().await { + match result { + Ok(chunk) => { + full_content.push_str(&chunk); + index += 1; + let event = AnalysisSseEvent::Chunk { content: chunk, index }; + let data = serde_json::to_string(&event).unwrap_or_default(); + yield Ok(Event::default().data(data)); + } + Err(e) => { + let event = AnalysisSseEvent::Error { message: e.to_string() }; + let data = serde_json::to_string(&event).unwrap_or_default(); + yield Ok(Event::default().data(data)); + let _ = state_clone.analysis.fail_analysis(analysis_id_clone, e.to_string()).await; + return; + } + } + } + + // 完成: 存储结果 + let metadata = serde_json::json!({ + "model": model_clone, + "provider": provider_name_clone, + }); + let _ = state_clone.analysis.complete_analysis(analysis_id_clone, full_content, metadata).await; + + let done_event = AnalysisSseEvent::Done { + analysis_id: analysis_id_clone, + status: "completed".into(), + }; + let data = serde_json::to_string(&done_event).unwrap_or_default(); + yield Ok(Event::default().data(data)); + }; + + Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default())) +} + +// === 趋势/方案/摘要端点 — 结构类似,切换 analysis_type 和 prompt name === + +macro_rules! analyze_endpoint { + ($fn_name:ident, $analysis_type:expr, $prompt_name:literal) => { + pub async fn $fn_name( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, + ) -> Result>>, erp_core::AppError> + where + AiState: FromRef, + S: Clone + Send + Sync + 'static, + { + require_permission(&ctx, "ai.analysis.manage")?; + // 与 stream_lab_report 相同的流程,区别: + // - analysis_type: $analysis_type + // - prompt name: $prompt_name + // - source_ref 来自 body.patient_id 或 body.report_id + // 实现时从 stream_lab_report 复制并修改对应参数 + todo!("参照 stream_lab_report 实现") + } + }; +} + +analyze_endpoint!(stream_trends, AnalysisType::Trends, "health_trend_analysis"); +analyze_endpoint!(stream_checkup_plan, AnalysisType::CheckupPlan, "personalized_checkup_plan"); +analyze_endpoint!(stream_report_summary, AnalysisType::ReportSummary, "report_summary_generation"); + +// === 分析历史 === + +#[derive(Debug, Deserialize)] +pub struct ListAnalysisQuery { + pub patient_id: Option, + pub analysis_type: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn list_analysis( + State(_state): State, + Extension(ctx): Extension, + Query(_params): Query, +) -> Result>, erp_core::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.analysis.list")?; + // TODO: 查询 ai_analysis_results 表 + Ok(Json(erp_core::ApiResponse::success(()))) +} + +pub async fn get_analysis( + State(_state): State, + Extension(ctx): Extension, + Path(_id): Path, +) -> Result>, erp_core::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.analysis.list")?; + // TODO: 查询单条分析结果 + Ok(Json(erp_core::ApiResponse::success(()))) +} +``` + +> 注意: `erp_core::ApiResponse::success` 和 `erp_core::rbac::require_permission` 的实际签名需参照现有代码。宏 `analyze_endpoint!` 展开的函数暂时用 `todo!()`,实际实现时从 `stream_lab_report` 复制并修改参数。 + +- [ ] **Step 2: 验证编译** + +```bash +cargo check --workspace +``` + +- [ ] **Step 3: 提交** + +```bash +git add crates/erp-ai/src/handler/ +git commit -m "feat(ai): SSE 流式分析 Handler 实现" +``` + +--- + +### Task 12: 端到端验证 + +- [ ] **Step 1: 启动 PostgreSQL + 后端服务** + +```bash +cd crates/erp-server && cargo run +``` + +验证: +- 迁移自动执行,`ai_prompts`/`ai_analysis_results`/`ai_usage_logs` 三张表创建成功 +- 服务启动无错误日志 + +- [ ] **Step 2: 检查 API 文档** + +访问 `http://localhost:3000/api/docs/openapi.json`,确认 `/api/v1/ai/analyze/*` 端点已注册 + +- [ ] **Step 3: 数据库验证** + +```sql +SELECT table_name FROM information_schema.tables WHERE table_name LIKE 'ai_%'; +``` + +Expected: `ai_prompts`, `ai_analysis_results`, `ai_usage_logs` + +- [ ] **Step 4: 提交最终状态 + 推送** + +```bash +git push +``` + +--- + +## 验证清单 + +- [ ] `cargo check --workspace` — 全 workspace 编译通过 +- [ ] `cargo test --workspace` — 所有测试通过 +- [ ] PostgreSQL 三张 AI 表存在 +- [ ] `/api/v1/ai/analyze/lab-report` 端点在 Swagger UI 可见 +- [ ] AI 权限码已同步到数据库 `permissions` 表 +- [ ] `AiModule` 出现在启动日志的已注册模块列表中 + +--- + +## 后续计划 (Phase 1 MVP 剩余) + +以下任务在实际实现阶段细化: + +- **Task 13:** HealthDataProvider 实际数据查询实现 (替换 erp-health stub) +- **Task 14:** 种子 Prompt 模板数据 (4 个默认模板通过迁移插入) +- **Task 15:** Redis 缓存层 (缓存命中时跳过 AI 调用) +- **Task 16:** 降级规则引擎 (50 条常见指标本地规则) +- **Task 17:** 速率限制中间件 +- **Task 18:** 小程序 AI 解读页面 (报告详情页 + SSE 渲染)