feat(ai): v2 新增 GenerateReport RPC + ReportService

第 9 个 RPC GenerateReport(学情报告生成):data-ana 学情数据 → LLM 生成 → 结构化提取

新增 ReportService 业务编排层 + GenerateReportRequest/GeneratedReport 模型

gRPC servicer + HTTP POST /v1/ai/generate/report(权限 ai:report:generate)

proto_gen 重新生成 + 测试覆盖(servicer/service/HTTP/模型/权限 共 26 用例)

402 测试通过,覆盖率 88.5%
This commit is contained in:
SpecialX
2026-07-14 22:57:57 +08:00
parent 843b370b3d
commit aac26c7c6f
21 changed files with 1954 additions and 278 deletions

View File

@@ -65,6 +65,7 @@ def create_grpc_server(
question_service: Any = None,
expression_service: Any = None,
workflow_service: Any = None,
report_service: Any = None,
) -> GrpcServer:
"""创建 gRPC server工厂函数."""
servicer = AiServicer(
@@ -72,5 +73,6 @@ def create_grpc_server(
question_service=question_service,
expression_service=expression_service,
workflow_service=workflow_service,
report_service=report_service,
)
return GrpcServer(port=port, servicer=servicer)

View File

@@ -1,4 +1,4 @@
"""AiService gRPC Servicer8 RPC 实现).
"""AiService gRPC Servicer9 RPC 实现).
所有 RPC 返回 protobuf message降级采用方案 Bdegraded 字段在 message 内)。
业务逻辑由注入的 service 层处理servicer 仅做 proto ↔ domain 模型转换。
@@ -25,6 +25,7 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
- question_service: QuestionServiceGenerateQuestion / StreamGenerateQuestion
- expression_service: ExpressionServiceOptimizeExpression
- workflow_service: LessonPlanWorkflowService备课工作流
- report_service: ReportService学情报告
"""
def __init__(
@@ -33,11 +34,13 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
question_service: Any = None,
expression_service: Any = None,
workflow_service: Any = None,
report_service: Any = None,
) -> None:
self._chat_service = chat_service
self._question_service = question_service
self._expression_service = expression_service
self._workflow_service = workflow_service
self._report_service = report_service
async def Chat( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
@@ -46,10 +49,7 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
) -> ai_pb2.ChatResponse:
"""非流式聊天."""
ctx = get_user_context(context)
messages = [
{"role": m.role, "content": m.content}
for m in request.messages
]
messages = [{"role": m.role, "content": m.content} for m in request.messages]
try:
if self._chat_service is None:
return _degraded_chat_response(request.model, "chat_service not initialized")
@@ -88,10 +88,7 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
) -> AsyncGenerator[ai_pb2.ChatChunk, None]:
"""流式聊天SSE over gRPC."""
ctx = get_user_context(context)
messages = [
{"role": m.role, "content": m.content}
for m in request.messages
]
messages = [{"role": m.role, "content": m.content} for m in request.messages]
try:
if self._chat_service is None:
yield ai_pb2.ChatChunk(content="[degraded] chat_service not initialized", done=True)
@@ -322,6 +319,43 @@ class AiServicer(ai_pb2_grpc.AiServiceServicer):
logger.error("confirm_lesson_plan_rpc_error", error=str(exc))
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
async def GenerateReport( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
request: ai_pb2.GenerateReportRequest,
context: grpc.ServicerContext,
) -> ai_pb2.GeneratedReport:
"""生成学情报告."""
ctx = get_user_context(context)
try:
if self._report_service is None:
return ai_pb2.GeneratedReport(
id="",
content="",
summary="",
degraded=True,
degraded_reason="report_service not initialized",
)
result = await self._report_service.generate(
class_id=request.class_id,
report_type=request.report_type,
student_id=request.student_id if request.HasField("student_id") else None,
user_id=request.user_id or ctx.user_id,
data_scope=request.data_scope or ctx.data_scope,
)
return ai_pb2.GeneratedReport(
id=result.id,
content=result.content,
summary=result.summary,
recommendations=result.recommendations,
degraded=result.degraded,
degraded_reason=result.degraded_reason,
)
except AIError:
raise
except Exception as exc: # noqa: BLE001
logger.error("generate_report_rpc_error", error=str(exc))
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
def _degraded_chat_response(model: str, reason: str) -> ai_pb2.ChatResponse:
"""构建降级聊天响应."""

View File

@@ -1,8 +1,8 @@
"""AI 网关服务入口.
整合组件02-architecture-design.md §1.2 完整分层):
- HTTP 端点(/v1/ai 前缀ActionState 信封10 端点)
- gRPC server端口 500588 RPC
- HTTP 端点(/v1/ai 前缀ActionState 信封11 端点)
- gRPC server端口 500589 RPC
- LLM Provider FailoverChain4 适配器 + 熔断 + 故障切换)
- Prompt 模板服务Jinja2 + YAML
- 评估三道防线RuleValidator + LLMJudge + QualityGate
@@ -10,6 +10,7 @@
- 安全层PII + 输入清洗 + 输出审核)
- 下游 gRPC 客户端content/data-ana/iam真实 gRPC 调用)
- 备课工作流4 步编排 + Redis 状态存储)
- 学情报告class_summary / student_detail / exam_analysisdata-ana 数据源)
- 限流Redis 三维度令牌桶)
- OpenTelemetry + Prometheus
"""
@@ -44,6 +45,7 @@ from .middleware.permission import (
PERMISSION_AI_EXPRESSION_OPTIMIZE,
PERMISSION_AI_LESSON_GENERATE,
PERMISSION_AI_QUESTION_GENERATE,
PERMISSION_AI_REPORT_GENERATE,
)
from .models import (
ChatRequest,
@@ -53,6 +55,8 @@ from .models import (
ConfirmResultResponse,
GeneratedQuestionResponse,
GenerateQuestionRequest,
GenerateReportRequest,
GenerateReportResponse,
LessonPreparationData,
LessonPreparationRequest,
LessonPreparationResponse,
@@ -64,7 +68,7 @@ from .models import (
from .prompt_service import PromptTemplateService
from .providers import create_failover_chain
from .rate_limiter import RateLimiter
from .services import ChatService, ExpressionService, QuestionService
from .services import ChatService, ExpressionService, QuestionService, ReportService
from .services.evaluation import QualityGate, RuleValidator
from .usage import KafkaProducer, QuotaEnforcer, UsageRecorder
from .workflow import LessonPlanWorkflowService, WorkflowStateStore
@@ -136,6 +140,14 @@ _content_client = ContentClientGrpc(endpoint=settings.content_grpc_endpoint)
_data_ana_client = DataAnaClientGrpc(endpoint=settings.data_ana_grpc_endpoint)
_iam_client = IamClientGrpc(endpoint=settings.iam_grpc_endpoint)
# ReportService 依赖 _data_ana_client必须在客户端实例化后创建
_report_service = ReportService(
failover_chain=_failover_chain,
prompt_service=_prompt_service,
data_ana_client=_data_ana_client,
default_model=settings.default_chat_model,
)
_state_store = WorkflowStateStore(
redis=None,
ttl_seconds=settings.workflow_ttl_seconds,
@@ -156,6 +168,7 @@ _grpc_server = create_grpc_server(
question_service=_question_service,
expression_service=_expression_service,
workflow_service=_workflow_service,
report_service=_report_service,
)
_redis: Redis | None = None
@@ -479,4 +492,37 @@ async def confirm_lesson_plan(
return ConfirmResultResponse(success=True, data=data, error=None)
@router.post("/generate/report", response_model=GenerateReportResponse)
async def generate_report(
req: GenerateReportRequest,
request: Request,
) -> GenerateReportResponse:
"""生成学情报告.
支持三种报告类型:
- class_summary: 班级学情总结
- student_detail: 单个学生学情详情(需 student_id
- exam_analysis: 考试分析
数据来源data-ana 服务(班级学情 / 学生薄弱点 / 学习趋势)。
LLM 不可用时返回 degraded 响应degraded=true
"""
ctx = extract_user_context(request)
_permission_guard.check(ctx, PERMISSION_AI_REPORT_GENERATE)
await _rate_limiter.check(
user_id=ctx.user_id,
ip=_client_ip(request),
school_id=ctx.school_id,
)
with tracer.start_as_current_span("generate_report"):
result = await _report_service.generate(
class_id=req.class_id,
report_type=req.report_type,
student_id=req.student_id,
user_id=ctx.user_id,
data_scope=req.data_scope or ctx.data_scope,
)
return GenerateReportResponse(success=True, data=result, error=None)
app.include_router(router)

View File

@@ -6,6 +6,7 @@ ai 服务的权限点(对齐 004 Permissions 常量):
- ai:expression:optimize: 优化表达
- ai:lesson:generate: 生成教案
- ai:lesson:confirm: 确认教案
- ai:report:generate: 生成学情报告
全并行模式dev_mode=true 时跳过权限校验,仅记录警告。
"""
@@ -27,6 +28,7 @@ PERMISSION_AI_QUESTION_GENERATE = "ai:question:generate"
PERMISSION_AI_EXPRESSION_OPTIMIZE = "ai:expression:optimize"
PERMISSION_AI_LESSON_GENERATE = "ai:lesson:generate"
PERMISSION_AI_LESSON_CONFIRM = "ai:lesson:confirm"
PERMISSION_AI_REPORT_GENERATE = "ai:report:generate"
# 角色 → 权限映射简化版P6 迁移到 iam 动态权限)
ROLE_PERMISSIONS: dict[str, set[str]] = {
@@ -36,6 +38,7 @@ ROLE_PERMISSIONS: dict[str, set[str]] = {
PERMISSION_AI_EXPRESSION_OPTIMIZE,
PERMISSION_AI_LESSON_GENERATE,
PERMISSION_AI_LESSON_CONFIRM,
PERMISSION_AI_REPORT_GENERATE,
},
"admin": {
PERMISSION_AI_CHAT,
@@ -43,6 +46,7 @@ ROLE_PERMISSIONS: dict[str, set[str]] = {
PERMISSION_AI_EXPRESSION_OPTIMIZE,
PERMISSION_AI_LESSON_GENERATE,
PERMISSION_AI_LESSON_CONFIRM,
PERMISSION_AI_REPORT_GENERATE,
},
"student": {
PERMISSION_AI_CHAT,

View File

@@ -14,6 +14,12 @@ from .question import (
GenerateQuestionRequest,
QuestionType,
)
from .report import (
GeneratedReportData,
GenerateReportRequest,
GenerateReportResponse,
ReportType,
)
from .workflow import (
ConfirmRequest,
ConfirmResultData,
@@ -39,6 +45,10 @@ __all__ = [
"GeneratedQuestionData",
"GeneratedQuestionResponse",
"QuestionType",
"GenerateReportRequest",
"GenerateReportResponse",
"GeneratedReportData",
"ReportType",
"OptimizeExpressionRequest",
"OptimizedExpressionData",
"OptimizeExpressionResponse",

View File

@@ -0,0 +1,37 @@
"""学情报告模型."""
from typing import Literal
from pydantic import BaseModel, Field
from .action_state import ActionState
ReportType = Literal["class_summary", "student_detail", "exam_analysis"]
class GenerateReportRequest(BaseModel):
"""学情报告生成请求."""
class_id: str = Field(..., description="班级 ID")
report_type: ReportType = Field(
"class_summary",
description="报告类型class_summary / student_detail / exam_analysis",
)
student_id: str | None = Field(None, description="学生 IDstudent_detail 必填)")
user_id: str | None = None
data_scope: str | None = None
class GeneratedReportData(BaseModel):
"""学情报告数据(含降级标记)."""
id: str
content: str
summary: str
recommendations: list[str] = Field(default_factory=list)
degraded: bool = False
degraded_reason: str = ""
class GenerateReportResponse(ActionState[GeneratedReportData]):
"""学情报告响应信封."""

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
import grpc
import warnings
from . import ai_pb2 as ai__pb2
import ai_pb2 as ai__pb2
GRPC_GENERATED_VERSION = '1.82.1'
GRPC_VERSION = grpc.__version__
@@ -78,6 +78,11 @@ class AiServiceStub:
request_serializer=ai__pb2.ConfirmLessonPlanRequest.SerializeToString,
response_deserializer=ai__pb2.ConfirmResult.FromString,
_registered_method=True)
self.GenerateReport = channel.unary_unary(
'/next_edu_cloud.ai.v1.AiService/GenerateReport',
request_serializer=ai__pb2.GenerateReportRequest.SerializeToString,
response_deserializer=ai__pb2.GeneratedReport.FromString,
_registered_method=True)
class AiServiceServicer:
@@ -143,6 +148,13 @@ class AiServiceServicer:
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GenerateReport(self, request, context):
"""生成学情报告(班级总结 / 学生详情 / 考试分析)
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_AiServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -186,6 +198,11 @@ def add_AiServiceServicer_to_server(servicer, server):
request_deserializer=ai__pb2.ConfirmLessonPlanRequest.FromString,
response_serializer=ai__pb2.ConfirmResult.SerializeToString,
),
'GenerateReport': grpc.unary_unary_rpc_method_handler(
servicer.GenerateReport,
request_deserializer=ai__pb2.GenerateReportRequest.FromString,
response_serializer=ai__pb2.GeneratedReport.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'next_edu_cloud.ai.v1.AiService', rpc_method_handlers)
@@ -416,3 +433,30 @@ class AiService:
timeout,
metadata,
_registered_method=True)
@staticmethod
def GenerateReport(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.ai.v1.AiService/GenerateReport',
ai__pb2.GenerateReportRequest.SerializeToString,
ai__pb2.GeneratedReport.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)

File diff suppressed because one or more lines are too long

View File

@@ -532,6 +532,11 @@ class KnowledgeGraphServiceStub:
request_serializer=content__pb2.GetLearningPathRequest.SerializeToString,
response_deserializer=content__pb2.LearningPath.FromString,
_registered_method=True)
self.GetKnowledgePath = channel.unary_unary(
'/next_edu_cloud.content.v1.KnowledgeGraphService/GetKnowledgePath',
request_serializer=content__pb2.GetKnowledgePathRequest.SerializeToString,
response_deserializer=content__pb2.LearningPath.FromString,
_registered_method=True)
self.AddPrerequisite = channel.unary_unary(
'/next_edu_cloud.content.v1.KnowledgeGraphService/AddPrerequisite',
request_serializer=content__pb2.AddPrerequisiteRequest.SerializeToString,
@@ -559,6 +564,12 @@ class KnowledgeGraphServiceServicer:
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetKnowledgePath(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def AddPrerequisite(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
@@ -584,6 +595,11 @@ def add_KnowledgeGraphServiceServicer_to_server(servicer, server):
request_deserializer=content__pb2.GetLearningPathRequest.FromString,
response_serializer=content__pb2.LearningPath.SerializeToString,
),
'GetKnowledgePath': grpc.unary_unary_rpc_method_handler(
servicer.GetKnowledgePath,
request_deserializer=content__pb2.GetKnowledgePathRequest.FromString,
response_serializer=content__pb2.LearningPath.SerializeToString,
),
'AddPrerequisite': grpc.unary_unary_rpc_method_handler(
servicer.AddPrerequisite,
request_deserializer=content__pb2.AddPrerequisiteRequest.FromString,
@@ -659,6 +675,33 @@ class KnowledgeGraphService:
metadata,
_registered_method=True)
@staticmethod
def GetKnowledgePath(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.KnowledgeGraphService/GetKnowledgePath',
content__pb2.GetKnowledgePathRequest.SerializeToString,
content__pb2.LearningPath.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def AddPrerequisite(request,
target,
@@ -1085,3 +1128,477 @@ class QuestionService:
timeout,
metadata,
_registered_method=True)
class ElectiveServiceStub:
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.ListAvailableElectiveCourses = channel.unary_unary(
'/next_edu_cloud.content.v1.ElectiveService/ListAvailableElectiveCourses',
request_serializer=content__pb2.ListAvailableElectiveCoursesRequest.SerializeToString,
response_deserializer=content__pb2.ListElectiveCoursesResponse.FromString,
_registered_method=True)
self.ListElectiveSelectionsByStudent = channel.unary_unary(
'/next_edu_cloud.content.v1.ElectiveService/ListElectiveSelectionsByStudent',
request_serializer=content__pb2.ListElectiveSelectionsByStudentRequest.SerializeToString,
response_deserializer=content__pb2.ListElectiveSelectionsResponse.FromString,
_registered_method=True)
self.SelectCourse = channel.unary_unary(
'/next_edu_cloud.content.v1.ElectiveService/SelectCourse',
request_serializer=content__pb2.SelectCourseRequest.SerializeToString,
response_deserializer=content__pb2.ElectiveSelection.FromString,
_registered_method=True)
self.DropCourse = channel.unary_unary(
'/next_edu_cloud.content.v1.ElectiveService/DropCourse',
request_serializer=content__pb2.DropCourseRequest.SerializeToString,
response_deserializer=content__pb2.Empty.FromString,
_registered_method=True)
class ElectiveServiceServicer:
"""Missing associated documentation comment in .proto file."""
def ListAvailableElectiveCourses(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ListElectiveSelectionsByStudent(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def SelectCourse(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def DropCourse(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_ElectiveServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'ListAvailableElectiveCourses': grpc.unary_unary_rpc_method_handler(
servicer.ListAvailableElectiveCourses,
request_deserializer=content__pb2.ListAvailableElectiveCoursesRequest.FromString,
response_serializer=content__pb2.ListElectiveCoursesResponse.SerializeToString,
),
'ListElectiveSelectionsByStudent': grpc.unary_unary_rpc_method_handler(
servicer.ListElectiveSelectionsByStudent,
request_deserializer=content__pb2.ListElectiveSelectionsByStudentRequest.FromString,
response_serializer=content__pb2.ListElectiveSelectionsResponse.SerializeToString,
),
'SelectCourse': grpc.unary_unary_rpc_method_handler(
servicer.SelectCourse,
request_deserializer=content__pb2.SelectCourseRequest.FromString,
response_serializer=content__pb2.ElectiveSelection.SerializeToString,
),
'DropCourse': grpc.unary_unary_rpc_method_handler(
servicer.DropCourse,
request_deserializer=content__pb2.DropCourseRequest.FromString,
response_serializer=content__pb2.Empty.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'next_edu_cloud.content.v1.ElectiveService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('next_edu_cloud.content.v1.ElectiveService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class ElectiveService:
"""Missing associated documentation comment in .proto file."""
@staticmethod
def ListAvailableElectiveCourses(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.ElectiveService/ListAvailableElectiveCourses',
content__pb2.ListAvailableElectiveCoursesRequest.SerializeToString,
content__pb2.ListElectiveCoursesResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def ListElectiveSelectionsByStudent(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.ElectiveService/ListElectiveSelectionsByStudent',
content__pb2.ListElectiveSelectionsByStudentRequest.SerializeToString,
content__pb2.ListElectiveSelectionsResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def SelectCourse(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.ElectiveService/SelectCourse',
content__pb2.SelectCourseRequest.SerializeToString,
content__pb2.ElectiveSelection.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def DropCourse(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.ElectiveService/DropCourse',
content__pb2.DropCourseRequest.SerializeToString,
content__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
class LessonPlanServiceStub:
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.ListLessonPlansByTeacher = channel.unary_unary(
'/next_edu_cloud.content.v1.LessonPlanService/ListLessonPlansByTeacher',
request_serializer=content__pb2.ListLessonPlansByTeacherRequest.SerializeToString,
response_deserializer=content__pb2.ListLessonPlansResponse.FromString,
_registered_method=True)
self.ListLessonPlansByStudent = channel.unary_unary(
'/next_edu_cloud.content.v1.LessonPlanService/ListLessonPlansByStudent',
request_serializer=content__pb2.ListLessonPlansByStudentRequest.SerializeToString,
response_deserializer=content__pb2.ListLessonPlansResponse.FromString,
_registered_method=True)
self.GetLessonPlan = channel.unary_unary(
'/next_edu_cloud.content.v1.LessonPlanService/GetLessonPlan',
request_serializer=content__pb2.GetLessonPlanRequest.SerializeToString,
response_deserializer=content__pb2.LessonPlan.FromString,
_registered_method=True)
class LessonPlanServiceServicer:
"""Missing associated documentation comment in .proto file."""
def ListLessonPlansByTeacher(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ListLessonPlansByStudent(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetLessonPlan(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_LessonPlanServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'ListLessonPlansByTeacher': grpc.unary_unary_rpc_method_handler(
servicer.ListLessonPlansByTeacher,
request_deserializer=content__pb2.ListLessonPlansByTeacherRequest.FromString,
response_serializer=content__pb2.ListLessonPlansResponse.SerializeToString,
),
'ListLessonPlansByStudent': grpc.unary_unary_rpc_method_handler(
servicer.ListLessonPlansByStudent,
request_deserializer=content__pb2.ListLessonPlansByStudentRequest.FromString,
response_serializer=content__pb2.ListLessonPlansResponse.SerializeToString,
),
'GetLessonPlan': grpc.unary_unary_rpc_method_handler(
servicer.GetLessonPlan,
request_deserializer=content__pb2.GetLessonPlanRequest.FromString,
response_serializer=content__pb2.LessonPlan.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'next_edu_cloud.content.v1.LessonPlanService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('next_edu_cloud.content.v1.LessonPlanService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class LessonPlanService:
"""Missing associated documentation comment in .proto file."""
@staticmethod
def ListLessonPlansByTeacher(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.LessonPlanService/ListLessonPlansByTeacher',
content__pb2.ListLessonPlansByTeacherRequest.SerializeToString,
content__pb2.ListLessonPlansResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def ListLessonPlansByStudent(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.LessonPlanService/ListLessonPlansByStudent',
content__pb2.ListLessonPlansByStudentRequest.SerializeToString,
content__pb2.ListLessonPlansResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetLessonPlan(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.LessonPlanService/GetLessonPlan',
content__pb2.GetLessonPlanRequest.SerializeToString,
content__pb2.LessonPlan.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
class CoursePlanServiceStub:
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.ListCoursePlansByStudent = channel.unary_unary(
'/next_edu_cloud.content.v1.CoursePlanService/ListCoursePlansByStudent',
request_serializer=content__pb2.ListCoursePlansByStudentRequest.SerializeToString,
response_deserializer=content__pb2.ListCoursePlansResponse.FromString,
_registered_method=True)
self.GetCoursePlan = channel.unary_unary(
'/next_edu_cloud.content.v1.CoursePlanService/GetCoursePlan',
request_serializer=content__pb2.GetCoursePlanRequest.SerializeToString,
response_deserializer=content__pb2.CoursePlan.FromString,
_registered_method=True)
class CoursePlanServiceServicer:
"""Missing associated documentation comment in .proto file."""
def ListCoursePlansByStudent(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetCoursePlan(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_CoursePlanServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'ListCoursePlansByStudent': grpc.unary_unary_rpc_method_handler(
servicer.ListCoursePlansByStudent,
request_deserializer=content__pb2.ListCoursePlansByStudentRequest.FromString,
response_serializer=content__pb2.ListCoursePlansResponse.SerializeToString,
),
'GetCoursePlan': grpc.unary_unary_rpc_method_handler(
servicer.GetCoursePlan,
request_deserializer=content__pb2.GetCoursePlanRequest.FromString,
response_serializer=content__pb2.CoursePlan.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'next_edu_cloud.content.v1.CoursePlanService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('next_edu_cloud.content.v1.CoursePlanService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class CoursePlanService:
"""Missing associated documentation comment in .proto file."""
@staticmethod
def ListCoursePlansByStudent(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.CoursePlanService/ListCoursePlansByStudent',
content__pb2.ListCoursePlansByStudentRequest.SerializeToString,
content__pb2.ListCoursePlansResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetCoursePlan(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.content.v1.CoursePlanService/GetCoursePlan',
content__pb2.GetCoursePlanRequest.SerializeToString,
content__pb2.CoursePlan.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)

File diff suppressed because one or more lines are too long

View File

@@ -63,26 +63,16 @@ class IamServiceStub:
request_serializer=iam__pb2.GetUserInfoRequest.SerializeToString,
response_deserializer=iam__pb2.UserInfo.FromString,
_registered_method=True)
self.GetUserProfile = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/GetUserProfile',
request_serializer=iam__pb2.GetUserProfileRequest.SerializeToString,
response_deserializer=iam__pb2.UserInfo.FromString,
_registered_method=True)
self.UpdateProfile = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/UpdateProfile',
request_serializer=iam__pb2.UpdateProfileRequest.SerializeToString,
response_deserializer=iam__pb2.UserInfo.FromString,
_registered_method=True)
self.ChangePassword = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/ChangePassword',
request_serializer=iam__pb2.ChangePasswordRequest.SerializeToString,
response_deserializer=iam__pb2.ChangePasswordResponse.FromString,
_registered_method=True)
self.BatchGetUsers = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/BatchGetUsers',
request_serializer=iam__pb2.BatchGetUsersRequest.SerializeToString,
response_deserializer=iam__pb2.BatchGetUsersResponse.FromString,
_registered_method=True)
self.GetEffectiveDataScope = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/GetEffectiveDataScope',
request_serializer=iam__pb2.GetEffectiveDataScopeRequest.SerializeToString,
response_deserializer=iam__pb2.EffectiveDataScope.FromString,
_registered_method=True)
self.GetEffectivePermissions = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/GetEffectivePermissions',
request_serializer=iam__pb2.GetEffectivePermissionsRequest.SerializeToString,
@@ -93,11 +83,6 @@ class IamServiceStub:
request_serializer=iam__pb2.GetEffectiveAccessRequest.SerializeToString,
response_deserializer=iam__pb2.EffectiveAccessResponse.FromString,
_registered_method=True)
self.GetEffectiveDataScope = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/GetEffectiveDataScope',
request_serializer=iam__pb2.GetEffectiveDataScopeRequest.SerializeToString,
response_deserializer=iam__pb2.EffectiveDataScope.FromString,
_registered_method=True)
self.GetViewports = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/GetViewports',
request_serializer=iam__pb2.GetViewportsRequest.SerializeToString,
@@ -113,6 +98,21 @@ class IamServiceStub:
request_serializer=iam__pb2.GetChildrenByParentRequest.SerializeToString,
response_deserializer=iam__pb2.ChildrenResponse.FromString,
_registered_method=True)
self.CreateUser = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/CreateUser',
request_serializer=iam__pb2.CreateUserRequest.SerializeToString,
response_deserializer=iam__pb2.UserInfo.FromString,
_registered_method=True)
self.UpdateUser = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/UpdateUser',
request_serializer=iam__pb2.UpdateUserRequest.SerializeToString,
response_deserializer=iam__pb2.UserInfo.FromString,
_registered_method=True)
self.DeleteUser = channel.unary_unary(
'/next_edu_cloud.iam.v1.IamService/DeleteUser',
request_serializer=iam__pb2.DeleteUserRequest.SerializeToString,
response_deserializer=iam__pb2.DeleteUserResponse.FromString,
_registered_method=True)
class IamServiceServicer:
@@ -154,32 +154,20 @@ class IamServiceServicer:
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetUserProfile(self, request, context):
"""GetUserProfile 是 GetUserInfo 的语义别名student-bff 期望的命名).
返回结构与 GetUserInfo 完全相同,仅 RPC 名不同以兼容下游契约.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def UpdateProfile(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ChangePassword(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def BatchGetUsers(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetEffectiveDataScope(self, request, context):
"""GetEffectiveDataScope 解析用户可见数据范围DataScope 6 级).
data-ana gRPC 调用此 RPC 解析查询过滤范围coord-cross-review §2 #3 裁决 P4 补全).
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetEffectivePermissions(self, request, context):
"""权限与视口类
"""
@@ -193,14 +181,6 @@ class IamServiceServicer:
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetEffectiveDataScope(self, request, context):
"""GetEffectiveDataScope 解析用户可见数据范围DataScope 6 级).
data-ana gRPC 调用此 RPC 解析查询过滤范围coord-cross-review §2 #3 裁决 P4 补全).
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetViewports(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
@@ -220,6 +200,25 @@ class IamServiceServicer:
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def CreateUser(self, request, context):
"""管理员用户管理类admin-portal §2.3 P1 阻塞项补齐)
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def UpdateUser(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def DeleteUser(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_IamServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -248,26 +247,16 @@ def add_IamServiceServicer_to_server(servicer, server):
request_deserializer=iam__pb2.GetUserInfoRequest.FromString,
response_serializer=iam__pb2.UserInfo.SerializeToString,
),
'GetUserProfile': grpc.unary_unary_rpc_method_handler(
servicer.GetUserProfile,
request_deserializer=iam__pb2.GetUserProfileRequest.FromString,
response_serializer=iam__pb2.UserInfo.SerializeToString,
),
'UpdateProfile': grpc.unary_unary_rpc_method_handler(
servicer.UpdateProfile,
request_deserializer=iam__pb2.UpdateProfileRequest.FromString,
response_serializer=iam__pb2.UserInfo.SerializeToString,
),
'ChangePassword': grpc.unary_unary_rpc_method_handler(
servicer.ChangePassword,
request_deserializer=iam__pb2.ChangePasswordRequest.FromString,
response_serializer=iam__pb2.ChangePasswordResponse.SerializeToString,
),
'BatchGetUsers': grpc.unary_unary_rpc_method_handler(
servicer.BatchGetUsers,
request_deserializer=iam__pb2.BatchGetUsersRequest.FromString,
response_serializer=iam__pb2.BatchGetUsersResponse.SerializeToString,
),
'GetEffectiveDataScope': grpc.unary_unary_rpc_method_handler(
servicer.GetEffectiveDataScope,
request_deserializer=iam__pb2.GetEffectiveDataScopeRequest.FromString,
response_serializer=iam__pb2.EffectiveDataScope.SerializeToString,
),
'GetEffectivePermissions': grpc.unary_unary_rpc_method_handler(
servicer.GetEffectivePermissions,
request_deserializer=iam__pb2.GetEffectivePermissionsRequest.FromString,
@@ -278,11 +267,6 @@ def add_IamServiceServicer_to_server(servicer, server):
request_deserializer=iam__pb2.GetEffectiveAccessRequest.FromString,
response_serializer=iam__pb2.EffectiveAccessResponse.SerializeToString,
),
'GetEffectiveDataScope': grpc.unary_unary_rpc_method_handler(
servicer.GetEffectiveDataScope,
request_deserializer=iam__pb2.GetEffectiveDataScopeRequest.FromString,
response_serializer=iam__pb2.EffectiveDataScope.SerializeToString,
),
'GetViewports': grpc.unary_unary_rpc_method_handler(
servicer.GetViewports,
request_deserializer=iam__pb2.GetViewportsRequest.FromString,
@@ -298,6 +282,21 @@ def add_IamServiceServicer_to_server(servicer, server):
request_deserializer=iam__pb2.GetChildrenByParentRequest.FromString,
response_serializer=iam__pb2.ChildrenResponse.SerializeToString,
),
'CreateUser': grpc.unary_unary_rpc_method_handler(
servicer.CreateUser,
request_deserializer=iam__pb2.CreateUserRequest.FromString,
response_serializer=iam__pb2.UserInfo.SerializeToString,
),
'UpdateUser': grpc.unary_unary_rpc_method_handler(
servicer.UpdateUser,
request_deserializer=iam__pb2.UpdateUserRequest.FromString,
response_serializer=iam__pb2.UserInfo.SerializeToString,
),
'DeleteUser': grpc.unary_unary_rpc_method_handler(
servicer.DeleteUser,
request_deserializer=iam__pb2.DeleteUserRequest.FromString,
response_serializer=iam__pb2.DeleteUserResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'next_edu_cloud.iam.v1.IamService', rpc_method_handlers)
@@ -448,87 +447,6 @@ class IamService:
metadata,
_registered_method=True)
@staticmethod
def GetUserProfile(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.iam.v1.IamService/GetUserProfile',
iam__pb2.GetUserProfileRequest.SerializeToString,
iam__pb2.UserInfo.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def UpdateProfile(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.iam.v1.IamService/UpdateProfile',
iam__pb2.UpdateProfileRequest.SerializeToString,
iam__pb2.UserInfo.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def ChangePassword(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.iam.v1.IamService/ChangePassword',
iam__pb2.ChangePasswordRequest.SerializeToString,
iam__pb2.ChangePasswordResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def BatchGetUsers(request,
target,
@@ -556,6 +474,33 @@ class IamService:
metadata,
_registered_method=True)
@staticmethod
def GetEffectiveDataScope(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.iam.v1.IamService/GetEffectiveDataScope',
iam__pb2.GetEffectiveDataScopeRequest.SerializeToString,
iam__pb2.EffectiveDataScope.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetEffectivePermissions(request,
target,
@@ -610,33 +555,6 @@ class IamService:
metadata,
_registered_method=True)
@staticmethod
def GetEffectiveDataScope(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.iam.v1.IamService/GetEffectiveDataScope',
iam__pb2.GetEffectiveDataScopeRequest.SerializeToString,
iam__pb2.EffectiveDataScope.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetViewports(request,
target,
@@ -717,3 +635,84 @@ class IamService:
timeout,
metadata,
_registered_method=True)
@staticmethod
def CreateUser(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.iam.v1.IamService/CreateUser',
iam__pb2.CreateUserRequest.SerializeToString,
iam__pb2.UserInfo.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def UpdateUser(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.iam.v1.IamService/UpdateUser',
iam__pb2.UpdateUserRequest.SerializeToString,
iam__pb2.UserInfo.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def DeleteUser(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.iam.v1.IamService/DeleteUser',
iam__pb2.DeleteUserRequest.SerializeToString,
iam__pb2.DeleteUserResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)

View File

@@ -4,15 +4,18 @@
- ChatService: 聊天(非流式 + 流式)
- QuestionService: 题目生成(非流式 + 流式 + 评估三道防线)
- ExpressionService: 表达优化
- ReportService: 学情报告生成class_summary / student_detail / exam_analysis
- LessonPlanWorkflowService: 备课工作流M16-2 实现)
"""
from .chat_service import ChatService
from .expression_service import ExpressionService
from .question_service import QuestionService
from .report_service import ReportService
__all__ = [
"ChatService",
"QuestionService",
"ExpressionService",
"ReportService",
]

View File

@@ -0,0 +1,219 @@
"""学情报告服务.
编排 LLM Provider FailoverChain + data-ana 学情数据 + Prompt 模板。
支持 3 种报告类型class_summary / student_detail / exam_analysis。
"""
import json
import time
from typing import Any
import structlog
from ..clients.data_ana_client import DataAnaClient
from ..errors import AILLMUnavailableError
from ..models.report import GeneratedReportData, ReportType
from ..prompt_service import PromptTemplateService
from ..providers import ProviderFailoverChain
logger = structlog.get_logger()
class ReportService:
"""学情报告生成服务."""
def __init__(
self,
failover_chain: ProviderFailoverChain,
prompt_service: PromptTemplateService | None = None,
data_ana_client: DataAnaClient | None = None,
default_model: str = "gpt-4o-mini",
) -> None:
self._chain = failover_chain
self._prompts = prompt_service
self._data_ana = data_ana_client
self._default_model = default_model
async def generate(
self,
class_id: str,
report_type: ReportType,
student_id: str | None = None,
user_id: str = "",
data_scope: str = "",
) -> GeneratedReportData:
"""生成学情报告.
流程1) 从 data-ana 拉学情 → 2) 组装 prompt → 3) LLM 生成报告。
Returns:
GeneratedReportData含降级标记
"""
report_id = f"report-{int(time.time() * 1000)}"
# 1. 收集学情上下文data-ana 不可用时降级为空上下文)
context = await self._collect_context(class_id, report_type, student_id)
# 2. 组装 prompt
prompt = self._build_prompt(report_type, context)
# 3. 调用 LLM 生成报告
try:
response = await self._chain.chat(
messages=[
{"role": "system", "content": self._system_prompt(report_type)},
{"role": "user", "content": prompt},
],
model=self._default_model,
temperature=0.3,
)
content = response.content
summary = self._extract_summary(content)
recommendations = self._extract_recommendations(content)
return GeneratedReportData(
id=report_id,
content=content,
summary=summary,
recommendations=recommendations,
degraded=False,
degraded_reason="",
)
except AILLMUnavailableError as exc:
logger.warning("report_degraded_llm", reason=str(exc), report_type=report_type)
return GeneratedReportData(
id=report_id,
content="",
summary="",
recommendations=[],
degraded=True,
degraded_reason=f"LLM unavailable: {exc}",
)
async def _collect_context(
self,
class_id: str,
report_type: ReportType,
student_id: str | None,
) -> dict[str, Any]:
"""从 data-ana 收集学情上下文(不可用时返回降级上下文)."""
context: dict[str, Any] = {
"class_id": class_id,
"report_type": report_type,
"student_id": student_id or "",
}
if self._data_ana is None or not self._data_ana.is_available():
context["degraded"] = True
context["degraded_reason"] = "data-ana client not configured"
return context
try:
perf = await self._data_ana.get_class_performance(class_id, "")
context["average_score"] = perf.average_score
context["pass_rate"] = perf.pass_rate
context["student_count"] = len(perf.scores)
if report_type == "student_detail" and student_id:
weakness = await self._data_ana.get_student_weakness(student_id, "")
trend = await self._data_ana.get_learning_trend(student_id)
context["weak_points"] = [
{"title": wp.title, "mastery": wp.mastery} for wp in weakness.weak_points
]
context["trend_points"] = [
{"date": tp.date, "score": tp.score} for tp in trend.points
]
except Exception as exc: # noqa: BLE001
logger.warning("report_context_degraded", error=str(exc))
context["degraded"] = True
context["degraded_reason"] = f"data-ana call failed: {exc}"
return context
def _system_prompt(self, report_type: ReportType) -> str:
"""系统 prompt定义 LLM 角色)."""
if self._prompts is not None:
try:
return self._prompts.render(
"report_system",
{"report_type": report_type},
)
except Exception: # noqa: BLE001
pass
return (
"你是一名专业的教育分析师,负责根据学情数据生成结构化报告。"
"报告必须使用 Markdown 格式,包含「摘要」「详细分析」「教学建议」三部分。"
)
def _build_prompt(self, report_type: ReportType, context: dict[str, Any]) -> str:
"""构建用户 prompt."""
if self._prompts is not None:
try:
return self._prompts.render(f"report_{report_type}", context)
except Exception: # noqa: BLE001
pass
# fallback内联模板
context_json = json.dumps(context, ensure_ascii=False, default=str)
type_desc = {
"class_summary": "班级学情总结",
"student_detail": "学生个人学情详情",
"exam_analysis": "考试成绩分析",
}.get(report_type, "学情报告")
return (
f"请根据以下学情数据生成{type_desc}\n\n"
f"学情数据:\n{context_json}\n\n"
"要求:\n"
"1. 摘要100 字以内概述\n"
"2. 详细分析:基于数据的关键发现\n"
"3. 教学建议3-5 条可执行建议\n"
)
def _extract_summary(self, content: str) -> str:
"""从报告内容提取摘要(取第一段或前 200 字)."""
if not content:
return ""
# 尝试匹配「摘要」段落
lines = content.split("\n")
for i, line in enumerate(lines):
if "摘要" in line and i + 1 < len(lines):
return lines[i + 1].strip()[:200]
# fallback取前 200 字
return content[:200].strip()
def _extract_recommendations(self, content: str) -> list[str]:
"""从报告内容提取教学建议(匹配列表项).
仅在「教学建议」/「建议」章节标题(以 # 开头或独立行)下提取列表项,
避免列表项自身包含「建议」关键词时被误判为章节标题。
"""
if not content:
return []
recommendations: list[str] = []
in_section = False
for line in content.split("\n"):
stripped = line.strip()
# 仅匹配章节标题Markdown 标题 # 开头,或纯文本标题行不含列表标记)
is_header = stripped.startswith("#")
is_plain_title = not stripped.startswith(("- ", "* ", "", "1.", "2.", "3.")) and (
"教学建议" in stripped or stripped == "建议"
)
if is_header and ("教学建议" in stripped or "建议" in stripped):
in_section = True
continue
if is_plain_title:
in_section = True
continue
if in_section:
# 匹配 Markdown 列表项(- 或 1.
if stripped.startswith(("- ", "* ", "")):
recommendations.append(stripped[2:].strip())
elif stripped and stripped[0].isdigit() and ". " in stripped:
recommendations.append(stripped.split(". ", 1)[1].strip())
elif stripped.startswith("#"):
# 进入下一个章节
break
elif not stripped:
# 空行跳过
continue
else:
# 非列表非标题的非空行 → 章节结束
break
return recommendations[:5]