docs(ai): 实施计划 Chunk 6 (SSE Handler + 端到端验证 + 后续任务)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

This commit is contained in:
iven
2026-04-25 13:41:23 +08:00
parent 41b17eedad
commit 2963e7ce63

View File

@@ -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<uuid::Uuid>,
pub patient_id: Option<uuid::Uuid>,
pub metrics: Option<Vec<String>>,
pub options: Option<AnalyzeOptions>,
}
// === SSE 分析端点 ===
pub async fn stream_lab_report<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<AnalyzeBody>,
) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, erp_core::AppError>
where
AiState: FromRef<S>,
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<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<AnalyzeBody>,
) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, erp_core::AppError>
where
AiState: FromRef<S>,
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<uuid::Uuid>,
pub analysis_type: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
pub async fn list_analysis<S>(
State(_state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Query(_params): Query<ListAnalysisQuery>,
) -> Result<Json<erp_core::ApiResponse<()>>, erp_core::AppError>
where
AiState: FromRef<S>,
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<S>(
State(_state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(_id): Path<uuid::Uuid>,
) -> Result<Json<erp_core::ApiResponse<()>>, erp_core::AppError>
where
AiState: FromRef<S>,
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 渲染)