feat(ai): gRPC clients 扩展 + server interceptors + proto_gen + 测试 + nextstep 文档
This commit is contained in:
@@ -2,7 +2,15 @@ module.exports = {
|
||||
'*.{ts,tsx}': ['eslint --fix', 'prettier --write'],
|
||||
// Go 工具链不在 git hook PATH 中,Go 文件格式化由 go fmt 手动执行
|
||||
// golangci-lint 安装后恢复:['gofmt -w', 'golangci-lint run --fix']
|
||||
'*.py': ['ruff check --fix', 'ruff format'],
|
||||
// 排除 proto_gen(自动生成的 gRPC stub)和 .coverage(二进制文件)
|
||||
'*.py': (files) => {
|
||||
const filtered = files.filter(
|
||||
(f) => !f.includes('proto_gen') && !f.endsWith('.coverage'),
|
||||
);
|
||||
return filtered.length > 0
|
||||
? [`ruff check --fix ${filtered.map((f) => `"${f}"`).join(' ')}`, `ruff format ${filtered.map((f) => `"${f}"`).join(' ')}`]
|
||||
: [];
|
||||
},
|
||||
// buf format 一次只接受 1 个文件参数,需逐个调用
|
||||
'*.proto': (files) => files.map((file) => `buf format --write "${file}"`),
|
||||
'*.md': ['prettier --write'],
|
||||
|
||||
Binary file not shown.
190
services/ai/docs/nextstep.md
Normal file
190
services/ai/docs/nextstep.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# AI 模块 nextstep
|
||||
|
||||
> 模块:ai(ai12 负责)| gRPC 50058 | HTTP 3008 | Python (FastAPI)
|
||||
> 更新时间:2026-07-13
|
||||
|
||||
---
|
||||
|
||||
## §1 当前状态
|
||||
|
||||
P5 实现已完成。8 RPC 全部实现,gRPC 拦截器已修复为异步兼容,下游 gRPC 客户端已从 Mock 切换为真实 gRPC 调用。377 个测试通过,覆盖率 88%。
|
||||
|
||||
### 已完成
|
||||
|
||||
- [x] ai.proto 8 RPC 完整版(含 GetLessonPlanStatus / ConfirmLessonPlan)
|
||||
- [x] events.proto AIUsageEvent 补全
|
||||
- [x] gRPC server 端口 50058,异步拦截器(Logging + Auth + Error)
|
||||
- [x] HTTP 10 端点(/v1/ai 前缀,ActionState 信封)
|
||||
- [x] LLM Provider FailoverChain(OpenAI / Anthropic / 百川 / Ollama + 熔断 + 故障切换)
|
||||
- [x] 评估三道防线(RuleValidator + LLMJudge + QualityGate)
|
||||
- [x] 用量记录(Redis)+ Kafka 事件发布 + 配额管理
|
||||
- [x] 安全层(PII + 输入清洗 + 输出审核)
|
||||
- [x] 下游 gRPC 客户端真实调用(content / data-ana / iam,不再使用 Mock)
|
||||
- [x] 备课工作流(4 步编排 + Redis 状态存储)
|
||||
- [x] Dockerfile 多阶段构建 + docker-compose.deploy.yml 环境变量补全
|
||||
|
||||
---
|
||||
|
||||
## §2 上游依赖(ai 依赖谁)
|
||||
|
||||
### §2.1 gRPC 同步调用
|
||||
|
||||
| 被调用方 | 端口 | Service.RPC | 用途 | 状态 |
|
||||
| --------------- | ----- | -------------------------------------- | --------------------------------------- | --------- |
|
||||
| content (ai09) | 50054 | KnowledgeGraphService.GetPrerequisites | 查询知识点前置依赖(备课工作流 Step 2) | ✅ 已实现 |
|
||||
| content (ai09) | 50054 | KnowledgeGraphService.GetLearningPath | 查询学习路径(备课工作流 Step 2) | ✅ 已实现 |
|
||||
| content (ai09) | 50054 | QuestionService.BatchCreateQuestions | 批量创建题目入库(备课工作流 Confirm) | ✅ 已实现 |
|
||||
| data-ana (ai11) | 50055 | AnalyticsService.GetClassPerformance | 查询班级学情(备课工作流 Step 1) | ✅ 已实现 |
|
||||
| data-ana (ai11) | 50055 | AnalyticsService.GetStudentWeakness | 查询学生薄弱点(备课工作流 Step 1) | ✅ 已实现 |
|
||||
| data-ana (ai11) | 50055 | AnalyticsService.GetLearningTrend | 查询学习趋势(备课工作流 Step 1) | ✅ 已实现 |
|
||||
| iam (ai06) | 50052 | IamService.GetEffectiveDataScope | 查询用户数据范围(多租户配额) | ✅ 已实现 |
|
||||
|
||||
### §2.2 基础设施依赖
|
||||
|
||||
| 依赖 | 用途 | 状态 |
|
||||
| ----------------------- | ----------------------------------------------- | --------- |
|
||||
| Redis | 限流(三维度令牌桶)+ 工作流状态存储 + 用量记录 | ✅ 已实现 |
|
||||
| Kafka | AIUsageEvent 事件发布(topic: `edu.ai.usage`) | ✅ 已实现 |
|
||||
| OpenTelemetry Collector | 链路追踪 + 指标导出 | ✅ 已实现 |
|
||||
|
||||
### §2.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` | 本地降级 | ✅ 已实现 |
|
||||
|
||||
> 未配置任何 API key 时进入降级模式,返回 `degraded=true` + 空内容。
|
||||
|
||||
---
|
||||
|
||||
## §3 下游就绪信号(谁依赖 ai)
|
||||
|
||||
### §3.1 teacher-bff (ai03) — 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 | ✅ 已就绪 |
|
||||
|
||||
> teacher-bff 通过 `AI_GRPC_TARGET=ai:50058` 连接,留空时走降级模式 B。
|
||||
|
||||
### §3.2 student-bff (ai04) — P1
|
||||
|
||||
| 就绪标志 | 消费方式 | 状态 |
|
||||
| --------------------------------------- | ---------- | --------- |
|
||||
| AiService.StreamChat 可调用(流式对话) | gRPC 50058 | ✅ 已就绪 |
|
||||
|
||||
> student-bff 需要的 StreamAIChat 对应 ai 的 StreamChat RPC。
|
||||
|
||||
### §3.3 api-gateway (ai01) — P2
|
||||
|
||||
| 就绪标志 | 消费方式 | 状态 |
|
||||
| -------------------------- | ---------------------------------------- | --------- |
|
||||
| ai HTTP 3008 /healthz 可达 | HTTP 反向代理 `/api/v1/ai/*` → `ai:3008` | ✅ 已就绪 |
|
||||
|
||||
### §3.4 data-ana (ai11) — P2
|
||||
|
||||
| 就绪标志 | 消费方式 | 状态 |
|
||||
| ------------------------------------- | -------------------------------------- | --------- |
|
||||
| Kafka topic `edu.ai.usage` 有事件发布 | CDC 消费者 → ClickHouse `ai_usage_log` | ✅ 已就绪 |
|
||||
|
||||
### §3.5 parent-bff / push-gateway — 无直接依赖
|
||||
|
||||
parent-bff 和 push-gateway 不直接依赖 ai 模块。
|
||||
|
||||
---
|
||||
|
||||
## §4 联调待办
|
||||
|
||||
| # | 联调项 | 联调方 | 阻塞条件 | 状态 |
|
||||
| --- | ------------------------------------- | ------------------ | ----------------------------- | ------------------------------------ |
|
||||
| 1 | ai gRPC + teacher-bff SSE 联调 | teacher-bff (ai03) | ai 服务容器启动 | ✅ ai 侧就绪(待 teacher-bff 接入) |
|
||||
| 2 | ai gRPC StreamChat + student-bff 联调 | student-bff (ai04) | ai 服务容器启动 | ✅ ai 侧就绪(待 student-bff 接入) |
|
||||
| 3 | ai /healthz + api-gateway 联调 | api-gateway (ai01) | ai 服务容器启动 | ✅ ai 侧就绪(待 api-gateway 路由) |
|
||||
| 4 | AIUsageEvent + data-ana CDC 消费联调 | data-ana (ai11) | ai Kafka 生产 + data-ana 消费 | ✅ ai 生产就绪(待 data-ana 消费) |
|
||||
| 5 | ai ↔ content gRPC 联调 | content (ai09) | 双方容器启动 | ✅ ai 客户端就绪(待 content 启动) |
|
||||
| 6 | ai ↔ data-ana gRPC 联调 | data-ana (ai11) | 双方容器启动 | ✅ ai 客户端就绪(待 data-ana 启动) |
|
||||
| 7 | ai ↔ iam gRPC 联调 | iam (ai06) | 双方容器启动 | ✅ ai 客户端就绪(待 iam 启动) |
|
||||
|
||||
> ai 侧 Docker 容器已启动并验证通过,下游 gRPC 客户端连接循环已在 lifespan 中执行成功。剩余联调项等待对端模块接入。
|
||||
|
||||
---
|
||||
|
||||
## §5 Docker 测试环境
|
||||
|
||||
### §5.1 构建与启动
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker compose -f infra/docker-compose.deploy.yml build ai
|
||||
|
||||
# 启动 ai 服务(依赖 Redis + Kafka + 基础设施)
|
||||
docker compose -f infra/docker-compose.yml up -d redis kafka
|
||||
docker compose -f infra/docker-compose.deploy.yml up -d ai
|
||||
```
|
||||
|
||||
### §5.2 健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:3008/healthz # {"status":"ok","service":"ai"}
|
||||
curl http://localhost:3008/readyz # {"status":"ok","llm_configured":...,"grpc_running":true}
|
||||
```
|
||||
|
||||
### §5.3 环境变量(docker-compose.deploy.yml 已配置)
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| ------------------------- | -------------------- | -------------------- |
|
||||
| `HTTP_PORT` | 3008 | HTTP 端口 |
|
||||
| `GRPC_PORT` | 50058 | gRPC 端口 |
|
||||
| `REDIS_URL` | redis://redis:6379/0 | Redis 连接 |
|
||||
| `KAFKA_BOOTSTRAP_SERVERS` | kafka:29092 | Kafka 连接(容器内) |
|
||||
| `CONTENT_GRPC_ENDPOINT` | content:50054 | content gRPC 端点 |
|
||||
| `DATA_ANA_GRPC_ENDPOINT` | data-ana:50055 | data-ana gRPC 端点 |
|
||||
| `IAM_GRPC_ENDPOINT` | iam:50052 | iam gRPC 端点 |
|
||||
| `OPENAI_API_KEY` | (空) | OpenAI API Key |
|
||||
| `DEV_MODE` | true | 开发模式(本地测试) |
|
||||
|
||||
### §5.4 本地 Docker 测试结果(2026-07-13)
|
||||
|
||||
**测试环境**:`infra/docker-compose.test.yml` + `edu-full_default` 外部网络
|
||||
|
||||
| # | 验证项 | 结果 | 证据 |
|
||||
| --- | -------------------- | ------- | ------------------------------------------------------------------------------------- |
|
||||
| 1 | Docker 镜像构建 | ✅ 通过 | 多阶段构建成功(builder + runtime),Dockerfile curl 版本 pinning 已移除 |
|
||||
| 2 | 容器启动 | ✅ 通过 | ai 服务容器成功启动并加入 `edu-full_default` 网络 |
|
||||
| 3 | 下游 gRPC 客户端连接 | ✅ 通过 | 日志:`grpc_client_connected endpoint=content:50054` / `data-ana:50055` / `iam:50052` |
|
||||
| 4 | Redis 连接 | ✅ 通过 | 日志:限流器 + 工作流状态存储初始化成功 |
|
||||
| 5 | Kafka producer 启动 | ✅ 通过 | 日志:`kafka_producer_started bootstrap_servers=kafka:29092 topic=edu.ai.usage` |
|
||||
| 6 | gRPC server 启动 | ✅ 通过 | 日志:`grpc_server_started port=50058`,端口 50058 监听成功 |
|
||||
| 7 | gRPC 拦截器加载 | ✅ 通过 | 异步拦截器(Logging + Auth + Error)正确加载,无 ValueError 降级警告 |
|
||||
| 8 | HTTP /healthz | ✅ 通过 | `curl http://localhost:3008/healthz` → `{"status":"ok","service":"ai"}` |
|
||||
| 9 | HTTP /readyz | ✅ 通过 | `curl http://localhost:3008/readyz` → `{"grpc_running":true,...}` |
|
||||
| 10 | LLM 降级模式 | ✅ 预期 | 未配置 API key,`llm_configured:false, degraded:true`(预期行为) |
|
||||
| 11 | 单元测试 | ✅ 通过 | 377 个测试通过,覆盖率 88% |
|
||||
| 12 | ruff lint | ✅ 通过 | 零警告 |
|
||||
|
||||
**关键修复记录**:
|
||||
|
||||
| 问题 | 根因 | 修复 |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| gRPC 拦截器 ValueError | 同步 `grpc.ServerInterceptor` 与 `grpc.aio.server()` 不兼容,服务器静默降级为无拦截器 | 重写所有 3 个拦截器继承 `grpc.aio.ServerInterceptor`,`intercept_service` 改为 `async` |
|
||||
| Dockerfile 构建失败 | `curl=7.88.*` 版本 pinning 在 Debian Trixie 中不存在 | 移除版本 pinning,改为 `curl` |
|
||||
| 端口 50058 占用 | 本地 Python 进程占用 | `Stop-Process -Id <PID> -Force` |
|
||||
| proto_gen 导入失败 | 生成的 `*_pb2_grpc.py` 使用绝对导入 | `proto_gen/__init__.py` 添加 `sys.path.insert(0, _PB_DIR)` |
|
||||
| 类型注解 AttributeError | `grpc.aio.HandlerCallDetails` 不存在 | 类型注解改用 `grpc.HandlerCallDetails` 和 `grpc.RpcMethodHandler`(同步版本,用于注解) |
|
||||
|
||||
---
|
||||
|
||||
## §6 P6+ 待评估
|
||||
|
||||
| # | 待评估项 | 说明 |
|
||||
| --- | ----------------- | ------------------------------------------------------------------ |
|
||||
| 1 | Temporal 引入评估 | 备课工作流 P5 用 BackgroundTasks + Redis,P6 评估是否引入 Temporal |
|
||||
| 2 | content 事件订阅 | P5 不订阅 content 事件,P6+ 评估是否需要知识点变更事件驱动 |
|
||||
| 3 | MockLLMProvider | 02 文档提到但未实现,测试环境用 httpx Mock 替代 |
|
||||
@@ -5,20 +5,59 @@
|
||||
客户端:
|
||||
- ContentClient: 查询知识点/教材/题库(content 服务 gRPC 50054)
|
||||
- DataAnaClient: 查询学情/薄弱点/趋势(data-ana 服务 gRPC 50055)
|
||||
- IamClient: 查询 DataScope(iam 服务 gRPC 50052,P4 补全后启用)
|
||||
- IamClient: 查询 DataScope(iam 服务 gRPC 50052)
|
||||
|
||||
全并行模式:下游不可用时返回 mock 数据或抛 AI_DOWNSTREAM_UNAVAILABLE 降级。
|
||||
生产环境使用 *Grpc 类(真实 gRPC 调用);
|
||||
单元测试使用 *Mock 类(仅返回固定数据,不依赖外部服务)。
|
||||
"""
|
||||
|
||||
from .content_client import ContentClient, ContentClientMock
|
||||
from .data_ana_client import DataAnaClient, DataAnaClientMock
|
||||
from .iam_client import IamClient, IamClientMock
|
||||
from .content_client import (
|
||||
ContentClient,
|
||||
ContentClientGrpc,
|
||||
ContentClientMock,
|
||||
CreatedQuestion,
|
||||
KnowledgePoint,
|
||||
QuestionInput,
|
||||
)
|
||||
from .data_ana_client import (
|
||||
ClassPerformance,
|
||||
DataAnaClient,
|
||||
DataAnaClientGrpc,
|
||||
DataAnaClientMock,
|
||||
LearningTrend,
|
||||
StudentScore,
|
||||
StudentWeakness,
|
||||
TrendPoint,
|
||||
WeakPoint,
|
||||
)
|
||||
from .iam_client import (
|
||||
DataScope,
|
||||
IamClient,
|
||||
IamClientGrpc,
|
||||
IamClientMock,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# content
|
||||
"ContentClient",
|
||||
"ContentClientGrpc",
|
||||
"ContentClientMock",
|
||||
"CreatedQuestion",
|
||||
"KnowledgePoint",
|
||||
"QuestionInput",
|
||||
# data-ana
|
||||
"ClassPerformance",
|
||||
"DataAnaClient",
|
||||
"DataAnaClientGrpc",
|
||||
"DataAnaClientMock",
|
||||
"LearningTrend",
|
||||
"StudentScore",
|
||||
"StudentWeakness",
|
||||
"TrendPoint",
|
||||
"WeakPoint",
|
||||
# iam
|
||||
"DataScope",
|
||||
"IamClient",
|
||||
"IamClientGrpc",
|
||||
"IamClientMock",
|
||||
]
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Content 服务 gRPC 客户端.
|
||||
|
||||
用于:
|
||||
- 查询知识点前置依赖(GetPrerequisites)
|
||||
- 查询学习路径(GetLearningPath)
|
||||
- 创建题目入库(CreateQuestions - P5 mock,content 服务待补全)
|
||||
- 查询知识点前置依赖(KnowledgeGraphService.GetPrerequisites)
|
||||
- 查询学习路径(KnowledgeGraphService.GetLearningPath)
|
||||
- 创建题目入库(QuestionService.BatchCreateQuestions)
|
||||
|
||||
全并行模式:下游不可用时使用 mock 数据降级。
|
||||
真实 gRPC 调用:未连接或调用失败时抛 AIError(不再降级到 mock 数据)。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import grpc
|
||||
import structlog
|
||||
|
||||
from ..errors import AIError, ErrorCode
|
||||
from ..proto_gen import content_pb2, content_pb2_grpc
|
||||
from .base_client import BaseGrpcClient
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -29,7 +31,7 @@ class KnowledgePoint:
|
||||
|
||||
@dataclass
|
||||
class QuestionInput:
|
||||
"""题目入库输入(对应 ConfirmLessonPlan 调用 content.CreateQuestions)."""
|
||||
"""题目入库输入(对应 ConfirmLessonPlan 调用 content.BatchCreateQuestions)."""
|
||||
|
||||
question: str
|
||||
answer: str
|
||||
@@ -83,7 +85,7 @@ class ContentClient(ABC):
|
||||
|
||||
|
||||
class ContentClientMock(ContentClient):
|
||||
"""Content 客户端 Mock 实现(全并行模式)."""
|
||||
"""Content 客户端 Mock 实现(仅用于单元测试)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._available = True
|
||||
@@ -92,15 +94,8 @@ class ContentClientMock(ContentClient):
|
||||
self,
|
||||
knowledge_point_id: str,
|
||||
) -> list[KnowledgePoint]:
|
||||
logger.info(
|
||||
"content_mock_get_prerequisites",
|
||||
knowledge_point_id=knowledge_point_id,
|
||||
)
|
||||
return [
|
||||
KnowledgePoint(
|
||||
id="kp_base_001",
|
||||
title="基础概念(mock)",
|
||||
),
|
||||
KnowledgePoint(id="kp_base_001", title="基础概念(mock)"),
|
||||
]
|
||||
|
||||
async def get_learning_path(
|
||||
@@ -108,15 +103,9 @@ class ContentClientMock(ContentClient):
|
||||
student_id: str,
|
||||
subject_id: str,
|
||||
) -> list[KnowledgePoint]:
|
||||
logger.info(
|
||||
"content_mock_get_learning_path",
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
return [
|
||||
KnowledgePoint(id="kp_001", title="知识点1(mock)"),
|
||||
KnowledgePoint(id="kp_002", title="知识点2(mock)"),
|
||||
KnowledgePoint(id="kp_003", title="知识点3(mock)"),
|
||||
]
|
||||
|
||||
async def create_questions(
|
||||
@@ -124,16 +113,8 @@ class ContentClientMock(ContentClient):
|
||||
questions: list[QuestionInput],
|
||||
user_id: str = "",
|
||||
) -> list[CreatedQuestion]:
|
||||
logger.info(
|
||||
"content_mock_create_questions",
|
||||
count=len(questions),
|
||||
user_id=user_id,
|
||||
)
|
||||
return [
|
||||
CreatedQuestion(
|
||||
id=f"q_mock_{i:04d}",
|
||||
question=q.question,
|
||||
)
|
||||
CreatedQuestion(id=f"q_mock_{i:04d}", question=q.question)
|
||||
for i, q in enumerate(questions)
|
||||
]
|
||||
|
||||
@@ -141,84 +122,125 @@ class ContentClientMock(ContentClient):
|
||||
return self._available
|
||||
|
||||
|
||||
class ContentClientGrpc(ContentClient):
|
||||
"""Content 服务 gRPC 客户端实现.
|
||||
|
||||
全并行模式:gRPC 调用失败时降级到 mock 数据。
|
||||
"""
|
||||
class ContentClientGrpc(BaseGrpcClient, ContentClient):
|
||||
"""Content 服务 gRPC 客户端实现(真实调用,不降级到 mock)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str = "localhost:50054",
|
||||
request_id: str = "",
|
||||
) -> None:
|
||||
self._endpoint = endpoint
|
||||
self._request_id = request_id
|
||||
self._channel: Any = None
|
||||
self._mock = ContentClientMock()
|
||||
super().__init__(endpoint, request_id)
|
||||
self._kg_stub: content_pb2_grpc.KnowledgeGraphServiceStub | None = None
|
||||
self._q_stub: content_pb2_grpc.QuestionServiceStub | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""建立 gRPC 连接."""
|
||||
import grpc
|
||||
|
||||
self._channel = grpc.aio.insecure_channel(self._endpoint)
|
||||
logger.info("content_client_connected", endpoint=self._endpoint)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 gRPC 连接."""
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._channel = None
|
||||
"""建立 gRPC 连接并初始化 stub."""
|
||||
await super().connect()
|
||||
self._kg_stub = content_pb2_grpc.KnowledgeGraphServiceStub(self.channel)
|
||||
self._q_stub = content_pb2_grpc.QuestionServiceStub(self.channel)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._channel is not None
|
||||
return self._channel is not None and self._kg_stub is not None
|
||||
|
||||
async def get_prerequisites(
|
||||
self,
|
||||
knowledge_point_id: str,
|
||||
) -> list[KnowledgePoint]:
|
||||
"""查询知识点前置依赖(KnowledgeGraphService.GetPrerequisites)."""
|
||||
if not self.is_available():
|
||||
logger.warning("content_client_not_connected_using_mock")
|
||||
return await self._mock.get_prerequisites(knowledge_point_id)
|
||||
try:
|
||||
# 动态导入 proto 生成代码(如果存在)
|
||||
# 全并行模式:proto 未生成时降级到 mock
|
||||
return await self._mock.get_prerequisites(knowledge_point_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"content_get_prerequisites_failed_degraded",
|
||||
error=str(exc),
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
"content client not connected",
|
||||
)
|
||||
return await self._mock.get_prerequisites(knowledge_point_id)
|
||||
assert self._kg_stub is not None # noqa: S101 - narrowing for type checker
|
||||
request = content_pb2.GetPrerequisitesRequest(
|
||||
knowledge_point_id=knowledge_point_id,
|
||||
depth=1,
|
||||
)
|
||||
try:
|
||||
response: content_pb2.KnowledgePointsResponse = await self._kg_stub.GetPrerequisites(
|
||||
request
|
||||
)
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
f"content.GetPrerequisites failed: {exc.details()}",
|
||||
) from exc
|
||||
return [KnowledgePoint(id=p.id, title=p.title) for p in response.points]
|
||||
|
||||
async def get_learning_path(
|
||||
self,
|
||||
student_id: str,
|
||||
subject_id: str,
|
||||
) -> list[KnowledgePoint]:
|
||||
"""查询学习路径(KnowledgeGraphService.GetLearningPath)."""
|
||||
if not self.is_available():
|
||||
return await self._mock.get_learning_path(student_id, subject_id)
|
||||
try:
|
||||
return await self._mock.get_learning_path(student_id, subject_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"content_get_learning_path_failed_degraded",
|
||||
error=str(exc),
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
"content client not connected",
|
||||
)
|
||||
return await self._mock.get_learning_path(student_id, subject_id)
|
||||
assert self._kg_stub is not None # noqa: S101
|
||||
request = content_pb2.GetLearningPathRequest(
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
try:
|
||||
response: content_pb2.LearningPath = await self._kg_stub.GetLearningPath(request)
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
f"content.GetLearningPath failed: {exc.details()}",
|
||||
) from exc
|
||||
return [KnowledgePoint(id=p.id, title=p.title) for p in response.points]
|
||||
|
||||
async def create_questions(
|
||||
self,
|
||||
questions: list[QuestionInput],
|
||||
user_id: str = "",
|
||||
) -> list[CreatedQuestion]:
|
||||
"""批量创建题目入库(QuestionService.BatchCreateQuestions)."""
|
||||
if not self.is_available():
|
||||
logger.warning("content_client_not_connected_using_mock")
|
||||
return await self._mock.create_questions(questions, user_id)
|
||||
try:
|
||||
return await self._mock.create_questions(questions, user_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
f"content.CreateQuestions failed: {exc}",
|
||||
"content client not connected",
|
||||
)
|
||||
assert self._q_stub is not None # noqa: S101
|
||||
proto_questions = [
|
||||
content_pb2.CreateQuestionRequest(
|
||||
knowledge_point_id=(q.knowledge_point_ids[0] if q.knowledge_point_ids else ""),
|
||||
type=q.question_type,
|
||||
content=q.question,
|
||||
answer=q.answer,
|
||||
explanation=q.explanation,
|
||||
difficulty=_parse_difficulty(q.difficulty),
|
||||
source="ai_workflow",
|
||||
created_by=user_id,
|
||||
)
|
||||
for q in questions
|
||||
]
|
||||
request = content_pb2.BatchCreateQuestionsRequest(questions=proto_questions)
|
||||
try:
|
||||
response: content_pb2.BatchCreateQuestionsResponse = (
|
||||
await self._q_stub.BatchCreateQuestions(request)
|
||||
)
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
f"content.BatchCreateQuestions failed: {exc.details()}",
|
||||
) from exc
|
||||
# 按 ids 顺序返回,过滤掉失败的索引
|
||||
results: list[CreatedQuestion] = []
|
||||
failed_indices = {f.index for f in response.failed}
|
||||
for i, q in enumerate(questions):
|
||||
if i in failed_indices:
|
||||
continue
|
||||
if i < len(response.ids):
|
||||
results.append(CreatedQuestion(id=response.ids[i], question=q.question))
|
||||
return results
|
||||
|
||||
|
||||
def _parse_difficulty(difficulty: str) -> int:
|
||||
"""将字符串难度映射为 proto int32 difficulty."""
|
||||
mapping = {"easy": 1, "medium": 2, "hard": 3, "简单": 1, "中等": 2, "困难": 3}
|
||||
return mapping.get(difficulty.lower() if difficulty else "", 2)
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"""Data-ana 服务 gRPC 客户端.
|
||||
|
||||
用于:
|
||||
- 查询班级学情(GetClassPerformance)
|
||||
- 查询学生薄弱点(GetStudentWeakness)
|
||||
- 查询学习趋势(GetLearningTrend)
|
||||
- 查询班级学情(AnalyticsService.GetClassPerformance)
|
||||
- 查询学生薄弱点(AnalyticsService.GetStudentWeakness)
|
||||
- 查询学习趋势(AnalyticsService.GetLearningTrend)
|
||||
|
||||
全并行模式:下游不可用时使用 mock 数据降级。
|
||||
真实 gRPC 调用:未连接或调用失败时抛 AIError(不再降级到 mock 数据)。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import grpc
|
||||
import structlog
|
||||
|
||||
from ..errors import AIError, ErrorCode
|
||||
from ..proto_gen import analytics_pb2, analytics_pb2_grpc
|
||||
from .base_client import BaseGrpcClient
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@@ -109,7 +113,7 @@ class DataAnaClient(ABC):
|
||||
|
||||
|
||||
class DataAnaClientMock(DataAnaClient):
|
||||
"""Data-ana 客户端 Mock 实现(全并行模式)."""
|
||||
"""Data-ana 客户端 Mock 实现(仅用于单元测试)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._available = True
|
||||
@@ -121,11 +125,6 @@ class DataAnaClientMock(DataAnaClient):
|
||||
start_date: int = 0,
|
||||
end_date: int = 0,
|
||||
) -> ClassPerformance:
|
||||
logger.info(
|
||||
"data_ana_mock_class_performance",
|
||||
class_id=class_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
return ClassPerformance(
|
||||
class_id=class_id,
|
||||
average_score=78.5,
|
||||
@@ -133,7 +132,6 @@ class DataAnaClientMock(DataAnaClient):
|
||||
scores=[
|
||||
StudentScore(student_id="s_001", score=85.0, grade="A"),
|
||||
StudentScore(student_id="s_002", score=72.0, grade="B"),
|
||||
StudentScore(student_id="s_003", score=65.0, grade="C"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -142,11 +140,6 @@ class DataAnaClientMock(DataAnaClient):
|
||||
student_id: str,
|
||||
subject_id: str,
|
||||
) -> StudentWeakness:
|
||||
logger.info(
|
||||
"data_ana_mock_student_weakness",
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
return StudentWeakness(
|
||||
student_id=student_id,
|
||||
weak_points=[
|
||||
@@ -155,11 +148,6 @@ class DataAnaClientMock(DataAnaClient):
|
||||
title="函数概念(mock)",
|
||||
mastery=0.45,
|
||||
),
|
||||
WeakPoint(
|
||||
knowledge_point_id="kp_005",
|
||||
title="三角函数(mock)",
|
||||
mastery=0.52,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -169,16 +157,11 @@ class DataAnaClientMock(DataAnaClient):
|
||||
start_date: int = 0,
|
||||
end_date: int = 0,
|
||||
) -> LearningTrend:
|
||||
logger.info(
|
||||
"data_ana_mock_learning_trend",
|
||||
student_id=student_id,
|
||||
)
|
||||
return LearningTrend(
|
||||
student_id=student_id,
|
||||
points=[
|
||||
TrendPoint(date=20260101, score=65.0),
|
||||
TrendPoint(date=20260201, score=70.0),
|
||||
TrendPoint(date=20260301, score=75.0),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -186,37 +169,24 @@ class DataAnaClientMock(DataAnaClient):
|
||||
return self._available
|
||||
|
||||
|
||||
class DataAnaClientGrpc(DataAnaClient):
|
||||
"""Data-ana 服务 gRPC 客户端实现.
|
||||
|
||||
全并行模式:gRPC 调用失败时降级到 mock 数据。
|
||||
"""
|
||||
class DataAnaClientGrpc(BaseGrpcClient, DataAnaClient):
|
||||
"""Data-ana 服务 gRPC 客户端实现(真实调用,不降级到 mock)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str = "localhost:50055",
|
||||
request_id: str = "",
|
||||
) -> None:
|
||||
self._endpoint = endpoint
|
||||
self._request_id = request_id
|
||||
self._channel: Any = None
|
||||
self._mock = DataAnaClientMock()
|
||||
super().__init__(endpoint, request_id)
|
||||
self._stub: analytics_pb2_grpc.AnalyticsServiceStub | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""建立 gRPC 连接."""
|
||||
import grpc
|
||||
|
||||
self._channel = grpc.aio.insecure_channel(self._endpoint)
|
||||
logger.info("data_ana_client_connected", endpoint=self._endpoint)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 gRPC 连接."""
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._channel = None
|
||||
"""建立 gRPC 连接并初始化 stub."""
|
||||
await super().connect()
|
||||
self._stub = analytics_pb2_grpc.AnalyticsServiceStub(self.channel)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._channel is not None
|
||||
return self._channel is not None and self._stub is not None
|
||||
|
||||
async def get_class_performance(
|
||||
self,
|
||||
@@ -225,39 +195,74 @@ class DataAnaClientGrpc(DataAnaClient):
|
||||
start_date: int = 0,
|
||||
end_date: int = 0,
|
||||
) -> ClassPerformance:
|
||||
"""查询班级学情(AnalyticsService.GetClassPerformance)."""
|
||||
if not self.is_available():
|
||||
return await self._mock.get_class_performance(
|
||||
class_id, subject_id, start_date, end_date,
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
"data-ana client not connected",
|
||||
)
|
||||
assert self._stub is not None # noqa: S101
|
||||
request = analytics_pb2.GetClassPerformanceRequest(
|
||||
class_id=class_id,
|
||||
subject_id=subject_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
try:
|
||||
# 全并行模式:proto 未生成时降级到 mock
|
||||
return await self._mock.get_class_performance(
|
||||
class_id, subject_id, start_date, end_date,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"data_ana_class_performance_failed_degraded",
|
||||
error=str(exc),
|
||||
)
|
||||
return await self._mock.get_class_performance(
|
||||
class_id, subject_id, start_date, end_date,
|
||||
)
|
||||
response: analytics_pb2.ClassPerformance = await self._stub.GetClassPerformance(request)
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
f"data-ana.GetClassPerformance failed: {exc.details()}",
|
||||
) from exc
|
||||
return ClassPerformance(
|
||||
class_id=response.class_id,
|
||||
average_score=response.average_score,
|
||||
pass_rate=response.pass_rate,
|
||||
scores=[
|
||||
StudentScore(
|
||||
student_id=s.student_id,
|
||||
score=s.score,
|
||||
grade=s.grade,
|
||||
)
|
||||
for s in response.scores
|
||||
],
|
||||
)
|
||||
|
||||
async def get_student_weakness(
|
||||
self,
|
||||
student_id: str,
|
||||
subject_id: str,
|
||||
) -> StudentWeakness:
|
||||
"""查询学生薄弱点(AnalyticsService.GetStudentWeakness)."""
|
||||
if not self.is_available():
|
||||
return await self._mock.get_student_weakness(student_id, subject_id)
|
||||
try:
|
||||
return await self._mock.get_student_weakness(student_id, subject_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"data_ana_student_weakness_failed_degraded",
|
||||
error=str(exc),
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
"data-ana client not connected",
|
||||
)
|
||||
return await self._mock.get_student_weakness(student_id, subject_id)
|
||||
assert self._stub is not None # noqa: S101
|
||||
request = analytics_pb2.GetStudentWeaknessRequest(
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
try:
|
||||
response: analytics_pb2.StudentWeakness = await self._stub.GetStudentWeakness(request)
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
f"data-ana.GetStudentWeakness failed: {exc.details()}",
|
||||
) from exc
|
||||
return StudentWeakness(
|
||||
student_id=response.student_id,
|
||||
weak_points=[
|
||||
WeakPoint(
|
||||
knowledge_point_id=p.knowledge_point_id,
|
||||
title=p.title,
|
||||
mastery=p.mastery,
|
||||
)
|
||||
for p in response.weak_points
|
||||
],
|
||||
)
|
||||
|
||||
async def get_learning_trend(
|
||||
self,
|
||||
@@ -265,19 +270,26 @@ class DataAnaClientGrpc(DataAnaClient):
|
||||
start_date: int = 0,
|
||||
end_date: int = 0,
|
||||
) -> LearningTrend:
|
||||
"""查询学习趋势(AnalyticsService.GetLearningTrend)."""
|
||||
if not self.is_available():
|
||||
return await self._mock.get_learning_trend(
|
||||
student_id, start_date, end_date,
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
"data-ana client not connected",
|
||||
)
|
||||
assert self._stub is not None # noqa: S101
|
||||
request = analytics_pb2.GetLearningTrendRequest(
|
||||
student_id=student_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
try:
|
||||
return await self._mock.get_learning_trend(
|
||||
student_id, start_date, end_date,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"data_ana_learning_trend_failed_degraded",
|
||||
error=str(exc),
|
||||
)
|
||||
return await self._mock.get_learning_trend(
|
||||
student_id, start_date, end_date,
|
||||
)
|
||||
response: analytics_pb2.LearningTrend = await self._stub.GetLearningTrend(request)
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
f"data-ana.GetLearningTrend failed: {exc.details()}",
|
||||
) from exc
|
||||
return LearningTrend(
|
||||
student_id=response.student_id,
|
||||
points=[TrendPoint(date=p.date, score=p.score) for p in response.points],
|
||||
)
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
"""IAM 服务 gRPC 客户端.
|
||||
|
||||
用于:
|
||||
- 查询用户有效数据范围(GetEffectiveDataScope - ISSUE-07: P4 补全,ai 用 mock)
|
||||
- 查询用户有效数据范围(IamService.GetEffectiveDataScope)
|
||||
|
||||
全并行模式:IAM 不可用时使用 mock 数据降级。
|
||||
真实 gRPC 调用:未连接或调用失败时抛 AIError(不再降级到 mock 数据)。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import grpc
|
||||
import structlog
|
||||
|
||||
from ..errors import AIError, ErrorCode
|
||||
from ..proto_gen import iam_pb2, iam_pb2_grpc
|
||||
from .base_client import BaseGrpcClient
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@@ -56,10 +61,7 @@ class IamClient(ABC):
|
||||
self,
|
||||
user_id: str,
|
||||
) -> DataScope:
|
||||
"""查询用户有效数据范围.
|
||||
|
||||
ISSUE-07: IAM P4 补全 GetEffectiveDataScope RPC 后启用真实调用。
|
||||
"""
|
||||
"""查询用户有效数据范围."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
@@ -69,7 +71,7 @@ class IamClient(ABC):
|
||||
|
||||
|
||||
class IamClientMock(IamClient):
|
||||
"""IAM 客户端 Mock 实现(全并行模式)."""
|
||||
"""IAM 客户端 Mock 实现(仅用于单元测试)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._available = True
|
||||
@@ -78,7 +80,6 @@ class IamClientMock(IamClient):
|
||||
self,
|
||||
user_id: str,
|
||||
) -> DataScope:
|
||||
logger.info("iam_mock_get_data_scope", user_id=user_id)
|
||||
return DataScope(
|
||||
user_id=user_id,
|
||||
school_id="school_mock_001",
|
||||
@@ -93,43 +94,68 @@ class IamClientMock(IamClient):
|
||||
return self._available
|
||||
|
||||
|
||||
class IamClientGrpc(IamClient):
|
||||
"""IAM 服务 gRPC 客户端实现.
|
||||
|
||||
ISSUE-07: IAM P4 补全 GetEffectiveDataScope RPC 后启用。
|
||||
全并行模式:当前使用 mock 数据。
|
||||
"""
|
||||
class IamClientGrpc(BaseGrpcClient, IamClient):
|
||||
"""IAM 服务 gRPC 客户端实现(真实调用,不降级到 mock)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str = "localhost:50052",
|
||||
request_id: str = "",
|
||||
) -> None:
|
||||
self._endpoint = endpoint
|
||||
self._request_id = request_id
|
||||
self._channel: Any = None
|
||||
self._mock = IamClientMock()
|
||||
super().__init__(endpoint, request_id)
|
||||
self._stub: iam_pb2_grpc.IamServiceStub | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""建立 gRPC 连接."""
|
||||
import grpc
|
||||
|
||||
self._channel = grpc.aio.insecure_channel(self._endpoint)
|
||||
logger.info("iam_client_connected", endpoint=self._endpoint)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 gRPC 连接."""
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._channel = None
|
||||
"""建立 gRPC 连接并初始化 stub."""
|
||||
await super().connect()
|
||||
self._stub = iam_pb2_grpc.IamServiceStub(self.channel)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._channel is not None
|
||||
return self._channel is not None and self._stub is not None
|
||||
|
||||
async def get_effective_data_scope(
|
||||
self,
|
||||
user_id: str,
|
||||
) -> DataScope:
|
||||
# ISSUE-07: IAM P4 补全 GetEffectiveDataScope 后启用真实调用
|
||||
# 全并行模式:当前使用 mock
|
||||
return await self._mock.get_effective_data_scope(user_id)
|
||||
"""查询用户有效数据范围(IamService.GetEffectiveDataScope).
|
||||
|
||||
Proto 返回 EffectiveDataScope{user_id, level, scope_ids, school_id}:
|
||||
- level=SELF → class_ids=[], 仅本人
|
||||
- level=CLASS → class_ids=scope_ids
|
||||
- level=GRADE → grade_ids=scope_ids
|
||||
- level=SCHOOL → school_id 取响应的 school_id
|
||||
- level=ALL → is_admin=True
|
||||
"""
|
||||
if not self.is_available():
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
"iam client not connected",
|
||||
)
|
||||
assert self._stub is not None # noqa: S101
|
||||
request = iam_pb2.GetEffectiveDataScopeRequest(user_id=user_id)
|
||||
try:
|
||||
response: iam_pb2.EffectiveDataScope = await self._stub.GetEffectiveDataScope(request)
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
raise AIError(
|
||||
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
|
||||
f"iam.GetEffectiveDataScope failed: {exc.details()}",
|
||||
) from exc
|
||||
# 将 proto EffectiveDataScope 映射为内部 DataScope
|
||||
class_ids: list[str] = []
|
||||
grade_ids: list[str] = []
|
||||
is_admin = False
|
||||
level = response.level.upper() if response.level else "SELF"
|
||||
if level == "CLASS":
|
||||
class_ids = list(response.scope_ids)
|
||||
elif level == "GRADE":
|
||||
grade_ids = list(response.scope_ids)
|
||||
elif level == "ALL":
|
||||
is_admin = True
|
||||
return DataScope(
|
||||
user_id=response.user_id or user_id,
|
||||
school_id=response.school_id,
|
||||
class_ids=class_ids,
|
||||
grade_ids=grade_ids,
|
||||
role="admin" if is_admin else "teacher",
|
||||
is_admin=is_admin,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""gRPC 拦截器.
|
||||
"""gRPC 拦截器(异步版本,适配 grpc.aio.server).
|
||||
|
||||
提供:
|
||||
- LoggingInterceptor: 请求/响应日志 + 延迟统计
|
||||
- ErrorInterceptor: 异常捕获 → gRPC status code 映射
|
||||
- AuthInterceptor: 从 metadata 提取用户上下文
|
||||
|
||||
注意:grpc.aio.server 的 interceptors 必须继承 grpc.aio.ServerInterceptor,
|
||||
且 intercept_service 必须是 async 方法。同步 grpc.ServerInterceptor 会触发 ValueError。
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import grpc
|
||||
@@ -20,66 +22,72 @@ from ..middleware.error_handler import grpc_error_mapper
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class LoggingInterceptor(grpc.ServerInterceptor):
|
||||
"""请求日志 + 延迟统计."""
|
||||
async def _async_wrapper(
|
||||
handler: Any,
|
||||
request: Any,
|
||||
context: grpc.aio.ServicerContext,
|
||||
) -> Any:
|
||||
"""调用 async 或 sync handler 并返回响应."""
|
||||
return await handler(request, context)
|
||||
|
||||
def intercept_service(
|
||||
|
||||
class LoggingInterceptor(grpc.aio.ServerInterceptor):
|
||||
"""请求日志 + 延迟统计(异步)."""
|
||||
|
||||
async def intercept_service(
|
||||
self,
|
||||
continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
|
||||
continuation: Any,
|
||||
handler_call_details: grpc.HandlerCallDetails,
|
||||
) -> grpc.RpcMethodHandler:
|
||||
method = handler_call_details.method
|
||||
start = time.monotonic()
|
||||
|
||||
def log_wrapper(handler: grpc.RpcMethodHandler) -> grpc.RpcMethodHandler:
|
||||
original_behavior = handler.unary_unary
|
||||
|
||||
def new_behavior(request: Any, context: grpc.ServicerContext) -> Any:
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
try:
|
||||
response = original_behavior(request, context) # type: ignore[misc]
|
||||
logger.info(
|
||||
"grpc_request",
|
||||
method=method,
|
||||
latency_ms=latency_ms,
|
||||
status="ok",
|
||||
)
|
||||
return response
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"grpc_request_error",
|
||||
method=method,
|
||||
latency_ms=latency_ms,
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
handler.unary_unary = new_behavior # type: ignore[method-assign]
|
||||
return handler
|
||||
|
||||
handler = continuation(handler_call_details)
|
||||
if handler is None:
|
||||
return None
|
||||
return log_wrapper(handler)
|
||||
|
||||
|
||||
class ErrorInterceptor(grpc.ServerInterceptor):
|
||||
"""异常捕获 → gRPC status code 映射."""
|
||||
|
||||
def intercept_service(
|
||||
self,
|
||||
continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
|
||||
handler_call_details: grpc.HandlerCallDetails,
|
||||
) -> grpc.RpcMethodHandler:
|
||||
handler = continuation(handler_call_details)
|
||||
handler = await continuation(handler_call_details)
|
||||
if handler is None:
|
||||
return None
|
||||
|
||||
original_behavior = handler.unary_unary
|
||||
|
||||
def new_behavior(request: Any, context: grpc.ServicerContext) -> Any:
|
||||
async def new_behavior(request: Any, context: grpc.aio.ServicerContext) -> Any:
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
try:
|
||||
return original_behavior(request, context) # type: ignore[misc]
|
||||
response = await _async_wrapper(original_behavior, request, context)
|
||||
logger.info(
|
||||
"grpc_request",
|
||||
method=method,
|
||||
latency_ms=latency_ms,
|
||||
status="ok",
|
||||
)
|
||||
return response
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"grpc_request_error",
|
||||
method=method,
|
||||
latency_ms=latency_ms,
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
handler.unary_unary = new_behavior # type: ignore[method-assign]
|
||||
return handler
|
||||
|
||||
|
||||
class ErrorInterceptor(grpc.aio.ServerInterceptor):
|
||||
"""异常捕获 → gRPC status code 映射(异步)."""
|
||||
|
||||
async def intercept_service(
|
||||
self,
|
||||
continuation: Any,
|
||||
handler_call_details: grpc.HandlerCallDetails,
|
||||
) -> grpc.RpcMethodHandler:
|
||||
handler = await continuation(handler_call_details)
|
||||
if handler is None:
|
||||
return None
|
||||
|
||||
original_behavior = handler.unary_unary
|
||||
|
||||
async def new_behavior(request: Any, context: grpc.aio.ServicerContext) -> Any:
|
||||
try:
|
||||
return await _async_wrapper(original_behavior, request, context)
|
||||
except AIError as exc:
|
||||
code, msg, grpc_status = grpc_error_mapper(exc)
|
||||
logger.warning(
|
||||
@@ -88,7 +96,7 @@ class ErrorInterceptor(grpc.ServerInterceptor):
|
||||
error_code=code,
|
||||
message=msg,
|
||||
)
|
||||
context.abort(_grpc_status(grpc_status), f"{code}: {msg}")
|
||||
await context.abort(_grpc_status(grpc_status), f"{code}: {msg}")
|
||||
except Exception as exc:
|
||||
code, msg, grpc_status = grpc_error_mapper(exc)
|
||||
logger.error(
|
||||
@@ -96,31 +104,33 @@ class ErrorInterceptor(grpc.ServerInterceptor):
|
||||
method=handler_call_details.method,
|
||||
error=str(exc),
|
||||
)
|
||||
context.abort(_grpc_status(grpc_status), f"{code}: {msg}")
|
||||
await context.abort(_grpc_status(grpc_status), f"{code}: {msg}")
|
||||
|
||||
handler.unary_unary = new_behavior # type: ignore[method-assign]
|
||||
return handler
|
||||
|
||||
|
||||
class AuthInterceptor(grpc.ServerInterceptor):
|
||||
"""从 gRPC metadata 提取用户上下文,存入 context."""
|
||||
class AuthInterceptor(grpc.aio.ServerInterceptor):
|
||||
"""从 gRPC metadata 提取用户上下文,存入 context(异步)."""
|
||||
|
||||
def intercept_service(
|
||||
async def intercept_service(
|
||||
self,
|
||||
continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
|
||||
continuation: Any,
|
||||
handler_call_details: grpc.HandlerCallDetails,
|
||||
) -> grpc.RpcMethodHandler:
|
||||
handler = continuation(handler_call_details)
|
||||
handler = await continuation(handler_call_details)
|
||||
if handler is None:
|
||||
return None
|
||||
|
||||
original_behavior = handler.unary_unary
|
||||
|
||||
def new_behavior(request: Any, context: grpc.ServicerContext) -> Any:
|
||||
ctx = extract_user_context_from_metadata(handler_call_details.invocation_metadata)
|
||||
async def new_behavior(request: Any, context: grpc.aio.ServicerContext) -> Any:
|
||||
ctx = extract_user_context_from_metadata(
|
||||
handler_call_details.invocation_metadata,
|
||||
)
|
||||
# 将 UserContext 存入 context 供 servicer 使用
|
||||
context.user_context = ctx # type: ignore[attr-defined]
|
||||
return original_behavior(request, context) # type: ignore[misc]
|
||||
return await _async_wrapper(original_behavior, request, context)
|
||||
|
||||
handler.unary_unary = new_behavior # type: ignore[method-assign]
|
||||
return handler
|
||||
@@ -149,6 +159,6 @@ def _grpc_status(code: int) -> grpc.StatusCode:
|
||||
return status_map.get(code, grpc.StatusCode.UNKNOWN)
|
||||
|
||||
|
||||
def get_user_context(context: grpc.ServicerContext) -> UserContext:
|
||||
def get_user_context(context: grpc.aio.ServicerContext) -> UserContext:
|
||||
"""从 ServicerContext 提取 UserContext(AuthInterceptor 注入)."""
|
||||
return getattr(context, "user_context", UserContext())
|
||||
|
||||
@@ -39,16 +39,10 @@ class GrpcServer:
|
||||
async def start(self) -> None:
|
||||
"""启动 gRPC server.
|
||||
|
||||
注意:grpc.aio.server 的 interceptors 需要 grpc.aio.ServerInterceptor 基类,
|
||||
当前拦截器使用同步 grpc.ServerInterceptor 基类会报 ValueError。
|
||||
此处 try/except 降级为无拦截器启动,避免阻塞服务启动。
|
||||
使用 grpc.aio.ServerInterceptor 异步拦截器(Logging/Auth/Error)。
|
||||
"""
|
||||
interceptors = [LoggingInterceptor(), AuthInterceptor(), ErrorInterceptor()]
|
||||
try:
|
||||
self._server = grpc.aio.server(interceptors=interceptors)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("grpc_interceptors_incompatible_start_without")
|
||||
self._server = grpc.aio.server()
|
||||
self._server = grpc.aio.server(interceptors=interceptors)
|
||||
ai_pb2_grpc.add_AiServiceServicer_to_server(self._servicer, self._server)
|
||||
self._server.add_insecure_port(f"[::]:{self._port}")
|
||||
await self._server.start()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- 评估三道防线(RuleValidator + LLMJudge + QualityGate)
|
||||
- 用量记录(Redis)+ Kafka 事件发布 + 配额管理
|
||||
- 安全层(PII + 输入清洗 + 输出审核)
|
||||
- 下游 gRPC 客户端(content/data-ana/iam,全并行用 Mock)
|
||||
- 下游 gRPC 客户端(content/data-ana/iam,真实 gRPC 调用)
|
||||
- 备课工作流(4 步编排 + Redis 状态存储)
|
||||
- 限流(Redis 三维度令牌桶)
|
||||
- OpenTelemetry + Prometheus
|
||||
@@ -30,7 +30,7 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from prometheus_client import make_asgi_app
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from .clients import ContentClientMock, DataAnaClientMock, IamClientMock
|
||||
from .clients import ContentClientGrpc, DataAnaClientGrpc, IamClientGrpc
|
||||
from .config import settings
|
||||
from .grpc_server import create_grpc_server
|
||||
from .middleware import (
|
||||
@@ -130,10 +130,11 @@ _rate_limiter = RateLimiter(
|
||||
school_limit=settings.redis_rate_limit_school_per_min,
|
||||
)
|
||||
|
||||
# 下游客户端(全并行模式用 Mock,ISSUE-07)
|
||||
_content_client = ContentClientMock()
|
||||
_data_ana_client = DataAnaClientMock()
|
||||
_iam_client = IamClientMock()
|
||||
# 下游 gRPC 客户端(真实 gRPC 调用,lifespan 中连接)
|
||||
# 连接失败时降级(不阻断启动),但调用未连接的客户端方法会抛 AIError
|
||||
_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)
|
||||
|
||||
_state_store = WorkflowStateStore(
|
||||
redis=None,
|
||||
@@ -179,6 +180,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.warning("redis_connect_failed_degraded", error=str(exc))
|
||||
_redis = None
|
||||
|
||||
# 下游 gRPC 客户端连接(每个独立 try,单个失败不阻断其他)
|
||||
downstream_clients = [
|
||||
("content", _content_client),
|
||||
("data_ana", _data_ana_client),
|
||||
("iam", _iam_client),
|
||||
]
|
||||
for name, client in downstream_clients:
|
||||
try:
|
||||
await client.connect()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"downstream_client_connect_failed",
|
||||
client=name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
await _kafka_producer.start()
|
||||
await _grpc_server.start()
|
||||
|
||||
@@ -188,6 +205,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
grpc_port=settings.grpc_port,
|
||||
dev_mode=settings.is_dev,
|
||||
llm_available=settings.llm_available,
|
||||
content_connected=_content_client.is_available(),
|
||||
data_ana_connected=_data_ana_client.is_available(),
|
||||
iam_connected=_iam_client.is_available(),
|
||||
)
|
||||
if not settings.llm_available:
|
||||
logger.warning("ai_service_llm_degraded_no_api_key")
|
||||
@@ -198,6 +218,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("ai_service_stopping")
|
||||
await _grpc_server.stop()
|
||||
await _kafka_producer.stop()
|
||||
for name, client in reversed(downstream_clients):
|
||||
try:
|
||||
await client.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"downstream_client_close_failed",
|
||||
client=name,
|
||||
error=str(exc),
|
||||
)
|
||||
if _redis is not None:
|
||||
await _redis.aclose()
|
||||
logger.info("redis_closed")
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
"""protobuf 生成代码(勿手动编辑).
|
||||
|
||||
由 grpc_tools.protoc 从 packages/shared-proto/proto/ai.proto 生成。
|
||||
重新生成命令:
|
||||
由 grpc_tools.protoc 从 packages/shared-proto/proto/ 生成。
|
||||
重新生成命令(在 services/ai 目录下):
|
||||
uv run python -m grpc_tools.protoc \\
|
||||
-I ../../packages/shared-proto/proto \\
|
||||
--python_out=src/ai/proto_gen \\
|
||||
--grpc_python_out=src/ai/proto_gen \\
|
||||
../../packages/shared-proto/proto/ai.proto
|
||||
../../packages/shared-proto/proto/ai.proto \\
|
||||
../../packages/shared-proto/proto/content.proto \\
|
||||
../../packages/shared-proto/proto/analytics.proto \\
|
||||
../../packages/shared-proto/proto/iam.proto
|
||||
|
||||
注意:生成的 *_pb2_grpc.py 文件使用绝对导入(如 `import content_pb2 as content__pb2`),
|
||||
本文件通过 sys.path 注入确保这些绝对导入可用。
|
||||
"""
|
||||
|
||||
from . import ai_pb2, ai_pb2_grpc
|
||||
import os
|
||||
import sys
|
||||
|
||||
__all__ = ["ai_pb2", "ai_pb2_grpc"]
|
||||
# 将本目录加入 sys.path,使生成的 *_pb2_grpc.py 中的绝对导入(如 `import content_pb2`)可用
|
||||
_PB_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _PB_DIR not in sys.path:
|
||||
sys.path.insert(0, _PB_DIR)
|
||||
|
||||
from . import ( # noqa: E402
|
||||
ai_pb2,
|
||||
ai_pb2_grpc,
|
||||
analytics_pb2,
|
||||
analytics_pb2_grpc,
|
||||
content_pb2,
|
||||
content_pb2_grpc,
|
||||
iam_pb2,
|
||||
iam_pb2_grpc,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ai_pb2",
|
||||
"ai_pb2_grpc",
|
||||
"analytics_pb2",
|
||||
"analytics_pb2_grpc",
|
||||
"content_pb2",
|
||||
"content_pb2_grpc",
|
||||
"iam_pb2",
|
||||
"iam_pb2_grpc",
|
||||
]
|
||||
|
||||
102
services/ai/src/ai/proto_gen/analytics_pb2.py
Normal file
102
services/ai/src/ai/proto_gen/analytics_pb2.py
Normal file
File diff suppressed because one or more lines are too long
591
services/ai/src/ai/proto_gen/analytics_pb2_grpc.py
Normal file
591
services/ai/src/ai/proto_gen/analytics_pb2_grpc.py
Normal file
@@ -0,0 +1,591 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
import warnings
|
||||
|
||||
import analytics_pb2 as analytics__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.82.1'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
try:
|
||||
from grpc._utilities import first_version_is_lower
|
||||
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
||||
except ImportError:
|
||||
_version_not_supported = True
|
||||
|
||||
if _version_not_supported:
|
||||
raise RuntimeError(
|
||||
f'The grpc package installed is at version {GRPC_VERSION},'
|
||||
+ ' but the generated code in analytics_pb2_grpc.py depends on'
|
||||
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
||||
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
||||
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
||||
)
|
||||
|
||||
|
||||
class AnalyticsServiceStub:
|
||||
"""AnalyticsService 数据分析服务契约(D6 智能洞察领域).
|
||||
P4 启用 gRPC server 端口 50055,HTTP 3006 保留作 Gateway 直连降级.
|
||||
所有 RPC 返回 ActionState 信封(success/data/error/details.degraded).
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.GetClassPerformance = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetClassPerformance',
|
||||
request_serializer=analytics__pb2.GetClassPerformanceRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.ClassPerformance.FromString,
|
||||
_registered_method=True)
|
||||
self.GetStudentWeakness = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetStudentWeakness',
|
||||
request_serializer=analytics__pb2.GetStudentWeaknessRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.StudentWeakness.FromString,
|
||||
_registered_method=True)
|
||||
self.GetLearningTrend = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetLearningTrend',
|
||||
request_serializer=analytics__pb2.GetLearningTrendRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.LearningTrend.FromString,
|
||||
_registered_method=True)
|
||||
self.GetTeacherDashboard = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetTeacherDashboard',
|
||||
request_serializer=analytics__pb2.GetTeacherDashboardRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.TeacherDashboard.FromString,
|
||||
_registered_method=True)
|
||||
self.GetStudentDashboard = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetStudentDashboard',
|
||||
request_serializer=analytics__pb2.GetStudentDashboardRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.StudentDashboard.FromString,
|
||||
_registered_method=True)
|
||||
self.GetParentDashboard = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetParentDashboard',
|
||||
request_serializer=analytics__pb2.GetParentDashboardRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.ParentDashboard.FromString,
|
||||
_registered_method=True)
|
||||
self.GetAdminDashboard = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetAdminDashboard',
|
||||
request_serializer=analytics__pb2.GetAdminDashboardRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.AdminDashboard.FromString,
|
||||
_registered_method=True)
|
||||
self.GetWarnings = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetWarnings',
|
||||
request_serializer=analytics__pb2.GetWarningsRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.WarningList.FromString,
|
||||
_registered_method=True)
|
||||
self.TriggerWarning = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/TriggerWarning',
|
||||
request_serializer=analytics__pb2.TriggerWarningRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.TriggerWarningResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetMasteryDistribution = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetMasteryDistribution',
|
||||
request_serializer=analytics__pb2.GetMasteryDistributionRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.MasteryDistribution.FromString,
|
||||
_registered_method=True)
|
||||
self.GetStudentMastery = channel.unary_unary(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/GetStudentMastery',
|
||||
request_serializer=analytics__pb2.GetStudentMasteryRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.StudentMastery.FromString,
|
||||
_registered_method=True)
|
||||
self.SubscribeMasteryUpdate = channel.unary_stream(
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/SubscribeMasteryUpdate',
|
||||
request_serializer=analytics__pb2.SubscribeMasteryUpdateRequest.SerializeToString,
|
||||
response_deserializer=analytics__pb2.MasteryUpdateEvent.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class AnalyticsServiceServicer:
|
||||
"""AnalyticsService 数据分析服务契约(D6 智能洞察领域).
|
||||
P4 启用 gRPC server 端口 50055,HTTP 3006 保留作 Gateway 直连降级.
|
||||
所有 RPC 返回 ActionState 信封(success/data/error/details.degraded).
|
||||
"""
|
||||
|
||||
def GetClassPerformance(self, request, context):
|
||||
"""班级成绩分析(平均分/及格率/参考人数).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetStudentWeakness(self, request, context):
|
||||
"""学生薄弱知识点(mastery_level < 0.6).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetLearningTrend(self, request, context):
|
||||
"""学习趋势(历史成绩曲线).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetTeacherDashboard(self, request, context):
|
||||
"""教师仪表盘聚合(班级概览 + 待办 + 预警).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetStudentDashboard(self, request, context):
|
||||
"""学生仪表盘(个人学情 + 排名 + 薄弱点).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetParentDashboard(self, request, context):
|
||||
"""家长仪表盘(孩子学情概览).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetAdminDashboard(self, request, context):
|
||||
"""管理员仪表盘(全校统计 + AI 用量).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetWarnings(self, request, context):
|
||||
"""预警列表查询(按班级/严重度/时间过滤).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def TriggerWarning(self, request, context):
|
||||
"""手动触发预警(管理员/教师主动标记关注).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetMasteryDistribution(self, request, context):
|
||||
"""班级掌握度分布(mastered/progressing/weak 三档).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetStudentMastery(self, request, context):
|
||||
"""学生知识点掌握度明细.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def SubscribeMasteryUpdate(self, request, context):
|
||||
"""订阅掌握度更新(server-streaming,P5+ AI 个性化推荐实时推送通道).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_AnalyticsServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'GetClassPerformance': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetClassPerformance,
|
||||
request_deserializer=analytics__pb2.GetClassPerformanceRequest.FromString,
|
||||
response_serializer=analytics__pb2.ClassPerformance.SerializeToString,
|
||||
),
|
||||
'GetStudentWeakness': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetStudentWeakness,
|
||||
request_deserializer=analytics__pb2.GetStudentWeaknessRequest.FromString,
|
||||
response_serializer=analytics__pb2.StudentWeakness.SerializeToString,
|
||||
),
|
||||
'GetLearningTrend': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetLearningTrend,
|
||||
request_deserializer=analytics__pb2.GetLearningTrendRequest.FromString,
|
||||
response_serializer=analytics__pb2.LearningTrend.SerializeToString,
|
||||
),
|
||||
'GetTeacherDashboard': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetTeacherDashboard,
|
||||
request_deserializer=analytics__pb2.GetTeacherDashboardRequest.FromString,
|
||||
response_serializer=analytics__pb2.TeacherDashboard.SerializeToString,
|
||||
),
|
||||
'GetStudentDashboard': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetStudentDashboard,
|
||||
request_deserializer=analytics__pb2.GetStudentDashboardRequest.FromString,
|
||||
response_serializer=analytics__pb2.StudentDashboard.SerializeToString,
|
||||
),
|
||||
'GetParentDashboard': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetParentDashboard,
|
||||
request_deserializer=analytics__pb2.GetParentDashboardRequest.FromString,
|
||||
response_serializer=analytics__pb2.ParentDashboard.SerializeToString,
|
||||
),
|
||||
'GetAdminDashboard': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetAdminDashboard,
|
||||
request_deserializer=analytics__pb2.GetAdminDashboardRequest.FromString,
|
||||
response_serializer=analytics__pb2.AdminDashboard.SerializeToString,
|
||||
),
|
||||
'GetWarnings': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetWarnings,
|
||||
request_deserializer=analytics__pb2.GetWarningsRequest.FromString,
|
||||
response_serializer=analytics__pb2.WarningList.SerializeToString,
|
||||
),
|
||||
'TriggerWarning': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.TriggerWarning,
|
||||
request_deserializer=analytics__pb2.TriggerWarningRequest.FromString,
|
||||
response_serializer=analytics__pb2.TriggerWarningResponse.SerializeToString,
|
||||
),
|
||||
'GetMasteryDistribution': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetMasteryDistribution,
|
||||
request_deserializer=analytics__pb2.GetMasteryDistributionRequest.FromString,
|
||||
response_serializer=analytics__pb2.MasteryDistribution.SerializeToString,
|
||||
),
|
||||
'GetStudentMastery': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetStudentMastery,
|
||||
request_deserializer=analytics__pb2.GetStudentMasteryRequest.FromString,
|
||||
response_serializer=analytics__pb2.StudentMastery.SerializeToString,
|
||||
),
|
||||
'SubscribeMasteryUpdate': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.SubscribeMasteryUpdate,
|
||||
request_deserializer=analytics__pb2.SubscribeMasteryUpdateRequest.FromString,
|
||||
response_serializer=analytics__pb2.MasteryUpdateEvent.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'next_edu_cloud.analytics.v1.AnalyticsService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
server.add_registered_method_handlers('next_edu_cloud.analytics.v1.AnalyticsService', rpc_method_handlers)
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class AnalyticsService:
|
||||
"""AnalyticsService 数据分析服务契约(D6 智能洞察领域).
|
||||
P4 启用 gRPC server 端口 50055,HTTP 3006 保留作 Gateway 直连降级.
|
||||
所有 RPC 返回 ActionState 信封(success/data/error/details.degraded).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def GetClassPerformance(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.analytics.v1.AnalyticsService/GetClassPerformance',
|
||||
analytics__pb2.GetClassPerformanceRequest.SerializeToString,
|
||||
analytics__pb2.ClassPerformance.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetStudentWeakness(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.analytics.v1.AnalyticsService/GetStudentWeakness',
|
||||
analytics__pb2.GetStudentWeaknessRequest.SerializeToString,
|
||||
analytics__pb2.StudentWeakness.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetLearningTrend(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.analytics.v1.AnalyticsService/GetLearningTrend',
|
||||
analytics__pb2.GetLearningTrendRequest.SerializeToString,
|
||||
analytics__pb2.LearningTrend.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetTeacherDashboard(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.analytics.v1.AnalyticsService/GetTeacherDashboard',
|
||||
analytics__pb2.GetTeacherDashboardRequest.SerializeToString,
|
||||
analytics__pb2.TeacherDashboard.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetStudentDashboard(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.analytics.v1.AnalyticsService/GetStudentDashboard',
|
||||
analytics__pb2.GetStudentDashboardRequest.SerializeToString,
|
||||
analytics__pb2.StudentDashboard.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetParentDashboard(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.analytics.v1.AnalyticsService/GetParentDashboard',
|
||||
analytics__pb2.GetParentDashboardRequest.SerializeToString,
|
||||
analytics__pb2.ParentDashboard.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetAdminDashboard(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.analytics.v1.AnalyticsService/GetAdminDashboard',
|
||||
analytics__pb2.GetAdminDashboardRequest.SerializeToString,
|
||||
analytics__pb2.AdminDashboard.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetWarnings(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.analytics.v1.AnalyticsService/GetWarnings',
|
||||
analytics__pb2.GetWarningsRequest.SerializeToString,
|
||||
analytics__pb2.WarningList.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def TriggerWarning(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.analytics.v1.AnalyticsService/TriggerWarning',
|
||||
analytics__pb2.TriggerWarningRequest.SerializeToString,
|
||||
analytics__pb2.TriggerWarningResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetMasteryDistribution(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.analytics.v1.AnalyticsService/GetMasteryDistribution',
|
||||
analytics__pb2.GetMasteryDistributionRequest.SerializeToString,
|
||||
analytics__pb2.MasteryDistribution.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetStudentMastery(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.analytics.v1.AnalyticsService/GetStudentMastery',
|
||||
analytics__pb2.GetStudentMasteryRequest.SerializeToString,
|
||||
analytics__pb2.StudentMastery.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def SubscribeMasteryUpdate(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_stream(
|
||||
request,
|
||||
target,
|
||||
'/next_edu_cloud.analytics.v1.AnalyticsService/SubscribeMasteryUpdate',
|
||||
analytics__pb2.SubscribeMasteryUpdateRequest.SerializeToString,
|
||||
analytics__pb2.MasteryUpdateEvent.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
113
services/ai/src/ai/proto_gen/content_pb2.py
Normal file
113
services/ai/src/ai/proto_gen/content_pb2.py
Normal file
File diff suppressed because one or more lines are too long
1087
services/ai/src/ai/proto_gen/content_pb2_grpc.py
Normal file
1087
services/ai/src/ai/proto_gen/content_pb2_grpc.py
Normal file
File diff suppressed because it is too large
Load Diff
96
services/ai/src/ai/proto_gen/iam_pb2.py
Normal file
96
services/ai/src/ai/proto_gen/iam_pb2.py
Normal file
File diff suppressed because one or more lines are too long
719
services/ai/src/ai/proto_gen/iam_pb2_grpc.py
Normal file
719
services/ai/src/ai/proto_gen/iam_pb2_grpc.py
Normal file
@@ -0,0 +1,719 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
import warnings
|
||||
|
||||
import iam_pb2 as iam__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.82.1'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
try:
|
||||
from grpc._utilities import first_version_is_lower
|
||||
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
||||
except ImportError:
|
||||
_version_not_supported = True
|
||||
|
||||
if _version_not_supported:
|
||||
raise RuntimeError(
|
||||
f'The grpc package installed is at version {GRPC_VERSION},'
|
||||
+ ' but the generated code in iam_pb2_grpc.py depends on'
|
||||
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
||||
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
||||
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
||||
)
|
||||
|
||||
|
||||
class IamServiceStub:
|
||||
"""IamService 定义身份与访问管理契约
|
||||
双入口策略(president §2.16):REST 供 gateway 透传 + admin-portal 直连,
|
||||
gRPC 供 BFF 聚合调用。同一 Application Service 同时被两种 Controller 调用。
|
||||
gRPC 端口 50052,P2 即启用(I1 裁决)。
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.Register = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/Register',
|
||||
request_serializer=iam__pb2.RegisterRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.AuthResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.Login = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/Login',
|
||||
request_serializer=iam__pb2.LoginRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.AuthResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.RefreshToken = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/RefreshToken',
|
||||
request_serializer=iam__pb2.RefreshTokenRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.TokenPair.FromString,
|
||||
_registered_method=True)
|
||||
self.Logout = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/Logout',
|
||||
request_serializer=iam__pb2.LogoutRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.LogoutResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetUserInfo = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetUserInfo',
|
||||
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.GetEffectivePermissions = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetEffectivePermissions',
|
||||
request_serializer=iam__pb2.GetEffectivePermissionsRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.EffectivePermissionsResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetEffectiveAccess = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetEffectiveAccess',
|
||||
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,
|
||||
response_deserializer=iam__pb2.ViewportsResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetPublicKey = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetPublicKey',
|
||||
request_serializer=iam__pb2.GetPublicKeyRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.PublicKeyResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetChildrenByParent = channel.unary_unary(
|
||||
'/next_edu_cloud.iam.v1.IamService/GetChildrenByParent',
|
||||
request_serializer=iam__pb2.GetChildrenByParentRequest.SerializeToString,
|
||||
response_deserializer=iam__pb2.ChildrenResponse.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class IamServiceServicer:
|
||||
"""IamService 定义身份与访问管理契约
|
||||
双入口策略(president §2.16):REST 供 gateway 透传 + admin-portal 直连,
|
||||
gRPC 供 BFF 聚合调用。同一 Application Service 同时被两种 Controller 调用。
|
||||
gRPC 端口 50052,P2 即启用(I1 裁决)。
|
||||
"""
|
||||
|
||||
def Register(self, request, context):
|
||||
"""认证类
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def Login(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 RefreshToken(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 Logout(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 GetUserInfo(self, request, context):
|
||||
"""用户信息类
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
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 GetEffectivePermissions(self, request, context):
|
||||
"""权限与视口类
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetEffectiveAccess(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 GetViewports(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 GetPublicKey(self, request, context):
|
||||
"""密钥与关系类
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetChildrenByParent(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 = {
|
||||
'Register': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Register,
|
||||
request_deserializer=iam__pb2.RegisterRequest.FromString,
|
||||
response_serializer=iam__pb2.AuthResponse.SerializeToString,
|
||||
),
|
||||
'Login': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Login,
|
||||
request_deserializer=iam__pb2.LoginRequest.FromString,
|
||||
response_serializer=iam__pb2.AuthResponse.SerializeToString,
|
||||
),
|
||||
'RefreshToken': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.RefreshToken,
|
||||
request_deserializer=iam__pb2.RefreshTokenRequest.FromString,
|
||||
response_serializer=iam__pb2.TokenPair.SerializeToString,
|
||||
),
|
||||
'Logout': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Logout,
|
||||
request_deserializer=iam__pb2.LogoutRequest.FromString,
|
||||
response_serializer=iam__pb2.LogoutResponse.SerializeToString,
|
||||
),
|
||||
'GetUserInfo': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetUserInfo,
|
||||
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,
|
||||
),
|
||||
'GetEffectivePermissions': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetEffectivePermissions,
|
||||
request_deserializer=iam__pb2.GetEffectivePermissionsRequest.FromString,
|
||||
response_serializer=iam__pb2.EffectivePermissionsResponse.SerializeToString,
|
||||
),
|
||||
'GetEffectiveAccess': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetEffectiveAccess,
|
||||
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,
|
||||
response_serializer=iam__pb2.ViewportsResponse.SerializeToString,
|
||||
),
|
||||
'GetPublicKey': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetPublicKey,
|
||||
request_deserializer=iam__pb2.GetPublicKeyRequest.FromString,
|
||||
response_serializer=iam__pb2.PublicKeyResponse.SerializeToString,
|
||||
),
|
||||
'GetChildrenByParent': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetChildrenByParent,
|
||||
request_deserializer=iam__pb2.GetChildrenByParentRequest.FromString,
|
||||
response_serializer=iam__pb2.ChildrenResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'next_edu_cloud.iam.v1.IamService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
server.add_registered_method_handlers('next_edu_cloud.iam.v1.IamService', rpc_method_handlers)
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class IamService:
|
||||
"""IamService 定义身份与访问管理契约
|
||||
双入口策略(president §2.16):REST 供 gateway 透传 + admin-portal 直连,
|
||||
gRPC 供 BFF 聚合调用。同一 Application Service 同时被两种 Controller 调用。
|
||||
gRPC 端口 50052,P2 即启用(I1 裁决)。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def Register(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/Register',
|
||||
iam__pb2.RegisterRequest.SerializeToString,
|
||||
iam__pb2.AuthResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def Login(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/Login',
|
||||
iam__pb2.LoginRequest.SerializeToString,
|
||||
iam__pb2.AuthResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def RefreshToken(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/RefreshToken',
|
||||
iam__pb2.RefreshTokenRequest.SerializeToString,
|
||||
iam__pb2.TokenPair.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def Logout(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/Logout',
|
||||
iam__pb2.LogoutRequest.SerializeToString,
|
||||
iam__pb2.LogoutResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetUserInfo(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/GetUserInfo',
|
||||
iam__pb2.GetUserInfoRequest.SerializeToString,
|
||||
iam__pb2.UserInfo.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
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,
|
||||
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/BatchGetUsers',
|
||||
iam__pb2.BatchGetUsersRequest.SerializeToString,
|
||||
iam__pb2.BatchGetUsersResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetEffectivePermissions(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/GetEffectivePermissions',
|
||||
iam__pb2.GetEffectivePermissionsRequest.SerializeToString,
|
||||
iam__pb2.EffectivePermissionsResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetEffectiveAccess(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/GetEffectiveAccess',
|
||||
iam__pb2.GetEffectiveAccessRequest.SerializeToString,
|
||||
iam__pb2.EffectiveAccessResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
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,
|
||||
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/GetViewports',
|
||||
iam__pb2.GetViewportsRequest.SerializeToString,
|
||||
iam__pb2.ViewportsResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetPublicKey(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/GetPublicKey',
|
||||
iam__pb2.GetPublicKeyRequest.SerializeToString,
|
||||
iam__pb2.PublicKeyResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetChildrenByParent(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/GetChildrenByParent',
|
||||
iam__pb2.GetChildrenByParentRequest.SerializeToString,
|
||||
iam__pb2.ChildrenResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
@@ -19,7 +19,7 @@ class TestContentClientMock:
|
||||
"""查询学习路径."""
|
||||
client = ContentClientMock()
|
||||
result = await client.get_learning_path("s-1", "math")
|
||||
assert len(result) == 3
|
||||
assert len(result) == 2
|
||||
|
||||
async def test_create_questions(self) -> None:
|
||||
"""批量创建题目."""
|
||||
|
||||
@@ -419,34 +419,55 @@ async def test_content_grpc_connect_close() -> None:
|
||||
|
||||
|
||||
async def test_content_grpc_methods_not_connected() -> None:
|
||||
"""未连接时调用方法应抛 AIError(不降级到 mock)."""
|
||||
client = ContentClientGrpc()
|
||||
assert client.is_available() is False
|
||||
pre = await client.get_prerequisites("kp-1")
|
||||
assert len(pre) == 1
|
||||
path = await client.get_learning_path("s-1", "math")
|
||||
assert len(path) == 3
|
||||
questions = [
|
||||
QuestionInput(
|
||||
question="q1",
|
||||
answer="a1",
|
||||
explanation="e1",
|
||||
question_type="short_answer",
|
||||
difficulty="easy",
|
||||
knowledge_point_ids=["kp-1"],
|
||||
),
|
||||
]
|
||||
result = await client.create_questions(questions, user_id="u-1")
|
||||
assert len(result) == 1
|
||||
with pytest.raises(AIError, match="not connected"):
|
||||
await client.get_prerequisites("kp-1")
|
||||
with pytest.raises(AIError, match="not connected"):
|
||||
await client.get_learning_path("s-1", "math")
|
||||
with pytest.raises(AIError, match="not connected"):
|
||||
await client.create_questions([], user_id="u-1")
|
||||
|
||||
|
||||
async def test_content_grpc_methods_connected() -> None:
|
||||
"""连接后通过 gRPC stub 发起真实调用(使用 mock stub 验证)."""
|
||||
from src.ai.proto_gen import content_pb2
|
||||
|
||||
client = ContentClientGrpc()
|
||||
client._channel = MagicMock()
|
||||
# 构造 mock stub 返回 proto 响应
|
||||
mock_kg_stub = MagicMock()
|
||||
mock_kg_stub.GetPrerequisites = AsyncMock(
|
||||
return_value=content_pb2.KnowledgePointsResponse(
|
||||
points=[
|
||||
content_pb2.KnowledgePoint(id="kp_pre", title="前置知识点"),
|
||||
],
|
||||
),
|
||||
)
|
||||
mock_kg_stub.GetLearningPath = AsyncMock(
|
||||
return_value=content_pb2.LearningPath(
|
||||
points=[
|
||||
content_pb2.KnowledgePoint(id="kp_1", title="知识点1"),
|
||||
content_pb2.KnowledgePoint(id="kp_2", title="知识点2"),
|
||||
],
|
||||
),
|
||||
)
|
||||
mock_q_stub = MagicMock()
|
||||
mock_q_stub.BatchCreateQuestions = AsyncMock(
|
||||
return_value=content_pb2.BatchCreateQuestionsResponse(ids=["q_new_1"]),
|
||||
)
|
||||
client._kg_stub = mock_kg_stub
|
||||
client._q_stub = mock_q_stub
|
||||
assert client.is_available() is True
|
||||
|
||||
pre = await client.get_prerequisites("kp-1")
|
||||
assert len(pre) == 1
|
||||
assert pre[0].id == "kp_pre"
|
||||
|
||||
path = await client.get_learning_path("s-1", "math")
|
||||
assert len(path) == 3
|
||||
assert len(path) == 2
|
||||
|
||||
questions = [
|
||||
QuestionInput(
|
||||
question="q1",
|
||||
@@ -459,13 +480,25 @@ async def test_content_grpc_methods_connected() -> None:
|
||||
]
|
||||
result = await client.create_questions(questions, user_id="u-1")
|
||||
assert len(result) == 1
|
||||
assert result[0].id == "q_new_1"
|
||||
|
||||
|
||||
async def test_content_grpc_create_questions_exception_raises() -> None:
|
||||
"""gRPC 调用失败时包装为 AIError."""
|
||||
import grpc
|
||||
|
||||
client = ContentClientGrpc()
|
||||
client._channel = MagicMock()
|
||||
client._mock = MagicMock()
|
||||
client._mock.create_questions = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
mock_q_stub = MagicMock()
|
||||
mock_q_stub.BatchCreateQuestions = AsyncMock(
|
||||
side_effect=grpc.aio.AioRpcError(
|
||||
code=grpc.StatusCode.UNAVAILABLE,
|
||||
initial_metadata=[],
|
||||
trailing_metadata=[],
|
||||
details="service unavailable",
|
||||
),
|
||||
)
|
||||
client._q_stub = mock_q_stub
|
||||
with pytest.raises(AIError) as exc_info:
|
||||
await client.create_questions([], user_id="u-1")
|
||||
assert exc_info.value.code == ErrorCode.AI_DOWNSTREAM_UNAVAILABLE
|
||||
@@ -490,29 +523,71 @@ async def test_data_ana_grpc_connect_close() -> None:
|
||||
|
||||
|
||||
async def test_data_ana_grpc_methods_not_connected() -> None:
|
||||
"""未连接时调用方法应抛 AIError."""
|
||||
client = DataAnaClientGrpc()
|
||||
assert client.is_available() is False
|
||||
perf = await client.get_class_performance("c-1", "math")
|
||||
assert perf.class_id == "c-1"
|
||||
assert perf.average_score > 0
|
||||
weak = await client.get_student_weakness("s-1", "math")
|
||||
assert weak.student_id == "s-1"
|
||||
assert len(weak.weak_points) > 0
|
||||
trend = await client.get_learning_trend("s-1")
|
||||
assert trend.student_id == "s-1"
|
||||
assert len(trend.points) > 0
|
||||
with pytest.raises(AIError, match="not connected"):
|
||||
await client.get_class_performance("c-1", "math")
|
||||
with pytest.raises(AIError, match="not connected"):
|
||||
await client.get_student_weakness("s-1", "math")
|
||||
with pytest.raises(AIError, match="not connected"):
|
||||
await client.get_learning_trend("s-1")
|
||||
|
||||
|
||||
async def test_data_ana_grpc_methods_connected() -> None:
|
||||
"""连接后通过 gRPC stub 真实调用."""
|
||||
from src.ai.proto_gen import analytics_pb2
|
||||
|
||||
client = DataAnaClientGrpc()
|
||||
client._channel = MagicMock()
|
||||
mock_stub = MagicMock()
|
||||
mock_stub.GetClassPerformance = AsyncMock(
|
||||
return_value=analytics_pb2.ClassPerformance(
|
||||
class_id="c-1",
|
||||
average_score=82.5,
|
||||
pass_rate=0.9,
|
||||
scores=[
|
||||
analytics_pb2.StudentScore(student_id="s-1", score=85.0, grade="A"),
|
||||
],
|
||||
),
|
||||
)
|
||||
mock_stub.GetStudentWeakness = AsyncMock(
|
||||
return_value=analytics_pb2.StudentWeakness(
|
||||
student_id="s-1",
|
||||
weak_points=[
|
||||
analytics_pb2.WeakPoint(
|
||||
knowledge_point_id="kp-1",
|
||||
title="函数",
|
||||
mastery=0.4,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
mock_stub.GetLearningTrend = AsyncMock(
|
||||
return_value=analytics_pb2.LearningTrend(
|
||||
student_id="s-1",
|
||||
points=[
|
||||
analytics_pb2.TrendPoint(date=20260101, score=70.0),
|
||||
analytics_pb2.TrendPoint(date=20260201, score=75.0),
|
||||
],
|
||||
),
|
||||
)
|
||||
client._stub = mock_stub
|
||||
assert client.is_available() is True
|
||||
|
||||
perf = await client.get_class_performance("c-1", "math")
|
||||
assert perf.class_id == "c-1"
|
||||
assert perf.average_score == 82.5
|
||||
assert len(perf.scores) == 1
|
||||
|
||||
weak = await client.get_student_weakness("s-1", "math")
|
||||
assert weak.student_id == "s-1"
|
||||
assert len(weak.weak_points) == 1
|
||||
assert weak.weak_points[0].knowledge_point_id == "kp-1"
|
||||
|
||||
trend = await client.get_learning_trend("s-1")
|
||||
assert trend.student_id == "s-1"
|
||||
assert len(trend.points) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -534,16 +609,46 @@ async def test_iam_grpc_connect_close() -> None:
|
||||
|
||||
|
||||
async def test_iam_grpc_get_effective_data_scope() -> None:
|
||||
"""未连接时抛 AIError;连接后通过 gRPC stub 真实调用."""
|
||||
from src.ai.proto_gen import iam_pb2
|
||||
|
||||
client = IamClientGrpc()
|
||||
assert client.is_available() is False
|
||||
with pytest.raises(AIError, match="not connected"):
|
||||
await client.get_effective_data_scope("u-1")
|
||||
|
||||
# 连接后通过 mock stub 调用
|
||||
client._channel = MagicMock()
|
||||
mock_stub = MagicMock()
|
||||
mock_stub.GetEffectiveDataScope = AsyncMock(
|
||||
return_value=iam_pb2.EffectiveDataScope(
|
||||
user_id="u-1",
|
||||
level="CLASS",
|
||||
scope_ids=["class_001", "class_002"],
|
||||
school_id="school_001",
|
||||
),
|
||||
)
|
||||
client._stub = mock_stub
|
||||
assert client.is_available() is True
|
||||
|
||||
scope = await client.get_effective_data_scope("u-1")
|
||||
assert scope.user_id == "u-1"
|
||||
assert scope.school_id == "school_mock_001"
|
||||
assert len(scope.class_ids) > 0
|
||||
client._channel = MagicMock()
|
||||
assert client.is_available() is True
|
||||
scope2 = await client.get_effective_data_scope("u-2")
|
||||
assert scope2.user_id == "u-2"
|
||||
assert scope.school_id == "school_001"
|
||||
assert scope.class_ids == ["class_001", "class_002"]
|
||||
assert scope.is_admin is False
|
||||
|
||||
# ALL level 测试
|
||||
mock_stub.GetEffectiveDataScope = AsyncMock(
|
||||
return_value=iam_pb2.EffectiveDataScope(
|
||||
user_id="u-admin",
|
||||
level="ALL",
|
||||
scope_ids=[],
|
||||
school_id="",
|
||||
),
|
||||
)
|
||||
scope2 = await client.get_effective_data_scope("u-admin")
|
||||
assert scope2.is_admin is True
|
||||
assert scope2.role == "admin"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,13 +23,15 @@ from src.ai.workflow.state_store import WorkflowState, WorkflowStateStore
|
||||
from .conftest import MockProvider
|
||||
|
||||
# 有效的 LLM JSON 输出(通过三道防线评估)
|
||||
VALID_QUESTION_JSON = json.dumps({
|
||||
"question": "什么是函数?",
|
||||
"answer": "函数是一种对应关系",
|
||||
"explanation": "函数定义",
|
||||
"difficulty": "medium",
|
||||
"question_type": "short_answer",
|
||||
})
|
||||
VALID_QUESTION_JSON = json.dumps(
|
||||
{
|
||||
"question": "什么是函数?",
|
||||
"answer": "函数是一种对应关系",
|
||||
"explanation": "函数定义",
|
||||
"difficulty": "medium",
|
||||
"question_type": "short_answer",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_chain(provider: MockProvider | None = None) -> ProviderFailoverChain:
|
||||
@@ -135,11 +137,16 @@ class TestLessonPlanWorkflowConfirm:
|
||||
store = WorkflowStateStore(redis=None)
|
||||
state = _make_state(
|
||||
status="pending_review",
|
||||
questions=[GeneratedQuestionData(
|
||||
question="q1", answer="a1", explanation="e1",
|
||||
question_type="short_answer", difficulty="easy",
|
||||
knowledge_point_ids=["kp_1"],
|
||||
)],
|
||||
questions=[
|
||||
GeneratedQuestionData(
|
||||
question="q1",
|
||||
answer="a1",
|
||||
explanation="e1",
|
||||
question_type="short_answer",
|
||||
difficulty="easy",
|
||||
knowledge_point_ids=["kp_1"],
|
||||
)
|
||||
],
|
||||
)
|
||||
await store.create(state)
|
||||
svc = _make_service(store=store, content_client=ContentClientMock())
|
||||
@@ -166,11 +173,16 @@ class TestLessonPlanWorkflowConfirm:
|
||||
store = WorkflowStateStore(redis=None)
|
||||
state = _make_state(
|
||||
status="pending_review",
|
||||
questions=[GeneratedQuestionData(
|
||||
question="original", answer="a1", explanation="e1",
|
||||
question_type="short_answer", difficulty="easy",
|
||||
knowledge_point_ids=["kp_1"],
|
||||
)],
|
||||
questions=[
|
||||
GeneratedQuestionData(
|
||||
question="original",
|
||||
answer="a1",
|
||||
explanation="e1",
|
||||
question_type="short_answer",
|
||||
difficulty="easy",
|
||||
knowledge_point_ids=["kp_1"],
|
||||
)
|
||||
],
|
||||
)
|
||||
await store.create(state)
|
||||
|
||||
@@ -200,7 +212,7 @@ class TestLessonPlanWorkflowSteps:
|
||||
analysis = await svc._step1_analyze(state)
|
||||
assert "class_performance" in analysis
|
||||
assert analysis["class_performance"]["average_score"] == 78.5
|
||||
assert analysis["class_performance"]["student_count"] == 3
|
||||
assert analysis["class_performance"]["student_count"] == 2
|
||||
assert "weak_students" in analysis
|
||||
|
||||
async def test_step1_analyze_no_client_degraded(self) -> None:
|
||||
@@ -214,13 +226,14 @@ class TestLessonPlanWorkflowSteps:
|
||||
svc = _make_service(content_client=ContentClientMock())
|
||||
state = _make_state()
|
||||
kps = await svc._step2_recommend(state)
|
||||
assert len(kps) == 3
|
||||
assert len(kps) == 2
|
||||
assert kps[0]["id"] == "kp_001"
|
||||
|
||||
async def test_step2_recommend_no_client_fallback(self) -> None:
|
||||
svc = _make_service(content_client=None)
|
||||
state = _make_state(topic="函数")
|
||||
kps = await svc._step2_recommend(state)
|
||||
# 无 content_client 时降级到内置 3 个默认知识点
|
||||
assert len(kps) == 3
|
||||
assert "基础概念" in kps[0]["title"]
|
||||
assert "函数" in kps[0]["title"]
|
||||
@@ -233,7 +246,8 @@ class TestLessonPlanWorkflowSteps:
|
||||
)
|
||||
state = _make_state(question_count=1, target_difficulty="medium")
|
||||
questions = await svc._step3_generate(
|
||||
state, [{"id": "kp_1", "title": "KP1"}],
|
||||
state,
|
||||
[{"id": "kp_1", "title": "KP1"}],
|
||||
)
|
||||
assert len(questions) == 1
|
||||
assert questions[0].question == "什么是函数?"
|
||||
@@ -245,7 +259,8 @@ class TestLessonPlanWorkflowSteps:
|
||||
svc = _make_service(provider=provider)
|
||||
state = _make_state(question_count=1)
|
||||
questions = await svc._step3_generate(
|
||||
state, [{"id": "kp_1", "title": "KP1"}],
|
||||
state,
|
||||
[{"id": "kp_1", "title": "KP1"}],
|
||||
)
|
||||
assert len(questions) == 1
|
||||
assert questions[0].degraded is True
|
||||
|
||||
@@ -37,17 +37,24 @@ from src.ai.middleware.error_handler import (
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||
"""HTTP client wired to the FastAPI app (dev_mode=True, permissions skipped)."""
|
||||
from src.ai.main import _permission_guard, app
|
||||
"""HTTP client wired to the FastAPI app (dev_mode=True, permissions skipped).
|
||||
|
||||
注入 ContentClientMock 到 workflow_service,因为测试环境无真实 content gRPC server。
|
||||
"""
|
||||
from src.ai.clients import ContentClientMock
|
||||
from src.ai.main import _permission_guard, _workflow_service, app
|
||||
|
||||
original = _permission_guard._dev_mode
|
||||
original_content = _workflow_service._content_client # noqa: SLF001
|
||||
_permission_guard._dev_mode = True
|
||||
_workflow_service._content_client = ContentClientMock() # noqa: SLF001
|
||||
try:
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
finally:
|
||||
_permission_guard._dev_mode = original
|
||||
_workflow_service._content_client = original_content # noqa: SLF001
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -136,14 +143,14 @@ async def test_handle_unknown_error() -> None:
|
||||
async def test_grpc_error_mapper_ai_error() -> None:
|
||||
"""grpc_error_mapper maps AIError to correct gRPC status code."""
|
||||
cases = [
|
||||
(ErrorCode.AI_UNAUTHORIZED, 8), # UNAUTHENTICATED
|
||||
(ErrorCode.AI_FORBIDDEN, 7), # PERMISSION_DENIED
|
||||
(ErrorCode.AI_RATE_LIMITED, 9), # RESOURCE_EXHAUSTED
|
||||
(ErrorCode.AI_QUOTA_EXCEEDED, 9), # RESOURCE_EXHAUSTED
|
||||
(ErrorCode.AI_INVALID_MODEL, 3), # INVALID_ARGUMENT
|
||||
(ErrorCode.AI_UNAUTHORIZED, 8), # UNAUTHENTICATED
|
||||
(ErrorCode.AI_FORBIDDEN, 7), # PERMISSION_DENIED
|
||||
(ErrorCode.AI_RATE_LIMITED, 9), # RESOURCE_EXHAUSTED
|
||||
(ErrorCode.AI_QUOTA_EXCEEDED, 9), # RESOURCE_EXHAUSTED
|
||||
(ErrorCode.AI_INVALID_MODEL, 3), # INVALID_ARGUMENT
|
||||
(ErrorCode.AI_WORKFLOW_NOT_FOUND, 5), # NOT_FOUND
|
||||
(ErrorCode.AI_WORKFLOW_STATE_INVALID, 10), # FAILED_PRECONDITION
|
||||
(ErrorCode.AI_INTERNAL_ERROR, 13), # INTERNAL
|
||||
(ErrorCode.AI_INTERNAL_ERROR, 13), # INTERNAL
|
||||
]
|
||||
for code, expected_grpc_status in cases:
|
||||
exc = AIError(code, f"test {code.value}")
|
||||
@@ -390,9 +397,7 @@ async def test_confirm_lesson_plan_success(client: httpx.AsyncClient) -> None:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert status == "pending_review", (
|
||||
f"Workflow did not reach pending_review, got: {status}"
|
||||
)
|
||||
assert status == "pending_review", f"Workflow did not reach pending_review, got: {status}"
|
||||
|
||||
resp = await client.post(f"/v1/ai/lesson-plan/confirm/{workflow_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user