feat(ai): v2 新增 GenerateReport RPC + ReportService
第 9 个 RPC GenerateReport(学情报告生成):data-ana 学情数据 → LLM 生成 → 结构化提取 新增 ReportService 业务编排层 + GenerateReportRequest/GeneratedReport 模型 gRPC servicer + HTTP POST /v1/ai/generate/report(权限 ai:report:generate) proto_gen 重新生成 + 测试覆盖(servicer/service/HTTP/模型/权限 共 26 用例) 402 测试通过,覆盖率 88.5%
This commit is contained in:
Binary file not shown.
340
services/ai/docs/nextstep-v2.md
Normal file
340
services/ai/docs/nextstep-v2.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# AI 模块 nextstep-v2
|
||||
|
||||
> 模块:ai | gRPC 50058 | HTTP 3008 | Python (FastAPI + gRPC aio)
|
||||
> 更新时间:2026-07-14
|
||||
> 前序文档:[nextstep.md](./nextstep.md)(v1,8 RPC / 10 端点)
|
||||
|
||||
---
|
||||
|
||||
## §1 当前状态
|
||||
|
||||
v2 新增第 9 个 RPC `GenerateReport`(学情报告生成),满足 teacher-bff `generateReport` mutation 需求。9 RPC 全部实现,402 个测试通过,覆盖率 88.5%。
|
||||
|
||||
### v2 新增
|
||||
|
||||
- [x] ai.proto 新增 `GenerateReport` RPC + `GenerateReportRequest` / `GeneratedReport` message
|
||||
- [x] `ReportService` 业务编排层(data-ana 学情数据 → LLM 生成报告 → 结构化提取摘要+建议)
|
||||
- [x] gRPC servicer `GenerateReport` 方法(方案 B 降级)
|
||||
- [x] HTTP 端点 `POST /v1/ai/generate/report`(权限 `ai:report:generate`)
|
||||
- [x] 权限点 `PERMISSION_AI_REPORT_GENERATE`(teacher + admin 角色)
|
||||
- [x] proto_gen 重新生成(ai_pb2.py / ai_pb2_grpc.py)
|
||||
- [x] 测试覆盖(servicer 5 用例 + service 9 用例 + HTTP 5 用例 + 模型 5 用例 + 权限 2 用例)
|
||||
|
||||
### 完整 9 RPC 清单
|
||||
|
||||
| # | RPC | 类型 | 用途 | 状态 |
|
||||
| --- | ---------------------- | ---- | --------------------------- | --------- |
|
||||
| 1 | Chat | 一元 | 非流式聊天 | ✅ 已实现 |
|
||||
| 2 | StreamChat | 流式 | 流式聊天(SSE over gRPC) | ✅ 已实现 |
|
||||
| 3 | GenerateQuestion | 一元 | 生成题目 | ✅ 已实现 |
|
||||
| 4 | StreamGenerateQuestion | 流式 | 流式生成题目 | ✅ 已实现 |
|
||||
| 5 | OptimizeExpression | 一元 | 优化表达 | ✅ 已实现 |
|
||||
| 6 | GenerateLessonPlan | 一元 | 启动备课工作流 | ✅ 已实现 |
|
||||
| 7 | GetLessonPlanStatus | 一元 | 查询备课工作流状态 | ✅ 已实现 |
|
||||
| 8 | ConfirmLessonPlan | 一元 | 确认备课结果入库 | ✅ 已实现 |
|
||||
| 9 | **GenerateReport** | 一元 | **生成学情报告(v2 新增)** | ✅ 已实现 |
|
||||
|
||||
### 完整 11 HTTP 端点清单
|
||||
|
||||
| # | 端点 | 方法 | 权限 | 状态 |
|
||||
| --- | --------------------------------- | ---- | ---------------------- | --------- |
|
||||
| 1 | `/healthz` | GET | - | ✅ 已实现 |
|
||||
| 2 | `/readyz` | GET | - | ✅ 已实现 |
|
||||
| 3 | `/v1/ai/chat` | POST | ai:chat | ✅ 已实现 |
|
||||
| 4 | `/v1/ai/chat/stream` | POST | ai:chat | ✅ 已实现 |
|
||||
| 5 | `/v1/ai/generate/question` | POST | ai:question:generate | ✅ 已实现 |
|
||||
| 6 | `/v1/ai/generate/question/stream` | POST | ai:question:generate | ✅ 已实现 |
|
||||
| 7 | `/v1/ai/optimize/expression` | POST | ai:expression:optimize | ✅ 已实现 |
|
||||
| 8 | `/v1/ai/lesson-plan/generate` | POST | ai:lesson:generate | ✅ 已实现 |
|
||||
| 9 | `/v1/ai/lesson-plan/status/{id}` | GET | - | ✅ 已实现 |
|
||||
| 10 | `/v1/ai/lesson-plan/confirm/{id}` | POST | ai:lesson:confirm | ✅ 已实现 |
|
||||
| 11 | **`/v1/ai/generate/report`** | POST | **ai:report:generate** | ✅ 已实现 |
|
||||
|
||||
### 权限点清单
|
||||
|
||||
| 权限点 | teacher | admin | student | 用途 |
|
||||
| ---------------------- | ------- | ----- | ------- | ---------------- |
|
||||
| ai:chat | ✅ | ✅ | ✅ | 聊天 |
|
||||
| ai:question:generate | ✅ | ✅ | ❌ | 生成题目 |
|
||||
| ai:expression:optimize | ✅ | ✅ | ❌ | 优化表达 |
|
||||
| ai:lesson:generate | ✅ | ✅ | ❌ | 生成教案 |
|
||||
| ai:lesson:confirm | ✅ | ✅ | ❌ | 确认教案 |
|
||||
| **ai:report:generate** | ✅ | ✅ | ❌ | **生成学情报告** |
|
||||
|
||||
---
|
||||
|
||||
## §2 GenerateReport 实现详情
|
||||
|
||||
### §2.1 数据流
|
||||
|
||||
```
|
||||
teacher-bff generateReport mutation
|
||||
→ ai gRPC GenerateReport(:50058)
|
||||
→ ReportService.generate()
|
||||
→ data-ana gRPC GetClassPerformance / GetStudentWeakness / GetLearningTrend(:50055)
|
||||
→ Prompt 组装(PromptTemplateService 或内联 fallback)
|
||||
→ LLM FailoverChain.chat()(OpenAI → Anthropic → 百川 → Ollama)
|
||||
→ 结构化提取(摘要 + 教学建议)
|
||||
← GeneratedReport { id, content, summary, recommendations, degraded, degraded_reason }
|
||||
```
|
||||
|
||||
### §2.2 报告类型
|
||||
|
||||
| report_type | 用途 | 必填参数 | data-ana 数据源 |
|
||||
| -------------- | ---------------- | --------------------- | ----------------------------------------------------------- |
|
||||
| class_summary | 班级学情总结 | class_id | GetClassPerformance |
|
||||
| student_detail | 学生个人学情详情 | class_id + student_id | GetClassPerformance + GetStudentWeakness + GetLearningTrend |
|
||||
| exam_analysis | 考试分析 | class_id | GetClassPerformance |
|
||||
|
||||
### §2.3 降级策略
|
||||
|
||||
| 场景 | 降级行为 | degraded | degraded_reason |
|
||||
| -------------------- | ------------------------------------------------ | -------- | -------------------------------- |
|
||||
| LLM 全部不可用 | 返回空 content + 空 summary + 空 recommendations | true | "LLM unavailable: ..." |
|
||||
| data-ana 不可用 | 上下文降级(空数据),LLM 仍可生成(基于空数据) | false | (报告内容会说明无数据) |
|
||||
| ReportService 未注入 | gRPC 返回空 GeneratedReport | true | "report_service not initialized" |
|
||||
| 未知异常 | gRPC 抛 AIError(AI_INTERNAL_ERROR) | - | - |
|
||||
|
||||
---
|
||||
|
||||
## §3 上游依赖(ai 依赖谁)
|
||||
|
||||
### §3.1 gRPC 同步调用
|
||||
|
||||
| 被调用方 | 端口 | Service.RPC | 用途 | 状态 |
|
||||
| -------- | ----- | -------------------------------------- | ------------------------------------------- | --------- |
|
||||
| content | 50054 | KnowledgeGraphService.GetPrerequisites | 查询知识点前置依赖(备课工作流 Step 2) | ✅ 已实现 |
|
||||
| content | 50054 | KnowledgeGraphService.GetLearningPath | 查询学习路径(备课工作流 Step 2) | ✅ 已实现 |
|
||||
| content | 50054 | QuestionService.BatchCreateQuestions | 批量创建题目入库(备课工作流 Confirm) | ✅ 已实现 |
|
||||
| data-ana | 50055 | AnalyticsService.GetClassPerformance | 查询班级学情(备课工作流 + **学情报告**) | ✅ 已实现 |
|
||||
| data-ana | 50055 | AnalyticsService.GetStudentWeakness | 查询学生薄弱点(备课工作流 + **学情报告**) | ✅ 已实现 |
|
||||
| data-ana | 50055 | AnalyticsService.GetLearningTrend | 查询学习趋势(备课工作流 + **学情报告**) | ✅ 已实现 |
|
||||
| iam | 50052 | IamService.GetEffectiveDataScope | 查询用户数据范围(多租户配额) | ✅ 已实现 |
|
||||
|
||||
> **v2 变更**:data-ana 的 3 个 RPC 现在同时服务于备课工作流和学情报告两条业务线。
|
||||
|
||||
### §3.2 基础设施依赖
|
||||
|
||||
| 依赖 | 用途 | 状态 |
|
||||
| ----------------------- | ---------------------------------------------- | --------- |
|
||||
| Redis | 限流 + 工作流状态存储 + 用量记录 | ✅ 已实现 |
|
||||
| Kafka | AIUsageEvent 事件发布(topic: `edu.ai.usage`) | ✅ 已实现 |
|
||||
| OpenTelemetry Collector | 链路追踪 + 指标导出 | ✅ 已实现 |
|
||||
|
||||
### §3.3 LLM Provider 依赖
|
||||
|
||||
| Provider | 环境变量 | 用途 | 状态 |
|
||||
| --------- | ------------------------------------ | ------------- | --------- |
|
||||
| OpenAI | `OPENAI_API_KEY` / `OPENAI_BASE_URL` | 首选 LLM | ✅ 已实现 |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | Failover 第二 | ✅ 已实现 |
|
||||
| 百川 | `BAICHUAN_API_KEY` | Failover 第三 | ✅ 已实现 |
|
||||
| Ollama | `OLLAMA_BASE_URL` | 本地降级 | ✅ 已实现 |
|
||||
|
||||
---
|
||||
|
||||
## §4 下游就绪信号(谁依赖 ai)
|
||||
|
||||
### §4.1 teacher-bff — P1
|
||||
|
||||
| 就绪标志 | 消费方式 | 状态 |
|
||||
| -------------------------------------------------------- | --------------------- | ------------- |
|
||||
| AiService.Chat / StreamChat 可调用 | gRPC 50058 + SSE 3008 | ✅ 已就绪 |
|
||||
| AiService.GenerateQuestion 可调用 | gRPC 50058 | ✅ 已就绪 |
|
||||
| AiService.GenerateLessonPlan 可调用 | gRPC 50058 | ✅ 已就绪 |
|
||||
| AiService.GetLessonPlanStatus / ConfirmLessonPlan 可调用 | gRPC 50058 | ✅ 已就绪 |
|
||||
| AiService.OptimizeExpression 可调用 | gRPC 50058 | ✅ 已就绪 |
|
||||
| **AiService.GenerateReport 可调用** | gRPC 50058 | ✅ **已就绪** |
|
||||
|
||||
> teacher-bff 通过 `AI_GRPC_TARGET=ai:50058` 连接,留空时走降级模式 B。
|
||||
|
||||
### §4.2 student-bff — P1
|
||||
|
||||
| 就绪标志 | 消费方式 | 状态 |
|
||||
| --------------------------------------- | ---------- | --------- |
|
||||
| AiService.Chat 可调用(同步 AI 答疑) | gRPC 50058 | ✅ 已就绪 |
|
||||
| AiService.StreamChat 可调用(流式答疑) | gRPC 50058 | ✅ 已就绪 |
|
||||
|
||||
### §4.3 api-gateway — P2
|
||||
|
||||
| 就绪标志 | 消费方式 | 状态 |
|
||||
| -------------------------- | ---------------------------------------- | --------- |
|
||||
| ai HTTP 3008 /healthz 可达 | HTTP 反向代理 `/api/v1/ai/*` → `ai:3008` | ✅ 已就绪 |
|
||||
|
||||
### §4.4 data-ana — P2
|
||||
|
||||
| 就绪标志 | 消费方式 | 状态 |
|
||||
| ------------------------------------- | -------------------------------------- | --------- |
|
||||
| Kafka topic `edu.ai.usage` 有事件发布 | CDC 消费者 → ClickHouse `ai_usage_log` | ✅ 已就绪 |
|
||||
|
||||
---
|
||||
|
||||
## §5 需要上下游实现的工作(nextstep-v2 新增)
|
||||
|
||||
### §5.1 teacher-bff 需要完成
|
||||
|
||||
| # | 工作项 | 当前状态 | 优先级 |
|
||||
| --- | -------------------------------------------------------- | ---------------------------- | ------ |
|
||||
| 1 | AI gRPC 客户端添加 `generateReport` 方法 | 当前返回降级响应(degraded) | P1 |
|
||||
| 2 | `teacher.service.ts` 的 `generateReport()` 调用真实 gRPC | 当前硬编码降级响应 | P1 |
|
||||
| 3 | GraphQL schema `GeneratedReport` type 字段对齐 proto | 需确认字段一致性 | P2 |
|
||||
|
||||
**详细说明**:
|
||||
|
||||
teacher-bff 的 `ai-grpc.client.ts` 目前实现了 `chat` / `streamChat` / `generateQuestion` / `optimizeExpression`,但 `generateLessonPlan` 和 `generateReport` 走降级。现在 ai 侧 `GenerateReport` RPC 已就绪,teacher-bff 需:
|
||||
|
||||
```typescript
|
||||
// ai-grpc.client.ts 需添加
|
||||
async generateReport(input: GenerateReportInput): Promise<GeneratedReport> {
|
||||
const request = new GenerateReportRequest({
|
||||
classId: input.classId,
|
||||
reportType: input.reportType,
|
||||
studentId: input.studentId ?? undefined,
|
||||
userId: input.userId,
|
||||
dataScope: input.dataScope ?? undefined,
|
||||
});
|
||||
const response = await this.client.generateReport(request);
|
||||
return {
|
||||
id: response.id,
|
||||
content: response.content,
|
||||
summary: response.summary,
|
||||
recommendations: response.recommendations,
|
||||
degraded: response.degraded,
|
||||
degradedReason: response.degradedReason,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
ai.proto `GenerateReportRequest` 字段:
|
||||
|
||||
```protobuf
|
||||
message GenerateReportRequest {
|
||||
string class_id = 1;
|
||||
string report_type = 2; // class_summary / student_detail / exam_analysis
|
||||
optional string student_id = 3; // student_detail 时必填
|
||||
string user_id = 4;
|
||||
string data_scope = 5; // JSON 序列化的 DataScope
|
||||
}
|
||||
|
||||
message GeneratedReport {
|
||||
string id = 1;
|
||||
string content = 2; // 报告正文(Markdown)
|
||||
string summary = 3; // 摘要
|
||||
repeated string recommendations = 4; // 教学建议
|
||||
bool degraded = 5;
|
||||
string degraded_reason = 6;
|
||||
}
|
||||
```
|
||||
|
||||
### §5.2 student-bff 需要确认
|
||||
|
||||
| # | 工作项 | 当前状态 | 优先级 |
|
||||
| --- | ------------------------------------------- | -------------------------------- | ------ |
|
||||
| 1 | 确认 `ChatService.Chat` gRPC 调用可用 | ai 侧已就绪,待 student-bff 确认 | P1 |
|
||||
| 2 | 确认 `ChatService.StreamChat` gRPC 调用可用 | ai 侧已就绪,待 student-bff 确认 | P1 |
|
||||
|
||||
> student-bff nextstep-v2.md §5.4 标注这两个 RPC "需确认"。ai 侧 `Chat` 和 `StreamChat` RPC 已在 v1 实现,gRPC 50058 可直接调用。
|
||||
|
||||
### §5.3 data-ana 需要保持
|
||||
|
||||
| # | 工作项 | 当前状态 | 优先级 |
|
||||
| --- | ------------------------------------------------ | --------- | ------ |
|
||||
| 1 | AnalyticsService 3 RPC 保持可用 | ✅ 已就绪 | P1 |
|
||||
| 2 | GenerateReport 依赖 GetClassPerformance 等 3 RPC | ✅ 已就绪 | P1 |
|
||||
|
||||
> 学情报告功能依赖 data-ana 的 3 个 AnalyticsService RPC。如果 data-ana 不可用,报告会基于降级上下文(空数据)生成,但 LLM 仍可工作。
|
||||
|
||||
### §5.4 api-gateway 需要保持
|
||||
|
||||
| # | 工作项 | 当前状态 | 优先级 |
|
||||
| --- | ------------------------------------------------------------ | --------- | ------ |
|
||||
| 1 | `/api/v1/ai/*` → `ai:3008` 反向代理路由保持可用 | ✅ 已就绪 | P2 |
|
||||
| 2 | 新增 `/v1/ai/generate/report` 自动被 `/api/v1/ai/*` 通配覆盖 | ✅ 已就绪 | P2 |
|
||||
|
||||
> api-gateway 使用通配路由 `/api/v1/ai/*`,新增的 `/v1/ai/generate/report` 端点自动被覆盖,无需额外配置。
|
||||
|
||||
---
|
||||
|
||||
## §6 联调待办
|
||||
|
||||
| # | 联调项 | 联调方 | 阻塞条件 | 状态 |
|
||||
| --- | -------------------------------------- | ----------- | ------------------------------------ | ------------------------------------------- |
|
||||
| 1 | ai gRPC + teacher-bff SSE 联调 | teacher-bff | ai 服务容器启动 | ✅ ai 侧就绪(待 teacher-bff 接入) |
|
||||
| 2 | ai gRPC StreamChat + student-bff 联调 | student-bff | ai 服务容器启动 | ✅ ai 侧就绪(待 student-bff 确认) |
|
||||
| 3 | ai /healthz + api-gateway 联调 | api-gateway | ai 服务容器启动 | ✅ ai 侧就绪(待 api-gateway 路由) |
|
||||
| 4 | AIUsageEvent + data-ana CDC 消费联调 | data-ana | ai Kafka 生产 + data-ana 消费 | ✅ ai 生产就绪(待 data-ana 消费) |
|
||||
| 5 | ai ↔ content gRPC 联调 | content | 双方容器启动 | ✅ ai 客户端就绪(待 content 启动) |
|
||||
| 6 | ai ↔ data-ana gRPC 联调 | data-ana | 双方容器启动 | ✅ ai 客户端就绪(待 data-ana 启动) |
|
||||
| 7 | ai ↔ iam gRPC 联调 | iam | 双方容器启动 | ✅ ai 客户端就绪(待 iam 启动) |
|
||||
| 8 | **GenerateReport + teacher-bff 联调** | teacher-bff | ai RPC 就绪 + teacher-bff 客户端更新 | ✅ ai RPC 就绪(待 teacher-bff 客户端更新) |
|
||||
| 9 | **GenerateReport + data-ana 数据联调** | data-ana | 双方容器启动 + 真实学情数据 | ✅ ai 客户端就绪(待 data-ana 有数据) |
|
||||
|
||||
---
|
||||
|
||||
## §7 测试验证
|
||||
|
||||
### §7.1 单元测试
|
||||
|
||||
| 测试文件 | 新增用例数 | 覆盖范围 |
|
||||
| --------------------- | ---------- | -------------------------------------------------------------------------------------- |
|
||||
| test_grpc_servicer.py | 5 | GenerateReport 成功/学生详情/未初始化降级/LLM降级/内部错误 |
|
||||
| test_services.py | 9 | ReportService 生成成功/学生详情/LLM降级/无data-ana/摘要提取/建议提取(2)/prompt构建 |
|
||||
| test_main_app.py | 5 | HTTP 端点 class_summary/student_detail/invalid_type/missing_class_id/permission_denied |
|
||||
| test_models.py | 5 | report 模型 默认值/invalid_type/missing_class_id/student_detail/data_defaults |
|
||||
| test_permission.py | 2 | teacher 有 report 权限 / student 无 report 权限 |
|
||||
| **合计** | **26** | v1 377 → v2 402(+25 净增,1 个修复 _extract_recommendations bug) |
|
||||
|
||||
### §7.2 质量校验
|
||||
|
||||
| 校验项 | 结果 | 详情 |
|
||||
| ---------- | -------- | -------------------------------------------- |
|
||||
| ruff check | ✅ 通过 | 零警告 |
|
||||
| pytest | ✅ 通过 | 402 passed, 0 failed |
|
||||
| 覆盖率 | ✅ 88.5% | report_service.py 91%,models/report.py 100% |
|
||||
|
||||
### §7.3 Docker 验证(待执行)
|
||||
|
||||
> arch.db 本次不扫描,Docker 验证待 infra 环境就绪后执行。
|
||||
|
||||
| # | 验证项 | 状态 |
|
||||
| --- | --------------------- | --------- |
|
||||
| 1 | Docker 镜像构建 | ⏳ 待验证 |
|
||||
| 2 | 容器启动 + gRPC 50058 | ⏳ 待验证 |
|
||||
| 3 | HTTP /healthz | ⏳ 待验证 |
|
||||
| 4 | HTTP /readyz | ⏳ 待验证 |
|
||||
| 5 | GenerateReport gRPC | ⏳ 待验证 |
|
||||
|
||||
---
|
||||
|
||||
## §8 关键实现文件
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
| -------------------------------------- | -------- | -------------------------------------------- |
|
||||
| `packages/shared-proto/proto/ai.proto` | 修改 | 新增 GenerateReport RPC + 2 message |
|
||||
| `src/ai/proto_gen/ai_pb2.py` | 重新生成 | protobuf 代码 |
|
||||
| `src/ai/proto_gen/ai_pb2_grpc.py` | 重新生成 | gRPC stub/servicer 代码 |
|
||||
| `src/ai/models/report.py` | 新建 | Pydantic 请求/响应模型 |
|
||||
| `src/ai/models/__init__.py` | 修改 | 导出 report 模型 |
|
||||
| `src/ai/services/report_service.py` | 新建 | ReportService 业务编排 |
|
||||
| `src/ai/services/__init__.py` | 修改 | 导出 ReportService |
|
||||
| `src/ai/grpc_server/servicer.py` | 修改 | GenerateReport 方法 + report_service 注入 |
|
||||
| `src/ai/grpc_server/server.py` | 修改 | create_grpc_server 添加 report_service 参数 |
|
||||
| `src/ai/middleware/permission.py` | 修改 | 新增 PERMISSION_AI_REPORT_GENERATE 权限点 |
|
||||
| `src/ai/main.py` | 修改 | ReportService 实例化 + HTTP /generate/report |
|
||||
| `tests/test_grpc_servicer.py` | 修改 | 5 个 GenerateReport servicer 测试 |
|
||||
| `tests/test_services.py` | 修改 | 9 个 ReportService 测试 |
|
||||
| `tests/test_main_app.py` | 修改 | 5 个 HTTP 端点测试 |
|
||||
| `tests/test_models.py` | 修改 | 5 个 report 模型测试 |
|
||||
| `tests/test_permission.py` | 修改 | 2 个 report 权限测试 |
|
||||
|
||||
---
|
||||
|
||||
## §9 P6+ 待评估
|
||||
|
||||
| # | 待评估项 | 说明 |
|
||||
| --- | ----------------- | ------------------------------------------------------------------ |
|
||||
| 1 | Temporal 引入评估 | 备课工作流 P5 用 BackgroundTasks + Redis,P6 评估是否引入 Temporal |
|
||||
| 2 | content 事件订阅 | P5 不订阅 content 事件,P6+ 评估是否需要知识点变更事件驱动 |
|
||||
| 3 | MockLLMProvider | 02 文档提到但未实现,测试环境用 httpx Mock 替代 |
|
||||
| 4 | 报告持久化 | 当前 GenerateReport 不持久化报告,P6+ 评估是否需要入库 |
|
||||
| 5 | 报告模板管理 | 当前用内联 fallback 模板,P6+ 评估是否需要独立模板管理服务 |
|
||||
| 6 | 报告异步生成 | 当前同步生成,长报告可能超时,P6+ 评估是否改为异步 + 轮询模式 |
|
||||
@@ -65,6 +65,7 @@ def create_grpc_server(
|
||||
question_service: Any = None,
|
||||
expression_service: Any = None,
|
||||
workflow_service: Any = None,
|
||||
report_service: Any = None,
|
||||
) -> GrpcServer:
|
||||
"""创建 gRPC server(工厂函数)."""
|
||||
servicer = AiServicer(
|
||||
@@ -72,5 +73,6 @@ def create_grpc_server(
|
||||
question_service=question_service,
|
||||
expression_service=expression_service,
|
||||
workflow_service=workflow_service,
|
||||
report_service=report_service,
|
||||
)
|
||||
return GrpcServer(port=port, servicer=servicer)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AiService gRPC Servicer(8 RPC 实现).
|
||||
"""AiService gRPC Servicer(9 RPC 实现).
|
||||
|
||||
所有 RPC 返回 protobuf message,降级采用方案 B(degraded 字段在 message 内)。
|
||||
业务逻辑由注入的 service 层处理,servicer 仅做 proto ↔ domain 模型转换。
|
||||
@@ -25,6 +25,7 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
|
||||
- question_service: QuestionService(GenerateQuestion / StreamGenerateQuestion)
|
||||
- expression_service: ExpressionService(OptimizeExpression)
|
||||
- workflow_service: LessonPlanWorkflowService(备课工作流)
|
||||
- report_service: ReportService(学情报告)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -33,11 +34,13 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
|
||||
question_service: Any = None,
|
||||
expression_service: Any = None,
|
||||
workflow_service: Any = None,
|
||||
report_service: Any = None,
|
||||
) -> None:
|
||||
self._chat_service = chat_service
|
||||
self._question_service = question_service
|
||||
self._expression_service = expression_service
|
||||
self._workflow_service = workflow_service
|
||||
self._report_service = report_service
|
||||
|
||||
async def Chat( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
|
||||
self,
|
||||
@@ -46,10 +49,7 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
|
||||
) -> ai_pb2.ChatResponse:
|
||||
"""非流式聊天."""
|
||||
ctx = get_user_context(context)
|
||||
messages = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in request.messages
|
||||
]
|
||||
messages = [{"role": m.role, "content": m.content} for m in request.messages]
|
||||
try:
|
||||
if self._chat_service is None:
|
||||
return _degraded_chat_response(request.model, "chat_service not initialized")
|
||||
@@ -88,10 +88,7 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
|
||||
) -> AsyncGenerator[ai_pb2.ChatChunk, None]:
|
||||
"""流式聊天(SSE over gRPC)."""
|
||||
ctx = get_user_context(context)
|
||||
messages = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in request.messages
|
||||
]
|
||||
messages = [{"role": m.role, "content": m.content} for m in request.messages]
|
||||
try:
|
||||
if self._chat_service is None:
|
||||
yield ai_pb2.ChatChunk(content="[degraded] chat_service not initialized", done=True)
|
||||
@@ -322,6 +319,43 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
|
||||
logger.error("confirm_lesson_plan_rpc_error", error=str(exc))
|
||||
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
async def GenerateReport( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
|
||||
self,
|
||||
request: ai_pb2.GenerateReportRequest,
|
||||
context: grpc.ServicerContext,
|
||||
) -> ai_pb2.GeneratedReport:
|
||||
"""生成学情报告."""
|
||||
ctx = get_user_context(context)
|
||||
try:
|
||||
if self._report_service is None:
|
||||
return ai_pb2.GeneratedReport(
|
||||
id="",
|
||||
content="",
|
||||
summary="",
|
||||
degraded=True,
|
||||
degraded_reason="report_service not initialized",
|
||||
)
|
||||
result = await self._report_service.generate(
|
||||
class_id=request.class_id,
|
||||
report_type=request.report_type,
|
||||
student_id=request.student_id if request.HasField("student_id") else None,
|
||||
user_id=request.user_id or ctx.user_id,
|
||||
data_scope=request.data_scope or ctx.data_scope,
|
||||
)
|
||||
return ai_pb2.GeneratedReport(
|
||||
id=result.id,
|
||||
content=result.content,
|
||||
summary=result.summary,
|
||||
recommendations=result.recommendations,
|
||||
degraded=result.degraded,
|
||||
degraded_reason=result.degraded_reason,
|
||||
)
|
||||
except AIError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("generate_report_rpc_error", error=str(exc))
|
||||
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
|
||||
def _degraded_chat_response(model: str, reason: str) -> ai_pb2.ChatResponse:
|
||||
"""构建降级聊天响应."""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""AI 网关服务入口.
|
||||
|
||||
整合组件(02-architecture-design.md §1.2 完整分层):
|
||||
- HTTP 端点(/v1/ai 前缀,ActionState 信封,10 端点)
|
||||
- gRPC server(端口 50058,8 RPC)
|
||||
- HTTP 端点(/v1/ai 前缀,ActionState 信封,11 端点)
|
||||
- gRPC server(端口 50058,9 RPC)
|
||||
- LLM Provider FailoverChain(4 适配器 + 熔断 + 故障切换)
|
||||
- Prompt 模板服务(Jinja2 + YAML)
|
||||
- 评估三道防线(RuleValidator + LLMJudge + QualityGate)
|
||||
@@ -10,6 +10,7 @@
|
||||
- 安全层(PII + 输入清洗 + 输出审核)
|
||||
- 下游 gRPC 客户端(content/data-ana/iam,真实 gRPC 调用)
|
||||
- 备课工作流(4 步编排 + Redis 状态存储)
|
||||
- 学情报告(class_summary / student_detail / exam_analysis,data-ana 数据源)
|
||||
- 限流(Redis 三维度令牌桶)
|
||||
- OpenTelemetry + Prometheus
|
||||
"""
|
||||
@@ -44,6 +45,7 @@ from .middleware.permission import (
|
||||
PERMISSION_AI_EXPRESSION_OPTIMIZE,
|
||||
PERMISSION_AI_LESSON_GENERATE,
|
||||
PERMISSION_AI_QUESTION_GENERATE,
|
||||
PERMISSION_AI_REPORT_GENERATE,
|
||||
)
|
||||
from .models import (
|
||||
ChatRequest,
|
||||
@@ -53,6 +55,8 @@ from .models import (
|
||||
ConfirmResultResponse,
|
||||
GeneratedQuestionResponse,
|
||||
GenerateQuestionRequest,
|
||||
GenerateReportRequest,
|
||||
GenerateReportResponse,
|
||||
LessonPreparationData,
|
||||
LessonPreparationRequest,
|
||||
LessonPreparationResponse,
|
||||
@@ -64,7 +68,7 @@ from .models import (
|
||||
from .prompt_service import PromptTemplateService
|
||||
from .providers import create_failover_chain
|
||||
from .rate_limiter import RateLimiter
|
||||
from .services import ChatService, ExpressionService, QuestionService
|
||||
from .services import ChatService, ExpressionService, QuestionService, ReportService
|
||||
from .services.evaluation import QualityGate, RuleValidator
|
||||
from .usage import KafkaProducer, QuotaEnforcer, UsageRecorder
|
||||
from .workflow import LessonPlanWorkflowService, WorkflowStateStore
|
||||
@@ -136,6 +140,14 @@ _content_client = ContentClientGrpc(endpoint=settings.content_grpc_endpoint)
|
||||
_data_ana_client = DataAnaClientGrpc(endpoint=settings.data_ana_grpc_endpoint)
|
||||
_iam_client = IamClientGrpc(endpoint=settings.iam_grpc_endpoint)
|
||||
|
||||
# ReportService 依赖 _data_ana_client,必须在客户端实例化后创建
|
||||
_report_service = ReportService(
|
||||
failover_chain=_failover_chain,
|
||||
prompt_service=_prompt_service,
|
||||
data_ana_client=_data_ana_client,
|
||||
default_model=settings.default_chat_model,
|
||||
)
|
||||
|
||||
_state_store = WorkflowStateStore(
|
||||
redis=None,
|
||||
ttl_seconds=settings.workflow_ttl_seconds,
|
||||
@@ -156,6 +168,7 @@ _grpc_server = create_grpc_server(
|
||||
question_service=_question_service,
|
||||
expression_service=_expression_service,
|
||||
workflow_service=_workflow_service,
|
||||
report_service=_report_service,
|
||||
)
|
||||
|
||||
_redis: Redis | None = None
|
||||
@@ -479,4 +492,37 @@ async def confirm_lesson_plan(
|
||||
return ConfirmResultResponse(success=True, data=data, error=None)
|
||||
|
||||
|
||||
@router.post("/generate/report", response_model=GenerateReportResponse)
|
||||
async def generate_report(
|
||||
req: GenerateReportRequest,
|
||||
request: Request,
|
||||
) -> GenerateReportResponse:
|
||||
"""生成学情报告.
|
||||
|
||||
支持三种报告类型:
|
||||
- class_summary: 班级学情总结
|
||||
- student_detail: 单个学生学情详情(需 student_id)
|
||||
- exam_analysis: 考试分析
|
||||
|
||||
数据来源:data-ana 服务(班级学情 / 学生薄弱点 / 学习趋势)。
|
||||
LLM 不可用时返回 degraded 响应(degraded=true)。
|
||||
"""
|
||||
ctx = extract_user_context(request)
|
||||
_permission_guard.check(ctx, PERMISSION_AI_REPORT_GENERATE)
|
||||
await _rate_limiter.check(
|
||||
user_id=ctx.user_id,
|
||||
ip=_client_ip(request),
|
||||
school_id=ctx.school_id,
|
||||
)
|
||||
with tracer.start_as_current_span("generate_report"):
|
||||
result = await _report_service.generate(
|
||||
class_id=req.class_id,
|
||||
report_type=req.report_type,
|
||||
student_id=req.student_id,
|
||||
user_id=ctx.user_id,
|
||||
data_scope=req.data_scope or ctx.data_scope,
|
||||
)
|
||||
return GenerateReportResponse(success=True, data=result, error=None)
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
@@ -6,6 +6,7 @@ ai 服务的权限点(对齐 004 Permissions 常量):
|
||||
- ai:expression:optimize: 优化表达
|
||||
- ai:lesson:generate: 生成教案
|
||||
- ai:lesson:confirm: 确认教案
|
||||
- ai:report:generate: 生成学情报告
|
||||
|
||||
全并行模式:dev_mode=true 时跳过权限校验,仅记录警告。
|
||||
"""
|
||||
@@ -27,6 +28,7 @@ PERMISSION_AI_QUESTION_GENERATE = "ai:question:generate"
|
||||
PERMISSION_AI_EXPRESSION_OPTIMIZE = "ai:expression:optimize"
|
||||
PERMISSION_AI_LESSON_GENERATE = "ai:lesson:generate"
|
||||
PERMISSION_AI_LESSON_CONFIRM = "ai:lesson:confirm"
|
||||
PERMISSION_AI_REPORT_GENERATE = "ai:report:generate"
|
||||
|
||||
# 角色 → 权限映射(简化版,P6 迁移到 iam 动态权限)
|
||||
ROLE_PERMISSIONS: dict[str, set[str]] = {
|
||||
@@ -36,6 +38,7 @@ ROLE_PERMISSIONS: dict[str, set[str]] = {
|
||||
PERMISSION_AI_EXPRESSION_OPTIMIZE,
|
||||
PERMISSION_AI_LESSON_GENERATE,
|
||||
PERMISSION_AI_LESSON_CONFIRM,
|
||||
PERMISSION_AI_REPORT_GENERATE,
|
||||
},
|
||||
"admin": {
|
||||
PERMISSION_AI_CHAT,
|
||||
@@ -43,6 +46,7 @@ ROLE_PERMISSIONS: dict[str, set[str]] = {
|
||||
PERMISSION_AI_EXPRESSION_OPTIMIZE,
|
||||
PERMISSION_AI_LESSON_GENERATE,
|
||||
PERMISSION_AI_LESSON_CONFIRM,
|
||||
PERMISSION_AI_REPORT_GENERATE,
|
||||
},
|
||||
"student": {
|
||||
PERMISSION_AI_CHAT,
|
||||
|
||||
@@ -14,6 +14,12 @@ from .question import (
|
||||
GenerateQuestionRequest,
|
||||
QuestionType,
|
||||
)
|
||||
from .report import (
|
||||
GeneratedReportData,
|
||||
GenerateReportRequest,
|
||||
GenerateReportResponse,
|
||||
ReportType,
|
||||
)
|
||||
from .workflow import (
|
||||
ConfirmRequest,
|
||||
ConfirmResultData,
|
||||
@@ -39,6 +45,10 @@ __all__ = [
|
||||
"GeneratedQuestionData",
|
||||
"GeneratedQuestionResponse",
|
||||
"QuestionType",
|
||||
"GenerateReportRequest",
|
||||
"GenerateReportResponse",
|
||||
"GeneratedReportData",
|
||||
"ReportType",
|
||||
"OptimizeExpressionRequest",
|
||||
"OptimizedExpressionData",
|
||||
"OptimizeExpressionResponse",
|
||||
|
||||
37
services/ai/src/ai/models/report.py
Normal file
37
services/ai/src/ai/models/report.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""学情报告模型."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .action_state import ActionState
|
||||
|
||||
ReportType = Literal["class_summary", "student_detail", "exam_analysis"]
|
||||
|
||||
|
||||
class GenerateReportRequest(BaseModel):
|
||||
"""学情报告生成请求."""
|
||||
|
||||
class_id: str = Field(..., description="班级 ID")
|
||||
report_type: ReportType = Field(
|
||||
"class_summary",
|
||||
description="报告类型:class_summary / student_detail / exam_analysis",
|
||||
)
|
||||
student_id: str | None = Field(None, description="学生 ID(student_detail 必填)")
|
||||
user_id: str | None = None
|
||||
data_scope: str | None = None
|
||||
|
||||
|
||||
class GeneratedReportData(BaseModel):
|
||||
"""学情报告数据(含降级标记)."""
|
||||
|
||||
id: str
|
||||
content: str
|
||||
summary: str
|
||||
recommendations: list[str] = Field(default_factory=list)
|
||||
degraded: bool = False
|
||||
degraded_reason: str = ""
|
||||
|
||||
|
||||
class GenerateReportResponse(ActionState[GeneratedReportData]):
|
||||
"""学情报告响应信封."""
|
||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
import grpc
|
||||
import warnings
|
||||
|
||||
from . import ai_pb2 as ai__pb2
|
||||
import ai_pb2 as ai__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.82.1'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
@@ -78,6 +78,11 @@ class AiServiceStub:
|
||||
request_serializer=ai__pb2.ConfirmLessonPlanRequest.SerializeToString,
|
||||
response_deserializer=ai__pb2.ConfirmResult.FromString,
|
||||
_registered_method=True)
|
||||
self.GenerateReport = channel.unary_unary(
|
||||
'/next_edu_cloud.ai.v1.AiService/GenerateReport',
|
||||
request_serializer=ai__pb2.GenerateReportRequest.SerializeToString,
|
||||
response_deserializer=ai__pb2.GeneratedReport.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class AiServiceServicer:
|
||||
@@ -143,6 +148,13 @@ class AiServiceServicer:
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GenerateReport(self, request, context):
|
||||
"""生成学情报告(班级总结 / 学生详情 / 考试分析)
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_AiServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
@@ -186,6 +198,11 @@ def add_AiServiceServicer_to_server(servicer, server):
|
||||
request_deserializer=ai__pb2.ConfirmLessonPlanRequest.FromString,
|
||||
response_serializer=ai__pb2.ConfirmResult.SerializeToString,
|
||||
),
|
||||
'GenerateReport': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GenerateReport,
|
||||
request_deserializer=ai__pb2.GenerateReportRequest.FromString,
|
||||
response_serializer=ai__pb2.GeneratedReport.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'next_edu_cloud.ai.v1.AiService', rpc_method_handlers)
|
||||
@@ -416,3 +433,30 @@ class AiService:
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GenerateReport(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.ai.v1.AiService/GenerateReport',
|
||||
ai__pb2.GenerateReportRequest.SerializeToString,
|
||||
ai__pb2.GeneratedReport.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -532,6 +532,11 @@ class KnowledgeGraphServiceStub:
|
||||
request_serializer=content__pb2.GetLearningPathRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.LearningPath.FromString,
|
||||
_registered_method=True)
|
||||
self.GetKnowledgePath = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.KnowledgeGraphService/GetKnowledgePath',
|
||||
request_serializer=content__pb2.GetKnowledgePathRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.LearningPath.FromString,
|
||||
_registered_method=True)
|
||||
self.AddPrerequisite = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.KnowledgeGraphService/AddPrerequisite',
|
||||
request_serializer=content__pb2.AddPrerequisiteRequest.SerializeToString,
|
||||
@@ -559,6 +564,12 @@ class KnowledgeGraphServiceServicer:
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetKnowledgePath(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def AddPrerequisite(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
@@ -584,6 +595,11 @@ def add_KnowledgeGraphServiceServicer_to_server(servicer, server):
|
||||
request_deserializer=content__pb2.GetLearningPathRequest.FromString,
|
||||
response_serializer=content__pb2.LearningPath.SerializeToString,
|
||||
),
|
||||
'GetKnowledgePath': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetKnowledgePath,
|
||||
request_deserializer=content__pb2.GetKnowledgePathRequest.FromString,
|
||||
response_serializer=content__pb2.LearningPath.SerializeToString,
|
||||
),
|
||||
'AddPrerequisite': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.AddPrerequisite,
|
||||
request_deserializer=content__pb2.AddPrerequisiteRequest.FromString,
|
||||
@@ -659,6 +675,33 @@ class KnowledgeGraphService:
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetKnowledgePath(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.KnowledgeGraphService/GetKnowledgePath',
|
||||
content__pb2.GetKnowledgePathRequest.SerializeToString,
|
||||
content__pb2.LearningPath.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def AddPrerequisite(request,
|
||||
target,
|
||||
@@ -1085,3 +1128,477 @@ class QuestionService:
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class ElectiveServiceStub:
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.ListAvailableElectiveCourses = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.ElectiveService/ListAvailableElectiveCourses',
|
||||
request_serializer=content__pb2.ListAvailableElectiveCoursesRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.ListElectiveCoursesResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.ListElectiveSelectionsByStudent = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.ElectiveService/ListElectiveSelectionsByStudent',
|
||||
request_serializer=content__pb2.ListElectiveSelectionsByStudentRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.ListElectiveSelectionsResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.SelectCourse = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.ElectiveService/SelectCourse',
|
||||
request_serializer=content__pb2.SelectCourseRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.ElectiveSelection.FromString,
|
||||
_registered_method=True)
|
||||
self.DropCourse = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.ElectiveService/DropCourse',
|
||||
request_serializer=content__pb2.DropCourseRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.Empty.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class ElectiveServiceServicer:
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def ListAvailableElectiveCourses(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def ListElectiveSelectionsByStudent(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def SelectCourse(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def DropCourse(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_ElectiveServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'ListAvailableElectiveCourses': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ListAvailableElectiveCourses,
|
||||
request_deserializer=content__pb2.ListAvailableElectiveCoursesRequest.FromString,
|
||||
response_serializer=content__pb2.ListElectiveCoursesResponse.SerializeToString,
|
||||
),
|
||||
'ListElectiveSelectionsByStudent': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ListElectiveSelectionsByStudent,
|
||||
request_deserializer=content__pb2.ListElectiveSelectionsByStudentRequest.FromString,
|
||||
response_serializer=content__pb2.ListElectiveSelectionsResponse.SerializeToString,
|
||||
),
|
||||
'SelectCourse': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SelectCourse,
|
||||
request_deserializer=content__pb2.SelectCourseRequest.FromString,
|
||||
response_serializer=content__pb2.ElectiveSelection.SerializeToString,
|
||||
),
|
||||
'DropCourse': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.DropCourse,
|
||||
request_deserializer=content__pb2.DropCourseRequest.FromString,
|
||||
response_serializer=content__pb2.Empty.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'next_edu_cloud.content.v1.ElectiveService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
server.add_registered_method_handlers('next_edu_cloud.content.v1.ElectiveService', rpc_method_handlers)
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class ElectiveService:
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def ListAvailableElectiveCourses(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.ElectiveService/ListAvailableElectiveCourses',
|
||||
content__pb2.ListAvailableElectiveCoursesRequest.SerializeToString,
|
||||
content__pb2.ListElectiveCoursesResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def ListElectiveSelectionsByStudent(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.ElectiveService/ListElectiveSelectionsByStudent',
|
||||
content__pb2.ListElectiveSelectionsByStudentRequest.SerializeToString,
|
||||
content__pb2.ListElectiveSelectionsResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def SelectCourse(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.ElectiveService/SelectCourse',
|
||||
content__pb2.SelectCourseRequest.SerializeToString,
|
||||
content__pb2.ElectiveSelection.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def DropCourse(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.ElectiveService/DropCourse',
|
||||
content__pb2.DropCourseRequest.SerializeToString,
|
||||
content__pb2.Empty.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class LessonPlanServiceStub:
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.ListLessonPlansByTeacher = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.LessonPlanService/ListLessonPlansByTeacher',
|
||||
request_serializer=content__pb2.ListLessonPlansByTeacherRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.ListLessonPlansResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.ListLessonPlansByStudent = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.LessonPlanService/ListLessonPlansByStudent',
|
||||
request_serializer=content__pb2.ListLessonPlansByStudentRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.ListLessonPlansResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetLessonPlan = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.LessonPlanService/GetLessonPlan',
|
||||
request_serializer=content__pb2.GetLessonPlanRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.LessonPlan.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class LessonPlanServiceServicer:
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def ListLessonPlansByTeacher(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def ListLessonPlansByStudent(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetLessonPlan(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_LessonPlanServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'ListLessonPlansByTeacher': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ListLessonPlansByTeacher,
|
||||
request_deserializer=content__pb2.ListLessonPlansByTeacherRequest.FromString,
|
||||
response_serializer=content__pb2.ListLessonPlansResponse.SerializeToString,
|
||||
),
|
||||
'ListLessonPlansByStudent': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ListLessonPlansByStudent,
|
||||
request_deserializer=content__pb2.ListLessonPlansByStudentRequest.FromString,
|
||||
response_serializer=content__pb2.ListLessonPlansResponse.SerializeToString,
|
||||
),
|
||||
'GetLessonPlan': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetLessonPlan,
|
||||
request_deserializer=content__pb2.GetLessonPlanRequest.FromString,
|
||||
response_serializer=content__pb2.LessonPlan.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'next_edu_cloud.content.v1.LessonPlanService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
server.add_registered_method_handlers('next_edu_cloud.content.v1.LessonPlanService', rpc_method_handlers)
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class LessonPlanService:
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def ListLessonPlansByTeacher(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.LessonPlanService/ListLessonPlansByTeacher',
|
||||
content__pb2.ListLessonPlansByTeacherRequest.SerializeToString,
|
||||
content__pb2.ListLessonPlansResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def ListLessonPlansByStudent(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.LessonPlanService/ListLessonPlansByStudent',
|
||||
content__pb2.ListLessonPlansByStudentRequest.SerializeToString,
|
||||
content__pb2.ListLessonPlansResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetLessonPlan(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.LessonPlanService/GetLessonPlan',
|
||||
content__pb2.GetLessonPlanRequest.SerializeToString,
|
||||
content__pb2.LessonPlan.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class CoursePlanServiceStub:
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.ListCoursePlansByStudent = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.CoursePlanService/ListCoursePlansByStudent',
|
||||
request_serializer=content__pb2.ListCoursePlansByStudentRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.ListCoursePlansResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetCoursePlan = channel.unary_unary(
|
||||
'/next_edu_cloud.content.v1.CoursePlanService/GetCoursePlan',
|
||||
request_serializer=content__pb2.GetCoursePlanRequest.SerializeToString,
|
||||
response_deserializer=content__pb2.CoursePlan.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class CoursePlanServiceServicer:
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def ListCoursePlansByStudent(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetCoursePlan(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_CoursePlanServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'ListCoursePlansByStudent': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ListCoursePlansByStudent,
|
||||
request_deserializer=content__pb2.ListCoursePlansByStudentRequest.FromString,
|
||||
response_serializer=content__pb2.ListCoursePlansResponse.SerializeToString,
|
||||
),
|
||||
'GetCoursePlan': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetCoursePlan,
|
||||
request_deserializer=content__pb2.GetCoursePlanRequest.FromString,
|
||||
response_serializer=content__pb2.CoursePlan.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'next_edu_cloud.content.v1.CoursePlanService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
server.add_registered_method_handlers('next_edu_cloud.content.v1.CoursePlanService', rpc_method_handlers)
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class CoursePlanService:
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def ListCoursePlansByStudent(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.CoursePlanService/ListCoursePlansByStudent',
|
||||
content__pb2.ListCoursePlansByStudentRequest.SerializeToString,
|
||||
content__pb2.ListCoursePlansResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetCoursePlan(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.content.v1.CoursePlanService/GetCoursePlan',
|
||||
content__pb2.GetCoursePlanRequest.SerializeToString,
|
||||
content__pb2.CoursePlan.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -63,26 +63,16 @@ class IamServiceStub:
|
||||
request_serializer=iam__pb2.GetUserInfoRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.UserInfo.FromString,
|
||||
_registered_method=True)
|
||||
self.GetUserProfile = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetUserProfile',
|
||||
request_serializer=iam__pb2.GetUserProfileRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.UserInfo.FromString,
|
||||
_registered_method=True)
|
||||
self.UpdateProfile = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/UpdateProfile',
|
||||
request_serializer=iam__pb2.UpdateProfileRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.UserInfo.FromString,
|
||||
_registered_method=True)
|
||||
self.ChangePassword = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/ChangePassword',
|
||||
request_serializer=iam__pb2.ChangePasswordRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.ChangePasswordResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.BatchGetUsers = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/BatchGetUsers',
|
||||
request_serializer=iam__pb2.BatchGetUsersRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.BatchGetUsersResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetEffectiveDataScope = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetEffectiveDataScope',
|
||||
request_serializer=iam__pb2.GetEffectiveDataScopeRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.EffectiveDataScope.FromString,
|
||||
_registered_method=True)
|
||||
self.GetEffectivePermissions = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetEffectivePermissions',
|
||||
request_serializer=iam__pb2.GetEffectivePermissionsRequest.SerializeToString,
|
||||
@@ -93,11 +83,6 @@ class IamServiceStub:
|
||||
request_serializer=iam__pb2.GetEffectiveAccessRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.EffectiveAccessResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetEffectiveDataScope = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetEffectiveDataScope',
|
||||
request_serializer=iam__pb2.GetEffectiveDataScopeRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.EffectiveDataScope.FromString,
|
||||
_registered_method=True)
|
||||
self.GetViewports = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetViewports',
|
||||
request_serializer=iam__pb2.GetViewportsRequest.SerializeToString,
|
||||
@@ -113,6 +98,21 @@ class IamServiceStub:
|
||||
request_serializer=iam__pb2.GetChildrenByParentRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.ChildrenResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.CreateUser = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/CreateUser',
|
||||
request_serializer=iam__pb2.CreateUserRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.UserInfo.FromString,
|
||||
_registered_method=True)
|
||||
self.UpdateUser = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/UpdateUser',
|
||||
request_serializer=iam__pb2.UpdateUserRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.UserInfo.FromString,
|
||||
_registered_method=True)
|
||||
self.DeleteUser = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/DeleteUser',
|
||||
request_serializer=iam__pb2.DeleteUserRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.DeleteUserResponse.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class IamServiceServicer:
|
||||
@@ -154,32 +154,20 @@ class IamServiceServicer:
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetUserProfile(self, request, context):
|
||||
"""GetUserProfile 是 GetUserInfo 的语义别名(student-bff 期望的命名).
|
||||
返回结构与 GetUserInfo 完全相同,仅 RPC 名不同以兼容下游契约.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def UpdateProfile(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def ChangePassword(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def BatchGetUsers(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetEffectiveDataScope(self, request, context):
|
||||
"""GetEffectiveDataScope 解析用户可见数据范围(DataScope 6 级).
|
||||
data-ana gRPC 调用此 RPC 解析查询过滤范围(coord-cross-review §2 #3 裁决 P4 补全).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetEffectivePermissions(self, request, context):
|
||||
"""权限与视口类
|
||||
"""
|
||||
@@ -193,14 +181,6 @@ class IamServiceServicer:
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetEffectiveDataScope(self, request, context):
|
||||
"""GetEffectiveDataScope 解析用户可见数据范围(DataScope 6 级).
|
||||
data-ana gRPC 调用此 RPC 解析查询过滤范围(coord-cross-review §2 #3 裁决 P4 补全).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetViewports(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
@@ -220,6 +200,25 @@ class IamServiceServicer:
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def CreateUser(self, request, context):
|
||||
"""管理员用户管理类(admin-portal §2.3 P1 阻塞项补齐)
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def UpdateUser(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def DeleteUser(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_IamServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
@@ -248,26 +247,16 @@ def add_IamServiceServicer_to_server(servicer, server):
|
||||
request_deserializer=iam__pb2.GetUserInfoRequest.FromString,
|
||||
response_serializer=iam__pb2.UserInfo.SerializeToString,
|
||||
),
|
||||
'GetUserProfile': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetUserProfile,
|
||||
request_deserializer=iam__pb2.GetUserProfileRequest.FromString,
|
||||
response_serializer=iam__pb2.UserInfo.SerializeToString,
|
||||
),
|
||||
'UpdateProfile': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.UpdateProfile,
|
||||
request_deserializer=iam__pb2.UpdateProfileRequest.FromString,
|
||||
response_serializer=iam__pb2.UserInfo.SerializeToString,
|
||||
),
|
||||
'ChangePassword': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ChangePassword,
|
||||
request_deserializer=iam__pb2.ChangePasswordRequest.FromString,
|
||||
response_serializer=iam__pb2.ChangePasswordResponse.SerializeToString,
|
||||
),
|
||||
'BatchGetUsers': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.BatchGetUsers,
|
||||
request_deserializer=iam__pb2.BatchGetUsersRequest.FromString,
|
||||
response_serializer=iam__pb2.BatchGetUsersResponse.SerializeToString,
|
||||
),
|
||||
'GetEffectiveDataScope': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetEffectiveDataScope,
|
||||
request_deserializer=iam__pb2.GetEffectiveDataScopeRequest.FromString,
|
||||
response_serializer=iam__pb2.EffectiveDataScope.SerializeToString,
|
||||
),
|
||||
'GetEffectivePermissions': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetEffectivePermissions,
|
||||
request_deserializer=iam__pb2.GetEffectivePermissionsRequest.FromString,
|
||||
@@ -278,11 +267,6 @@ def add_IamServiceServicer_to_server(servicer, server):
|
||||
request_deserializer=iam__pb2.GetEffectiveAccessRequest.FromString,
|
||||
response_serializer=iam__pb2.EffectiveAccessResponse.SerializeToString,
|
||||
),
|
||||
'GetEffectiveDataScope': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetEffectiveDataScope,
|
||||
request_deserializer=iam__pb2.GetEffectiveDataScopeRequest.FromString,
|
||||
response_serializer=iam__pb2.EffectiveDataScope.SerializeToString,
|
||||
),
|
||||
'GetViewports': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetViewports,
|
||||
request_deserializer=iam__pb2.GetViewportsRequest.FromString,
|
||||
@@ -298,6 +282,21 @@ def add_IamServiceServicer_to_server(servicer, server):
|
||||
request_deserializer=iam__pb2.GetChildrenByParentRequest.FromString,
|
||||
response_serializer=iam__pb2.ChildrenResponse.SerializeToString,
|
||||
),
|
||||
'CreateUser': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.CreateUser,
|
||||
request_deserializer=iam__pb2.CreateUserRequest.FromString,
|
||||
response_serializer=iam__pb2.UserInfo.SerializeToString,
|
||||
),
|
||||
'UpdateUser': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.UpdateUser,
|
||||
request_deserializer=iam__pb2.UpdateUserRequest.FromString,
|
||||
response_serializer=iam__pb2.UserInfo.SerializeToString,
|
||||
),
|
||||
'DeleteUser': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.DeleteUser,
|
||||
request_deserializer=iam__pb2.DeleteUserRequest.FromString,
|
||||
response_serializer=iam__pb2.DeleteUserResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'next_edu_cloud.iam.v1.IamService', rpc_method_handlers)
|
||||
@@ -448,87 +447,6 @@ class IamService:
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetUserProfile(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.iam.v1.IamService/GetUserProfile',
|
||||
iam__pb2.GetUserProfileRequest.SerializeToString,
|
||||
iam__pb2.UserInfo.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def UpdateProfile(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.iam.v1.IamService/UpdateProfile',
|
||||
iam__pb2.UpdateProfileRequest.SerializeToString,
|
||||
iam__pb2.UserInfo.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def ChangePassword(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.iam.v1.IamService/ChangePassword',
|
||||
iam__pb2.ChangePasswordRequest.SerializeToString,
|
||||
iam__pb2.ChangePasswordResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def BatchGetUsers(request,
|
||||
target,
|
||||
@@ -556,6 +474,33 @@ class IamService:
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetEffectiveDataScope(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.iam.v1.IamService/GetEffectiveDataScope',
|
||||
iam__pb2.GetEffectiveDataScopeRequest.SerializeToString,
|
||||
iam__pb2.EffectiveDataScope.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetEffectivePermissions(request,
|
||||
target,
|
||||
@@ -610,33 +555,6 @@ class IamService:
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetEffectiveDataScope(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.iam.v1.IamService/GetEffectiveDataScope',
|
||||
iam__pb2.GetEffectiveDataScopeRequest.SerializeToString,
|
||||
iam__pb2.EffectiveDataScope.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetViewports(request,
|
||||
target,
|
||||
@@ -717,3 +635,84 @@ class IamService:
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def CreateUser(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.iam.v1.IamService/CreateUser',
|
||||
iam__pb2.CreateUserRequest.SerializeToString,
|
||||
iam__pb2.UserInfo.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def UpdateUser(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.iam.v1.IamService/UpdateUser',
|
||||
iam__pb2.UpdateUserRequest.SerializeToString,
|
||||
iam__pb2.UserInfo.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def DeleteUser(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.iam.v1.IamService/DeleteUser',
|
||||
iam__pb2.DeleteUserRequest.SerializeToString,
|
||||
iam__pb2.DeleteUserResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
- ChatService: 聊天(非流式 + 流式)
|
||||
- QuestionService: 题目生成(非流式 + 流式 + 评估三道防线)
|
||||
- ExpressionService: 表达优化
|
||||
- ReportService: 学情报告生成(class_summary / student_detail / exam_analysis)
|
||||
- LessonPlanWorkflowService: 备课工作流(M16-2 实现)
|
||||
"""
|
||||
|
||||
from .chat_service import ChatService
|
||||
from .expression_service import ExpressionService
|
||||
from .question_service import QuestionService
|
||||
from .report_service import ReportService
|
||||
|
||||
__all__ = [
|
||||
"ChatService",
|
||||
"QuestionService",
|
||||
"ExpressionService",
|
||||
"ReportService",
|
||||
]
|
||||
|
||||
219
services/ai/src/ai/services/report_service.py
Normal file
219
services/ai/src/ai/services/report_service.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""学情报告服务.
|
||||
|
||||
编排 LLM Provider FailoverChain + data-ana 学情数据 + Prompt 模板。
|
||||
支持 3 种报告类型:class_summary / student_detail / exam_analysis。
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from ..clients.data_ana_client import DataAnaClient
|
||||
from ..errors import AILLMUnavailableError
|
||||
from ..models.report import GeneratedReportData, ReportType
|
||||
from ..prompt_service import PromptTemplateService
|
||||
from ..providers import ProviderFailoverChain
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class ReportService:
|
||||
"""学情报告生成服务."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
failover_chain: ProviderFailoverChain,
|
||||
prompt_service: PromptTemplateService | None = None,
|
||||
data_ana_client: DataAnaClient | None = None,
|
||||
default_model: str = "gpt-4o-mini",
|
||||
) -> None:
|
||||
self._chain = failover_chain
|
||||
self._prompts = prompt_service
|
||||
self._data_ana = data_ana_client
|
||||
self._default_model = default_model
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
class_id: str,
|
||||
report_type: ReportType,
|
||||
student_id: str | None = None,
|
||||
user_id: str = "",
|
||||
data_scope: str = "",
|
||||
) -> GeneratedReportData:
|
||||
"""生成学情报告.
|
||||
|
||||
流程:1) 从 data-ana 拉学情 → 2) 组装 prompt → 3) LLM 生成报告。
|
||||
|
||||
Returns:
|
||||
GeneratedReportData(含降级标记)
|
||||
"""
|
||||
report_id = f"report-{int(time.time() * 1000)}"
|
||||
|
||||
# 1. 收集学情上下文(data-ana 不可用时降级为空上下文)
|
||||
context = await self._collect_context(class_id, report_type, student_id)
|
||||
|
||||
# 2. 组装 prompt
|
||||
prompt = self._build_prompt(report_type, context)
|
||||
|
||||
# 3. 调用 LLM 生成报告
|
||||
try:
|
||||
response = await self._chain.chat(
|
||||
messages=[
|
||||
{"role": "system", "content": self._system_prompt(report_type)},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
model=self._default_model,
|
||||
temperature=0.3,
|
||||
)
|
||||
content = response.content
|
||||
summary = self._extract_summary(content)
|
||||
recommendations = self._extract_recommendations(content)
|
||||
return GeneratedReportData(
|
||||
id=report_id,
|
||||
content=content,
|
||||
summary=summary,
|
||||
recommendations=recommendations,
|
||||
degraded=False,
|
||||
degraded_reason="",
|
||||
)
|
||||
except AILLMUnavailableError as exc:
|
||||
logger.warning("report_degraded_llm", reason=str(exc), report_type=report_type)
|
||||
return GeneratedReportData(
|
||||
id=report_id,
|
||||
content="",
|
||||
summary="",
|
||||
recommendations=[],
|
||||
degraded=True,
|
||||
degraded_reason=f"LLM unavailable: {exc}",
|
||||
)
|
||||
|
||||
async def _collect_context(
|
||||
self,
|
||||
class_id: str,
|
||||
report_type: ReportType,
|
||||
student_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""从 data-ana 收集学情上下文(不可用时返回降级上下文)."""
|
||||
context: dict[str, Any] = {
|
||||
"class_id": class_id,
|
||||
"report_type": report_type,
|
||||
"student_id": student_id or "",
|
||||
}
|
||||
if self._data_ana is None or not self._data_ana.is_available():
|
||||
context["degraded"] = True
|
||||
context["degraded_reason"] = "data-ana client not configured"
|
||||
return context
|
||||
|
||||
try:
|
||||
perf = await self._data_ana.get_class_performance(class_id, "")
|
||||
context["average_score"] = perf.average_score
|
||||
context["pass_rate"] = perf.pass_rate
|
||||
context["student_count"] = len(perf.scores)
|
||||
|
||||
if report_type == "student_detail" and student_id:
|
||||
weakness = await self._data_ana.get_student_weakness(student_id, "")
|
||||
trend = await self._data_ana.get_learning_trend(student_id)
|
||||
context["weak_points"] = [
|
||||
{"title": wp.title, "mastery": wp.mastery} for wp in weakness.weak_points
|
||||
]
|
||||
context["trend_points"] = [
|
||||
{"date": tp.date, "score": tp.score} for tp in trend.points
|
||||
]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("report_context_degraded", error=str(exc))
|
||||
context["degraded"] = True
|
||||
context["degraded_reason"] = f"data-ana call failed: {exc}"
|
||||
|
||||
return context
|
||||
|
||||
def _system_prompt(self, report_type: ReportType) -> str:
|
||||
"""系统 prompt(定义 LLM 角色)."""
|
||||
if self._prompts is not None:
|
||||
try:
|
||||
return self._prompts.render(
|
||||
"report_system",
|
||||
{"report_type": report_type},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return (
|
||||
"你是一名专业的教育分析师,负责根据学情数据生成结构化报告。"
|
||||
"报告必须使用 Markdown 格式,包含「摘要」「详细分析」「教学建议」三部分。"
|
||||
)
|
||||
|
||||
def _build_prompt(self, report_type: ReportType, context: dict[str, Any]) -> str:
|
||||
"""构建用户 prompt."""
|
||||
if self._prompts is not None:
|
||||
try:
|
||||
return self._prompts.render(f"report_{report_type}", context)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# fallback:内联模板
|
||||
context_json = json.dumps(context, ensure_ascii=False, default=str)
|
||||
type_desc = {
|
||||
"class_summary": "班级学情总结",
|
||||
"student_detail": "学生个人学情详情",
|
||||
"exam_analysis": "考试成绩分析",
|
||||
}.get(report_type, "学情报告")
|
||||
return (
|
||||
f"请根据以下学情数据生成{type_desc}。\n\n"
|
||||
f"学情数据:\n{context_json}\n\n"
|
||||
"要求:\n"
|
||||
"1. 摘要:100 字以内概述\n"
|
||||
"2. 详细分析:基于数据的关键发现\n"
|
||||
"3. 教学建议:3-5 条可执行建议\n"
|
||||
)
|
||||
|
||||
def _extract_summary(self, content: str) -> str:
|
||||
"""从报告内容提取摘要(取第一段或前 200 字)."""
|
||||
if not content:
|
||||
return ""
|
||||
# 尝试匹配「摘要」段落
|
||||
lines = content.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if "摘要" in line and i + 1 < len(lines):
|
||||
return lines[i + 1].strip()[:200]
|
||||
# fallback:取前 200 字
|
||||
return content[:200].strip()
|
||||
|
||||
def _extract_recommendations(self, content: str) -> list[str]:
|
||||
"""从报告内容提取教学建议(匹配列表项).
|
||||
|
||||
仅在「教学建议」/「建议」章节标题(以 # 开头或独立行)下提取列表项,
|
||||
避免列表项自身包含「建议」关键词时被误判为章节标题。
|
||||
"""
|
||||
if not content:
|
||||
return []
|
||||
recommendations: list[str] = []
|
||||
in_section = False
|
||||
for line in content.split("\n"):
|
||||
stripped = line.strip()
|
||||
# 仅匹配章节标题(Markdown 标题 # 开头,或纯文本标题行不含列表标记)
|
||||
is_header = stripped.startswith("#")
|
||||
is_plain_title = not stripped.startswith(("- ", "* ", "• ", "1.", "2.", "3.")) and (
|
||||
"教学建议" in stripped or stripped == "建议"
|
||||
)
|
||||
if is_header and ("教学建议" in stripped or "建议" in stripped):
|
||||
in_section = True
|
||||
continue
|
||||
if is_plain_title:
|
||||
in_section = True
|
||||
continue
|
||||
if in_section:
|
||||
# 匹配 Markdown 列表项(- 或 1.)
|
||||
if stripped.startswith(("- ", "* ", "• ")):
|
||||
recommendations.append(stripped[2:].strip())
|
||||
elif stripped and stripped[0].isdigit() and ". " in stripped:
|
||||
recommendations.append(stripped.split(". ", 1)[1].strip())
|
||||
elif stripped.startswith("#"):
|
||||
# 进入下一个章节
|
||||
break
|
||||
elif not stripped:
|
||||
# 空行跳过
|
||||
continue
|
||||
else:
|
||||
# 非列表非标题的非空行 → 章节结束
|
||||
break
|
||||
return recommendations[:5]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""gRPC servicer 测试(AiServicer 8 RPC + interceptors 辅助函数)."""
|
||||
"""gRPC servicer 测试(AiServicer 9 RPC + interceptors 辅助函数)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -17,22 +17,25 @@ from src.ai.middleware.auth import UserContext
|
||||
from src.ai.models.chat import ChatData, Usage
|
||||
from src.ai.models.expression import OptimizedExpressionData
|
||||
from src.ai.models.question import GeneratedQuestionData
|
||||
from src.ai.models.report import GeneratedReportData
|
||||
from src.ai.proto_gen import ai_pb2
|
||||
|
||||
|
||||
class TestAiServicer:
|
||||
"""AiServicer 8 RPC 测试."""
|
||||
"""AiServicer 9 RPC 测试."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
self.chat_svc = AsyncMock()
|
||||
self.question_svc = AsyncMock()
|
||||
self.expr_svc = AsyncMock()
|
||||
self.workflow_svc = AsyncMock()
|
||||
self.report_svc = AsyncMock()
|
||||
self.servicer = AiServicer(
|
||||
chat_service=self.chat_svc,
|
||||
question_service=self.question_svc,
|
||||
expression_service=self.expr_svc,
|
||||
workflow_service=self.workflow_svc,
|
||||
report_service=self.report_svc,
|
||||
)
|
||||
self.context = MagicMock()
|
||||
self.context.user_context = UserContext(user_id="u-1", role="teacher")
|
||||
@@ -43,7 +46,9 @@ class TestAiServicer:
|
||||
|
||||
async def test_chat_success(self) -> None:
|
||||
self.chat_svc.chat.return_value = ChatData(
|
||||
content="hi", model="gpt-4o", usage=Usage(),
|
||||
content="hi",
|
||||
model="gpt-4o",
|
||||
usage=Usage(),
|
||||
)
|
||||
request = ai_pb2.ChatRequest(model="gpt-4o")
|
||||
request.messages.add(role="user", content="hello")
|
||||
@@ -136,7 +141,9 @@ class TestAiServicer:
|
||||
evaluation_score=0.9,
|
||||
)
|
||||
request = ai_pb2.GenerateQuestionRequest(
|
||||
prompt="生成加法题", subject="数学", difficulty="easy",
|
||||
prompt="生成加法题",
|
||||
subject="数学",
|
||||
difficulty="easy",
|
||||
)
|
||||
result = await self.servicer.GenerateQuestion(request, self.context)
|
||||
assert result.question == "1+1=?"
|
||||
@@ -146,7 +153,9 @@ class TestAiServicer:
|
||||
async def test_generate_question_no_service_degraded(self) -> None:
|
||||
servicer = AiServicer(question_service=None)
|
||||
request = ai_pb2.GenerateQuestionRequest(
|
||||
prompt="生成加法题", subject="数学", difficulty="easy",
|
||||
prompt="生成加法题",
|
||||
subject="数学",
|
||||
difficulty="easy",
|
||||
)
|
||||
result = await servicer.GenerateQuestion(request, self.context)
|
||||
assert result.degraded is True
|
||||
@@ -155,7 +164,9 @@ class TestAiServicer:
|
||||
async def test_generate_question_llm_unavailable_degraded(self) -> None:
|
||||
self.question_svc.generate.side_effect = AILLMUnavailableError("llm down")
|
||||
request = ai_pb2.GenerateQuestionRequest(
|
||||
prompt="生成加法题", subject="数学", difficulty="easy",
|
||||
prompt="生成加法题",
|
||||
subject="数学",
|
||||
difficulty="easy",
|
||||
)
|
||||
result = await self.servicer.GenerateQuestion(request, self.context)
|
||||
assert result.degraded is True
|
||||
@@ -167,7 +178,9 @@ class TestAiServicer:
|
||||
|
||||
async def test_stream_generate_question_success(self) -> None:
|
||||
complete = ai_pb2.GeneratedQuestion(
|
||||
question="q", answer="a", question_type="short_answer",
|
||||
question="q",
|
||||
answer="a",
|
||||
question_type="short_answer",
|
||||
)
|
||||
|
||||
async def mock_stream_gen(request: object) -> None:
|
||||
@@ -176,7 +189,9 @@ class TestAiServicer:
|
||||
|
||||
self.question_svc.stream_generate = mock_stream_gen
|
||||
request = ai_pb2.GenerateQuestionRequest(
|
||||
prompt="生成加法题", subject="数学", difficulty="easy",
|
||||
prompt="生成加法题",
|
||||
subject="数学",
|
||||
difficulty="easy",
|
||||
)
|
||||
chunks = []
|
||||
async for chunk in self.servicer.StreamGenerateQuestion(request, self.context):
|
||||
@@ -190,7 +205,9 @@ class TestAiServicer:
|
||||
async def test_stream_generate_question_no_service_degraded(self) -> None:
|
||||
servicer = AiServicer(question_service=None)
|
||||
request = ai_pb2.GenerateQuestionRequest(
|
||||
prompt="生成加法题", subject="数学", difficulty="easy",
|
||||
prompt="生成加法题",
|
||||
subject="数学",
|
||||
difficulty="easy",
|
||||
)
|
||||
chunks = []
|
||||
async for chunk in servicer.StreamGenerateQuestion(request, self.context):
|
||||
@@ -205,7 +222,8 @@ class TestAiServicer:
|
||||
|
||||
async def test_optimize_expression_success(self) -> None:
|
||||
self.expr_svc.optimize.return_value = OptimizedExpressionData(
|
||||
optimized="优化后", suggestions=["建议1"],
|
||||
optimized="优化后",
|
||||
suggestions=["建议1"],
|
||||
)
|
||||
request = ai_pb2.OptimizeExpressionRequest(text="原始", context="")
|
||||
result = await self.servicer.OptimizeExpression(request, self.context)
|
||||
@@ -240,8 +258,11 @@ class TestAiServicer:
|
||||
degraded_reason="",
|
||||
)
|
||||
request = ai_pb2.GenerateLessonPlanRequest(
|
||||
class_id="c-1", subject_id="math", topic="函数",
|
||||
target_difficulty="medium", question_count=3,
|
||||
class_id="c-1",
|
||||
subject_id="math",
|
||||
topic="函数",
|
||||
target_difficulty="medium",
|
||||
question_count=3,
|
||||
)
|
||||
result = await self.servicer.GenerateLessonPlan(request, self.context)
|
||||
assert result.workflow_id == "wf-1"
|
||||
@@ -252,7 +273,9 @@ class TestAiServicer:
|
||||
async def test_generate_lesson_plan_no_service_degraded(self) -> None:
|
||||
servicer = AiServicer(workflow_service=None)
|
||||
request = ai_pb2.GenerateLessonPlanRequest(
|
||||
class_id="c-1", subject_id="math", topic="函数",
|
||||
class_id="c-1",
|
||||
subject_id="math",
|
||||
topic="函数",
|
||||
)
|
||||
result = await servicer.GenerateLessonPlan(request, self.context)
|
||||
assert result.degraded is True
|
||||
@@ -265,9 +288,13 @@ class TestAiServicer:
|
||||
|
||||
async def test_get_lesson_plan_status_success(self) -> None:
|
||||
question = GeneratedQuestionData(
|
||||
question="q1", answer="a1", explanation="e1",
|
||||
question_type="short_answer", difficulty="easy",
|
||||
knowledge_point_ids=["kp_1"], evaluation_score=0.8,
|
||||
question="q1",
|
||||
answer="a1",
|
||||
explanation="e1",
|
||||
question_type="short_answer",
|
||||
difficulty="easy",
|
||||
knowledge_point_ids=["kp_1"],
|
||||
evaluation_score=0.8,
|
||||
)
|
||||
self.workflow_svc.get_status.return_value = SimpleNamespace(
|
||||
workflow_id="wf-1",
|
||||
@@ -315,6 +342,98 @@ class TestAiServicer:
|
||||
assert result.success is False
|
||||
assert "workflow_service not initialized" in result.error
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# GenerateReport
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def test_generate_report_success(self) -> None:
|
||||
"""生成学情报告成功."""
|
||||
self.report_svc.generate.return_value = GeneratedReportData(
|
||||
id="report-1",
|
||||
content="# 班级学情报告\n\n摘要\n班级整体表现良好。",
|
||||
summary="班级整体表现良好。",
|
||||
recommendations=["加强函数概念教学", "增加练习题量"],
|
||||
degraded=False,
|
||||
degraded_reason="",
|
||||
)
|
||||
request = ai_pb2.GenerateReportRequest(
|
||||
class_id="c-1",
|
||||
report_type="class_summary",
|
||||
user_id="u-1",
|
||||
)
|
||||
result = await self.servicer.GenerateReport(request, self.context)
|
||||
assert result.id == "report-1"
|
||||
assert "班级学情报告" in result.content
|
||||
assert result.summary == "班级整体表现良好。"
|
||||
assert list(result.recommendations) == ["加强函数概念教学", "增加练习题量"]
|
||||
assert result.degraded is False
|
||||
|
||||
async def test_generate_report_student_detail_with_student_id(self) -> None:
|
||||
"""学生详情报告(带 student_id)."""
|
||||
self.report_svc.generate.return_value = GeneratedReportData(
|
||||
id="report-2",
|
||||
content="学生个人报告",
|
||||
summary="学生薄弱点分析",
|
||||
recommendations=["针对性练习"],
|
||||
degraded=False,
|
||||
degraded_reason="",
|
||||
)
|
||||
request = ai_pb2.GenerateReportRequest(
|
||||
class_id="c-1",
|
||||
report_type="student_detail",
|
||||
student_id="s-001",
|
||||
user_id="u-1",
|
||||
)
|
||||
result = await self.servicer.GenerateReport(request, self.context)
|
||||
# 验证 service 被调用时 student_id 正确传递
|
||||
self.report_svc.generate.assert_awaited_once()
|
||||
call_kwargs = self.report_svc.generate.call_args.kwargs
|
||||
assert call_kwargs["student_id"] == "s-001"
|
||||
assert call_kwargs["report_type"] == "student_detail"
|
||||
assert result.id == "report-2"
|
||||
|
||||
async def test_generate_report_no_service_degraded(self) -> None:
|
||||
"""report_service 未初始化时返回降级响应."""
|
||||
servicer = AiServicer(report_service=None)
|
||||
request = ai_pb2.GenerateReportRequest(
|
||||
class_id="c-1",
|
||||
report_type="class_summary",
|
||||
)
|
||||
result = await servicer.GenerateReport(request, self.context)
|
||||
assert result.degraded is True
|
||||
assert "report_service not initialized" in result.degraded_reason
|
||||
assert result.id == ""
|
||||
assert result.content == ""
|
||||
|
||||
async def test_generate_report_llm_unavailable_degraded(self) -> None:
|
||||
"""LLM 不可用时 ReportService 返回降级数据."""
|
||||
self.report_svc.generate.return_value = GeneratedReportData(
|
||||
id="report-3",
|
||||
content="",
|
||||
summary="",
|
||||
recommendations=[],
|
||||
degraded=True,
|
||||
degraded_reason="LLM unavailable: all providers failed",
|
||||
)
|
||||
request = ai_pb2.GenerateReportRequest(
|
||||
class_id="c-1",
|
||||
report_type="exam_analysis",
|
||||
)
|
||||
result = await self.servicer.GenerateReport(request, self.context)
|
||||
assert result.degraded is True
|
||||
assert "LLM unavailable" in result.degraded_reason
|
||||
|
||||
async def test_generate_report_internal_error_raises(self) -> None:
|
||||
"""未知异常转 AI_INTERNAL_ERROR."""
|
||||
self.report_svc.generate.side_effect = RuntimeError("boom")
|
||||
request = ai_pb2.GenerateReportRequest(
|
||||
class_id="c-1",
|
||||
report_type="class_summary",
|
||||
)
|
||||
with pytest.raises(AIError) as exc_info:
|
||||
await self.servicer.GenerateReport(request, self.context)
|
||||
assert exc_info.value.code == ErrorCode.AI_INTERNAL_ERROR
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# degraded response helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -328,6 +328,75 @@ async def test_optimize_expression(client: httpx.AsyncClient) -> None:
|
||||
assert body["data"]["degraded"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate report endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_generate_report_class_summary(client: httpx.AsyncClient) -> None:
|
||||
"""POST /v1/ai/generate/report with class_summary returns degraded report (no LLM key)."""
|
||||
resp = await client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={"class_id": "c-1", "report_type": "class_summary"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
# 测试环境无 LLM API key → degraded
|
||||
assert body["data"]["degraded"] is True
|
||||
assert "id" in body["data"]
|
||||
|
||||
|
||||
async def test_generate_report_student_detail(client: httpx.AsyncClient) -> None:
|
||||
"""POST /v1/ai/generate/report with student_detail + student_id."""
|
||||
resp = await client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={
|
||||
"class_id": "c-1",
|
||||
"report_type": "student_detail",
|
||||
"student_id": "s-001",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"]["degraded"] is True
|
||||
|
||||
|
||||
async def test_generate_report_invalid_type(client: httpx.AsyncClient) -> None:
|
||||
"""POST /v1/ai/generate/report with invalid report_type returns 422."""
|
||||
resp = await client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={"class_id": "c-1", "report_type": "invalid_type"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_generate_report_missing_class_id(client: httpx.AsyncClient) -> None:
|
||||
"""POST /v1/ai/generate/report without class_id returns 422."""
|
||||
resp = await client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={"report_type": "class_summary"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_generate_report_permission_denied(prod_client: httpx.AsyncClient) -> None:
|
||||
"""Student role attempting to generate report returns 403."""
|
||||
resp = await prod_client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={"class_id": "c-1", "report_type": "class_summary"},
|
||||
headers={
|
||||
"X-User-Id": "student-1",
|
||||
"X-User-Role": "student",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["error"]["code"] == "AI_FORBIDDEN"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lesson plan endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,10 @@ from pydantic import ValidationError
|
||||
from src.ai.models.chat import ChatData, ChatMessage, ChatRequest, Usage
|
||||
from src.ai.models.expression import OptimizeExpressionRequest
|
||||
from src.ai.models.question import GeneratedQuestionData, GenerateQuestionRequest
|
||||
from src.ai.models.report import (
|
||||
GeneratedReportData,
|
||||
GenerateReportRequest,
|
||||
)
|
||||
from src.ai.models.workflow import (
|
||||
ConfirmRequest,
|
||||
LessonPreparationRequest,
|
||||
@@ -52,8 +56,11 @@ class TestChatModels:
|
||||
|
||||
def test_chat_data_degraded_fields(self) -> None:
|
||||
data = ChatData(
|
||||
content="x", model="m", usage=Usage(),
|
||||
degraded=True, degraded_reason="test",
|
||||
content="x",
|
||||
model="m",
|
||||
usage=Usage(),
|
||||
degraded=True,
|
||||
degraded_reason="test",
|
||||
)
|
||||
assert data.degraded is True
|
||||
|
||||
@@ -131,3 +138,37 @@ class TestWorkflowModels:
|
||||
assert data.questions == []
|
||||
assert data.error is None
|
||||
assert data.degraded is False
|
||||
|
||||
|
||||
class TestReportModels:
|
||||
"""学情报告模型测试."""
|
||||
|
||||
def test_generate_report_request_defaults(self) -> None:
|
||||
req = GenerateReportRequest(class_id="c-1")
|
||||
assert req.report_type == "class_summary"
|
||||
assert req.student_id is None
|
||||
assert req.user_id is None
|
||||
assert req.data_scope is None
|
||||
|
||||
def test_generate_report_request_invalid_type(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
GenerateReportRequest(class_id="c-1", report_type="invalid")
|
||||
|
||||
def test_generate_report_request_missing_class_id(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
GenerateReportRequest()
|
||||
|
||||
def test_generate_report_request_student_detail(self) -> None:
|
||||
req = GenerateReportRequest(
|
||||
class_id="c-1",
|
||||
report_type="student_detail",
|
||||
student_id="s-001",
|
||||
)
|
||||
assert req.report_type == "student_detail"
|
||||
assert req.student_id == "s-001"
|
||||
|
||||
def test_generated_report_data_defaults(self) -> None:
|
||||
data = GeneratedReportData(id="r-1", content="内容", summary="摘要")
|
||||
assert data.recommendations == []
|
||||
assert data.degraded is False
|
||||
assert data.degraded_reason == ""
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.ai.middleware.permission import (
|
||||
PERMISSION_AI_LESSON_CONFIRM,
|
||||
PERMISSION_AI_LESSON_GENERATE,
|
||||
PERMISSION_AI_QUESTION_GENERATE,
|
||||
PERMISSION_AI_REPORT_GENERATE,
|
||||
PermissionGuard,
|
||||
)
|
||||
|
||||
@@ -39,6 +40,7 @@ class TestPermissionGuard:
|
||||
guard.check(ctx, PERMISSION_AI_QUESTION_GENERATE)
|
||||
guard.check(ctx, PERMISSION_AI_LESSON_GENERATE)
|
||||
guard.check(ctx, PERMISSION_AI_LESSON_CONFIRM)
|
||||
guard.check(ctx, PERMISSION_AI_REPORT_GENERATE)
|
||||
|
||||
def test_student_only_chat(self) -> None:
|
||||
"""student 角色仅有 chat 权限."""
|
||||
@@ -49,6 +51,14 @@ class TestPermissionGuard:
|
||||
guard.check(ctx, PERMISSION_AI_QUESTION_GENERATE)
|
||||
assert exc_info.value.code == ErrorCode.AI_FORBIDDEN
|
||||
|
||||
def test_student_denied_report(self) -> None:
|
||||
"""student 角色无权生成学情报告."""
|
||||
guard = PermissionGuard(dev_mode=False)
|
||||
ctx = UserContext(user_id="u-1", role="student")
|
||||
with pytest.raises(AIError) as exc_info:
|
||||
guard.check(ctx, PERMISSION_AI_REPORT_GENERATE)
|
||||
assert exc_info.value.code == ErrorCode.AI_FORBIDDEN
|
||||
|
||||
def test_unknown_role_defaults_student(self) -> None:
|
||||
"""未知角色降级为 student 权限."""
|
||||
guard = PermissionGuard(dev_mode=False)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""服务层测试(ChatService / QuestionService / ExpressionService)."""
|
||||
"""服务层测试(ChatService / QuestionService / ExpressionService / ReportService)."""
|
||||
|
||||
import json
|
||||
|
||||
from src.ai.clients.data_ana_client import DataAnaClientMock
|
||||
from src.ai.models.question import GenerateQuestionRequest
|
||||
from src.ai.providers import ProviderFailoverChain
|
||||
from src.ai.providers.circuit_breaker import CircuitBreaker
|
||||
@@ -9,6 +10,7 @@ from src.ai.services.chat_service import ChatService
|
||||
from src.ai.services.evaluation import QualityGate, RuleValidator
|
||||
from src.ai.services.expression_service import ExpressionService
|
||||
from src.ai.services.question_service import QuestionService
|
||||
from src.ai.services.report_service import ReportService
|
||||
|
||||
from .conftest import MockProvider
|
||||
|
||||
@@ -70,13 +72,15 @@ class TestQuestionService:
|
||||
|
||||
async def test_generate_success(self) -> None:
|
||||
"""生成题目成功."""
|
||||
output = json.dumps({
|
||||
"question": "1+1等于几?",
|
||||
"answer": "2",
|
||||
"explanation": "基础加法",
|
||||
"difficulty": "easy",
|
||||
"question_type": "short_answer",
|
||||
})
|
||||
output = json.dumps(
|
||||
{
|
||||
"question": "1+1等于几?",
|
||||
"answer": "2",
|
||||
"explanation": "基础加法",
|
||||
"difficulty": "easy",
|
||||
"question_type": "short_answer",
|
||||
}
|
||||
)
|
||||
chain = _make_chain(MockProvider(response_content=output))
|
||||
gate = QualityGate(rule_validator=RuleValidator())
|
||||
svc = QuestionService(
|
||||
@@ -150,10 +154,12 @@ class TestExpressionService:
|
||||
|
||||
async def test_optimize_success(self) -> None:
|
||||
"""优化成功."""
|
||||
output = json.dumps({
|
||||
"optimized": "优化后的文字",
|
||||
"suggestions": ["建议1"],
|
||||
})
|
||||
output = json.dumps(
|
||||
{
|
||||
"optimized": "优化后的文字",
|
||||
"suggestions": ["建议1"],
|
||||
}
|
||||
)
|
||||
chain = _make_chain(MockProvider(response_content=output))
|
||||
svc = ExpressionService(failover_chain=chain)
|
||||
data = await svc.optimize(text="原始文字")
|
||||
@@ -192,3 +198,133 @@ class TestExpressionService:
|
||||
prompt = svc._fallback_prompt("文字", "上下文")
|
||||
assert "文字" in prompt
|
||||
assert "上下文" in prompt
|
||||
|
||||
|
||||
class TestReportService:
|
||||
"""ReportService 测试."""
|
||||
|
||||
async def test_generate_class_summary_success(self) -> None:
|
||||
"""班级学情总结报告生成成功."""
|
||||
chain = _make_chain(
|
||||
MockProvider(
|
||||
response_content=(
|
||||
"# 班级学情报告\n\n"
|
||||
"## 摘要\n班级平均分 78.5,及格率 85%。\n\n"
|
||||
"## 详细分析\n整体表现良好。\n\n"
|
||||
"## 教学建议\n- 加强函数概念\n- 增加练习题\n"
|
||||
),
|
||||
)
|
||||
)
|
||||
svc = ReportService(
|
||||
failover_chain=chain,
|
||||
data_ana_client=DataAnaClientMock(),
|
||||
default_model="gpt-4o-mini",
|
||||
)
|
||||
data = await svc.generate(
|
||||
class_id="c-1",
|
||||
report_type="class_summary",
|
||||
)
|
||||
assert data.degraded is False
|
||||
assert "班级学情报告" in data.content
|
||||
assert data.summary # 非空
|
||||
assert len(data.recommendations) == 2
|
||||
assert "函数概念" in data.recommendations[0]
|
||||
|
||||
async def test_generate_student_detail_with_student_id(self) -> None:
|
||||
"""学生详情报告(带 student_id)."""
|
||||
chain = _make_chain(
|
||||
MockProvider(
|
||||
response_content=(
|
||||
"# 学生学情详情\n\n"
|
||||
"## 摘要\n该生在函数概念上较薄弱。\n\n"
|
||||
"## 教学建议\n- 针对性练习\n"
|
||||
),
|
||||
)
|
||||
)
|
||||
svc = ReportService(
|
||||
failover_chain=chain,
|
||||
data_ana_client=DataAnaClientMock(),
|
||||
)
|
||||
data = await svc.generate(
|
||||
class_id="c-1",
|
||||
report_type="student_detail",
|
||||
student_id="s-001",
|
||||
)
|
||||
assert data.degraded is False
|
||||
assert "学生学情详情" in data.content
|
||||
|
||||
async def test_generate_degraded_llm_fail(self) -> None:
|
||||
"""LLM 不可用时降级."""
|
||||
chain = _make_chain(MockProvider(fail=True))
|
||||
svc = ReportService(
|
||||
failover_chain=chain,
|
||||
data_ana_client=DataAnaClientMock(),
|
||||
)
|
||||
data = await svc.generate(
|
||||
class_id="c-1",
|
||||
report_type="exam_analysis",
|
||||
)
|
||||
assert data.degraded is True
|
||||
assert "LLM unavailable" in data.degraded_reason
|
||||
assert data.content == ""
|
||||
|
||||
async def test_generate_degraded_no_data_ana_client(self) -> None:
|
||||
"""无 data-ana 客户端时上下文降级(但 LLM 仍可生成)."""
|
||||
chain = _make_chain(MockProvider(response_content="# 报告\n\n## 摘要\n无数据。"))
|
||||
svc = ReportService(
|
||||
failover_chain=chain,
|
||||
data_ana_client=None,
|
||||
)
|
||||
data = await svc.generate(
|
||||
class_id="c-1",
|
||||
report_type="class_summary",
|
||||
)
|
||||
# LLM 可用 → 报告生成成功(上下文降级但不影响 LLM 调用)
|
||||
assert data.degraded is False
|
||||
assert "报告" in data.content
|
||||
|
||||
def test_extract_summary_from_explicit_section(self) -> None:
|
||||
"""从「摘要」段落提取摘要."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
content = "# 报告\n\n## 摘要\n这是摘要内容。\n\n## 详细\n详情"
|
||||
summary = svc._extract_summary(content)
|
||||
assert "这是摘要内容" in summary
|
||||
|
||||
def test_extract_summary_fallback_first_200_chars(self) -> None:
|
||||
"""无「摘要」段落时取前 200 字."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
content = "这是一段没有摘要标题的报告内容。"
|
||||
summary = svc._extract_summary(content)
|
||||
assert summary == content
|
||||
|
||||
def test_extract_recommendations_from_dash_list(self) -> None:
|
||||
"""从「-」列表提取建议."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
content = "## 教学建议\n- 建议一\n- 建议二\n## 其他\n"
|
||||
recs = svc._extract_recommendations(content)
|
||||
assert recs == ["建议一", "建议二"]
|
||||
|
||||
def test_extract_recommendations_from_numbered_list(self) -> None:
|
||||
"""从数字列表提取建议."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
content = "## 建议\n1. 第一条\n2. 第二条\n"
|
||||
recs = svc._extract_recommendations(content)
|
||||
assert recs == ["第一条", "第二条"]
|
||||
|
||||
def test_build_prompt_with_context(self) -> None:
|
||||
"""构建 prompt 包含上下文数据."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
context = {
|
||||
"class_id": "c-1",
|
||||
"report_type": "class_summary",
|
||||
"average_score": 78.5,
|
||||
"pass_rate": 0.85,
|
||||
}
|
||||
prompt = svc._build_prompt("class_summary", context)
|
||||
assert "class_summary" in prompt or "班级学情总结" in prompt
|
||||
assert "78.5" in prompt
|
||||
|
||||
Reference in New Issue
Block a user