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

@@ -1,4 +1,4 @@
"""gRPC servicer 测试AiServicer 8 RPC + interceptors 辅助函数)."""
"""gRPC servicer 测试AiServicer 9 RPC + interceptors 辅助函数)."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -17,22 +17,25 @@ from src.ai.middleware.auth import UserContext
from src.ai.models.chat import ChatData, Usage
from src.ai.models.expression import OptimizedExpressionData
from src.ai.models.question import GeneratedQuestionData
from src.ai.models.report import GeneratedReportData
from src.ai.proto_gen import ai_pb2
class TestAiServicer:
"""AiServicer 8 RPC 测试."""
"""AiServicer 9 RPC 测试."""
def setup_method(self) -> None:
self.chat_svc = AsyncMock()
self.question_svc = AsyncMock()
self.expr_svc = AsyncMock()
self.workflow_svc = AsyncMock()
self.report_svc = AsyncMock()
self.servicer = AiServicer(
chat_service=self.chat_svc,
question_service=self.question_svc,
expression_service=self.expr_svc,
workflow_service=self.workflow_svc,
report_service=self.report_svc,
)
self.context = MagicMock()
self.context.user_context = UserContext(user_id="u-1", role="teacher")
@@ -43,7 +46,9 @@ class TestAiServicer:
async def test_chat_success(self) -> None:
self.chat_svc.chat.return_value = ChatData(
content="hi", model="gpt-4o", usage=Usage(),
content="hi",
model="gpt-4o",
usage=Usage(),
)
request = ai_pb2.ChatRequest(model="gpt-4o")
request.messages.add(role="user", content="hello")
@@ -136,7 +141,9 @@ class TestAiServicer:
evaluation_score=0.9,
)
request = ai_pb2.GenerateQuestionRequest(
prompt="生成加法题", subject="数学", difficulty="easy",
prompt="生成加法题",
subject="数学",
difficulty="easy",
)
result = await self.servicer.GenerateQuestion(request, self.context)
assert result.question == "1+1=?"
@@ -146,7 +153,9 @@ class TestAiServicer:
async def test_generate_question_no_service_degraded(self) -> None:
servicer = AiServicer(question_service=None)
request = ai_pb2.GenerateQuestionRequest(
prompt="生成加法题", subject="数学", difficulty="easy",
prompt="生成加法题",
subject="数学",
difficulty="easy",
)
result = await servicer.GenerateQuestion(request, self.context)
assert result.degraded is True
@@ -155,7 +164,9 @@ class TestAiServicer:
async def test_generate_question_llm_unavailable_degraded(self) -> None:
self.question_svc.generate.side_effect = AILLMUnavailableError("llm down")
request = ai_pb2.GenerateQuestionRequest(
prompt="生成加法题", subject="数学", difficulty="easy",
prompt="生成加法题",
subject="数学",
difficulty="easy",
)
result = await self.servicer.GenerateQuestion(request, self.context)
assert result.degraded is True
@@ -167,7 +178,9 @@ class TestAiServicer:
async def test_stream_generate_question_success(self) -> None:
complete = ai_pb2.GeneratedQuestion(
question="q", answer="a", question_type="short_answer",
question="q",
answer="a",
question_type="short_answer",
)
async def mock_stream_gen(request: object) -> None:
@@ -176,7 +189,9 @@ class TestAiServicer:
self.question_svc.stream_generate = mock_stream_gen
request = ai_pb2.GenerateQuestionRequest(
prompt="生成加法题", subject="数学", difficulty="easy",
prompt="生成加法题",
subject="数学",
difficulty="easy",
)
chunks = []
async for chunk in self.servicer.StreamGenerateQuestion(request, self.context):
@@ -190,7 +205,9 @@ class TestAiServicer:
async def test_stream_generate_question_no_service_degraded(self) -> None:
servicer = AiServicer(question_service=None)
request = ai_pb2.GenerateQuestionRequest(
prompt="生成加法题", subject="数学", difficulty="easy",
prompt="生成加法题",
subject="数学",
difficulty="easy",
)
chunks = []
async for chunk in servicer.StreamGenerateQuestion(request, self.context):
@@ -205,7 +222,8 @@ class TestAiServicer:
async def test_optimize_expression_success(self) -> None:
self.expr_svc.optimize.return_value = OptimizedExpressionData(
optimized="优化后", suggestions=["建议1"],
optimized="优化后",
suggestions=["建议1"],
)
request = ai_pb2.OptimizeExpressionRequest(text="原始", context="")
result = await self.servicer.OptimizeExpression(request, self.context)
@@ -240,8 +258,11 @@ class TestAiServicer:
degraded_reason="",
)
request = ai_pb2.GenerateLessonPlanRequest(
class_id="c-1", subject_id="math", topic="函数",
target_difficulty="medium", question_count=3,
class_id="c-1",
subject_id="math",
topic="函数",
target_difficulty="medium",
question_count=3,
)
result = await self.servicer.GenerateLessonPlan(request, self.context)
assert result.workflow_id == "wf-1"
@@ -252,7 +273,9 @@ class TestAiServicer:
async def test_generate_lesson_plan_no_service_degraded(self) -> None:
servicer = AiServicer(workflow_service=None)
request = ai_pb2.GenerateLessonPlanRequest(
class_id="c-1", subject_id="math", topic="函数",
class_id="c-1",
subject_id="math",
topic="函数",
)
result = await servicer.GenerateLessonPlan(request, self.context)
assert result.degraded is True
@@ -265,9 +288,13 @@ class TestAiServicer:
async def test_get_lesson_plan_status_success(self) -> None:
question = GeneratedQuestionData(
question="q1", answer="a1", explanation="e1",
question_type="short_answer", difficulty="easy",
knowledge_point_ids=["kp_1"], evaluation_score=0.8,
question="q1",
answer="a1",
explanation="e1",
question_type="short_answer",
difficulty="easy",
knowledge_point_ids=["kp_1"],
evaluation_score=0.8,
)
self.workflow_svc.get_status.return_value = SimpleNamespace(
workflow_id="wf-1",
@@ -315,6 +342,98 @@ class TestAiServicer:
assert result.success is False
assert "workflow_service not initialized" in result.error
# ------------------------------------------------------------------ #
# GenerateReport
# ------------------------------------------------------------------ #
async def test_generate_report_success(self) -> None:
"""生成学情报告成功."""
self.report_svc.generate.return_value = GeneratedReportData(
id="report-1",
content="# 班级学情报告\n\n摘要\n班级整体表现良好。",
summary="班级整体表现良好。",
recommendations=["加强函数概念教学", "增加练习题量"],
degraded=False,
degraded_reason="",
)
request = ai_pb2.GenerateReportRequest(
class_id="c-1",
report_type="class_summary",
user_id="u-1",
)
result = await self.servicer.GenerateReport(request, self.context)
assert result.id == "report-1"
assert "班级学情报告" in result.content
assert result.summary == "班级整体表现良好。"
assert list(result.recommendations) == ["加强函数概念教学", "增加练习题量"]
assert result.degraded is False
async def test_generate_report_student_detail_with_student_id(self) -> None:
"""学生详情报告(带 student_id."""
self.report_svc.generate.return_value = GeneratedReportData(
id="report-2",
content="学生个人报告",
summary="学生薄弱点分析",
recommendations=["针对性练习"],
degraded=False,
degraded_reason="",
)
request = ai_pb2.GenerateReportRequest(
class_id="c-1",
report_type="student_detail",
student_id="s-001",
user_id="u-1",
)
result = await self.servicer.GenerateReport(request, self.context)
# 验证 service 被调用时 student_id 正确传递
self.report_svc.generate.assert_awaited_once()
call_kwargs = self.report_svc.generate.call_args.kwargs
assert call_kwargs["student_id"] == "s-001"
assert call_kwargs["report_type"] == "student_detail"
assert result.id == "report-2"
async def test_generate_report_no_service_degraded(self) -> None:
"""report_service 未初始化时返回降级响应."""
servicer = AiServicer(report_service=None)
request = ai_pb2.GenerateReportRequest(
class_id="c-1",
report_type="class_summary",
)
result = await servicer.GenerateReport(request, self.context)
assert result.degraded is True
assert "report_service not initialized" in result.degraded_reason
assert result.id == ""
assert result.content == ""
async def test_generate_report_llm_unavailable_degraded(self) -> None:
"""LLM 不可用时 ReportService 返回降级数据."""
self.report_svc.generate.return_value = GeneratedReportData(
id="report-3",
content="",
summary="",
recommendations=[],
degraded=True,
degraded_reason="LLM unavailable: all providers failed",
)
request = ai_pb2.GenerateReportRequest(
class_id="c-1",
report_type="exam_analysis",
)
result = await self.servicer.GenerateReport(request, self.context)
assert result.degraded is True
assert "LLM unavailable" in result.degraded_reason
async def test_generate_report_internal_error_raises(self) -> None:
"""未知异常转 AI_INTERNAL_ERROR."""
self.report_svc.generate.side_effect = RuntimeError("boom")
request = ai_pb2.GenerateReportRequest(
class_id="c-1",
report_type="class_summary",
)
with pytest.raises(AIError) as exc_info:
await self.servicer.GenerateReport(request, self.context)
assert exc_info.value.code == ErrorCode.AI_INTERNAL_ERROR
# ------------------------------------------------------------------ #
# degraded response helpers
# ------------------------------------------------------------------ #