docs(ai): 实施计划 Chunk 6 (SSE Handler + 端到端验证 + 后续任务)
This commit is contained in:
@@ -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 渲染)
|
||||
|
||||
Reference in New Issue
Block a user