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:
SpecialX
2026-07-14 22:57:57 +08:00
parent 843b370b3d
commit aac26c7c6f
21 changed files with 1954 additions and 278 deletions

Binary file not shown.

View File

@@ -0,0 +1,340 @@
# AI 模块 nextstep-v2
> 模块ai | gRPC 50058 | HTTP 3008 | Python (FastAPI + gRPC aio)
> 更新时间2026-07-14
> 前序文档:[nextstep.md](./nextstep.md)v18 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 + RedisP6 评估是否引入 Temporal |
| 2 | content 事件订阅 | P5 不订阅 content 事件P6+ 评估是否需要知识点变更事件驱动 |
| 3 | MockLLMProvider | 02 文档提到但未实现,测试环境用 httpx Mock 替代 |
| 4 | 报告持久化 | 当前 GenerateReport 不持久化报告P6+ 评估是否需要入库 |
| 5 | 报告模板管理 | 当前用内联 fallback 模板P6+ 评估是否需要独立模板管理服务 |
| 6 | 报告异步生成 | 当前同步生成长报告可能超时P6+ 评估是否改为异步 + 轮询模式 |

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
"""AiService gRPC Servicer8 RPC 实现).
"""AiService gRPC Servicer9 RPC 实现).
所有 RPC 返回 protobuf message降级采用方案 Bdegraded 字段在 message 内)。
业务逻辑由注入的 service 层处理servicer 仅做 proto ↔ domain 模型转换。
@@ -25,6 +25,7 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
- question_service: QuestionServiceGenerateQuestion / StreamGenerateQuestion
- expression_service: ExpressionServiceOptimizeExpression
- 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:
"""构建降级聊天响应."""

View File

@@ -1,8 +1,8 @@
"""AI 网关服务入口.
整合组件02-architecture-design.md §1.2 完整分层):
- HTTP 端点(/v1/ai 前缀ActionState 信封10 端点)
- gRPC server端口 500588 RPC
- HTTP 端点(/v1/ai 前缀ActionState 信封11 端点)
- gRPC server端口 500589 RPC
- LLM Provider FailoverChain4 适配器 + 熔断 + 故障切换)
- Prompt 模板服务Jinja2 + YAML
- 评估三道防线RuleValidator + LLMJudge + QualityGate
@@ -10,6 +10,7 @@
- 安全层PII + 输入清洗 + 输出审核)
- 下游 gRPC 客户端content/data-ana/iam真实 gRPC 调用)
- 备课工作流4 步编排 + Redis 状态存储)
- 学情报告class_summary / student_detail / exam_analysisdata-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)

View File

@@ -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,

View File

@@ -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",

View 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="学生 IDstudent_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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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",
]

View 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]

View File

@@ -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
# ------------------------------------------------------------------ #

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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 == ""

View File

@@ -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)

View File

@@ -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({
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({
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