feat(ai): gRPC clients 扩展 + server interceptors + proto_gen + 测试 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:59:41 +08:00
parent fb23c5234e
commit 7b790f1276
21 changed files with 3540 additions and 345 deletions

View File

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

View File

@@ -0,0 +1,190 @@
# AI 模块 nextstep
> 模块aiai12 负责)| 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 FailoverChainOpenAI / 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 + runtimeDockerfile 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 + RedisP6 评估是否引入 Temporal |
| 2 | content 事件订阅 | P5 不订阅 content 事件P6+ 评估是否需要知识点变更事件驱动 |
| 3 | MockLLMProvider | 02 文档提到但未实现,测试环境用 httpx Mock 替代 |

View File

@@ -5,20 +5,59 @@
客户端:
- ContentClient: 查询知识点/教材/题库content 服务 gRPC 50054
- DataAnaClient: 查询学情/薄弱点/趋势data-ana 服务 gRPC 50055
- IamClient: 查询 DataScopeiam 服务 gRPC 50052P4 补全后启用
- IamClient: 查询 DataScopeiam 服务 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",
]

View File

@@ -1,20 +1,22 @@
"""Content 服务 gRPC 客户端.
用于:
- 查询知识点前置依赖GetPrerequisites
- 查询学习路径GetLearningPath
- 创建题目入库(CreateQuestions - P5 mockcontent 服务待补全
- 查询知识点前置依赖(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="知识点1mock"),
KnowledgePoint(id="kp_002", title="知识点2mock"),
KnowledgePoint(id="kp_003", title="知识点3mock"),
]
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)

View File

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

View File

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

View File

@@ -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 提取 UserContextAuthInterceptor 注入)."""
return getattr(context, "user_context", UserContext())

View File

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

View File

@@ -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,
)
# 下游客户端(全并行模式用 MockISSUE-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")

View File

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

File diff suppressed because one or more lines are too long

View 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 端口 50055HTTP 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 端口 50055HTTP 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-streamingP5+ 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 端口 50055HTTP 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)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View 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.16REST 供 gateway 透传 + admin-portal 直连,
gRPC 供 BFF 聚合调用。同一 Application Service 同时被两种 Controller 调用。
gRPC 端口 50052P2 即启用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.16REST 供 gateway 透传 + admin-portal 直连,
gRPC 供 BFF 聚合调用。同一 Application Service 同时被两种 Controller 调用。
gRPC 端口 50052P2 即启用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.16REST 供 gateway 透传 + admin-portal 直连,
gRPC 供 BFF 聚合调用。同一 Application Service 同时被两种 Controller 调用。
gRPC 端口 50052P2 即启用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)

View File

@@ -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:
"""批量创建题目."""

View File

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

View File

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

View File

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