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:
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -328,6 +328,75 @@ async def test_optimize_expression(client: httpx.AsyncClient) -> None:
|
||||
assert body["data"]["degraded"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate report endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_generate_report_class_summary(client: httpx.AsyncClient) -> None:
|
||||
"""POST /v1/ai/generate/report with class_summary returns degraded report (no LLM key)."""
|
||||
resp = await client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={"class_id": "c-1", "report_type": "class_summary"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
# 测试环境无 LLM API key → degraded
|
||||
assert body["data"]["degraded"] is True
|
||||
assert "id" in body["data"]
|
||||
|
||||
|
||||
async def test_generate_report_student_detail(client: httpx.AsyncClient) -> None:
|
||||
"""POST /v1/ai/generate/report with student_detail + student_id."""
|
||||
resp = await client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={
|
||||
"class_id": "c-1",
|
||||
"report_type": "student_detail",
|
||||
"student_id": "s-001",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"]["degraded"] is True
|
||||
|
||||
|
||||
async def test_generate_report_invalid_type(client: httpx.AsyncClient) -> None:
|
||||
"""POST /v1/ai/generate/report with invalid report_type returns 422."""
|
||||
resp = await client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={"class_id": "c-1", "report_type": "invalid_type"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_generate_report_missing_class_id(client: httpx.AsyncClient) -> None:
|
||||
"""POST /v1/ai/generate/report without class_id returns 422."""
|
||||
resp = await client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={"report_type": "class_summary"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_generate_report_permission_denied(prod_client: httpx.AsyncClient) -> None:
|
||||
"""Student role attempting to generate report returns 403."""
|
||||
resp = await prod_client.post(
|
||||
"/v1/ai/generate/report",
|
||||
json={"class_id": "c-1", "report_type": "class_summary"},
|
||||
headers={
|
||||
"X-User-Id": "student-1",
|
||||
"X-User-Role": "student",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["error"]["code"] == "AI_FORBIDDEN"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lesson plan endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,10 @@ from pydantic import ValidationError
|
||||
from src.ai.models.chat import ChatData, ChatMessage, ChatRequest, Usage
|
||||
from src.ai.models.expression import OptimizeExpressionRequest
|
||||
from src.ai.models.question import GeneratedQuestionData, GenerateQuestionRequest
|
||||
from src.ai.models.report import (
|
||||
GeneratedReportData,
|
||||
GenerateReportRequest,
|
||||
)
|
||||
from src.ai.models.workflow import (
|
||||
ConfirmRequest,
|
||||
LessonPreparationRequest,
|
||||
@@ -52,8 +56,11 @@ class TestChatModels:
|
||||
|
||||
def test_chat_data_degraded_fields(self) -> None:
|
||||
data = ChatData(
|
||||
content="x", model="m", usage=Usage(),
|
||||
degraded=True, degraded_reason="test",
|
||||
content="x",
|
||||
model="m",
|
||||
usage=Usage(),
|
||||
degraded=True,
|
||||
degraded_reason="test",
|
||||
)
|
||||
assert data.degraded is True
|
||||
|
||||
@@ -131,3 +138,37 @@ class TestWorkflowModels:
|
||||
assert data.questions == []
|
||||
assert data.error is None
|
||||
assert data.degraded is False
|
||||
|
||||
|
||||
class TestReportModels:
|
||||
"""学情报告模型测试."""
|
||||
|
||||
def test_generate_report_request_defaults(self) -> None:
|
||||
req = GenerateReportRequest(class_id="c-1")
|
||||
assert req.report_type == "class_summary"
|
||||
assert req.student_id is None
|
||||
assert req.user_id is None
|
||||
assert req.data_scope is None
|
||||
|
||||
def test_generate_report_request_invalid_type(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
GenerateReportRequest(class_id="c-1", report_type="invalid")
|
||||
|
||||
def test_generate_report_request_missing_class_id(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
GenerateReportRequest()
|
||||
|
||||
def test_generate_report_request_student_detail(self) -> None:
|
||||
req = GenerateReportRequest(
|
||||
class_id="c-1",
|
||||
report_type="student_detail",
|
||||
student_id="s-001",
|
||||
)
|
||||
assert req.report_type == "student_detail"
|
||||
assert req.student_id == "s-001"
|
||||
|
||||
def test_generated_report_data_defaults(self) -> None:
|
||||
data = GeneratedReportData(id="r-1", content="内容", summary="摘要")
|
||||
assert data.recommendations == []
|
||||
assert data.degraded is False
|
||||
assert data.degraded_reason == ""
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.ai.middleware.permission import (
|
||||
PERMISSION_AI_LESSON_CONFIRM,
|
||||
PERMISSION_AI_LESSON_GENERATE,
|
||||
PERMISSION_AI_QUESTION_GENERATE,
|
||||
PERMISSION_AI_REPORT_GENERATE,
|
||||
PermissionGuard,
|
||||
)
|
||||
|
||||
@@ -39,6 +40,7 @@ class TestPermissionGuard:
|
||||
guard.check(ctx, PERMISSION_AI_QUESTION_GENERATE)
|
||||
guard.check(ctx, PERMISSION_AI_LESSON_GENERATE)
|
||||
guard.check(ctx, PERMISSION_AI_LESSON_CONFIRM)
|
||||
guard.check(ctx, PERMISSION_AI_REPORT_GENERATE)
|
||||
|
||||
def test_student_only_chat(self) -> None:
|
||||
"""student 角色仅有 chat 权限."""
|
||||
@@ -49,6 +51,14 @@ class TestPermissionGuard:
|
||||
guard.check(ctx, PERMISSION_AI_QUESTION_GENERATE)
|
||||
assert exc_info.value.code == ErrorCode.AI_FORBIDDEN
|
||||
|
||||
def test_student_denied_report(self) -> None:
|
||||
"""student 角色无权生成学情报告."""
|
||||
guard = PermissionGuard(dev_mode=False)
|
||||
ctx = UserContext(user_id="u-1", role="student")
|
||||
with pytest.raises(AIError) as exc_info:
|
||||
guard.check(ctx, PERMISSION_AI_REPORT_GENERATE)
|
||||
assert exc_info.value.code == ErrorCode.AI_FORBIDDEN
|
||||
|
||||
def test_unknown_role_defaults_student(self) -> None:
|
||||
"""未知角色降级为 student 权限."""
|
||||
guard = PermissionGuard(dev_mode=False)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""服务层测试(ChatService / QuestionService / ExpressionService)."""
|
||||
"""服务层测试(ChatService / QuestionService / ExpressionService / ReportService)."""
|
||||
|
||||
import json
|
||||
|
||||
from src.ai.clients.data_ana_client import DataAnaClientMock
|
||||
from src.ai.models.question import GenerateQuestionRequest
|
||||
from src.ai.providers import ProviderFailoverChain
|
||||
from src.ai.providers.circuit_breaker import CircuitBreaker
|
||||
@@ -9,6 +10,7 @@ from src.ai.services.chat_service import ChatService
|
||||
from src.ai.services.evaluation import QualityGate, RuleValidator
|
||||
from src.ai.services.expression_service import ExpressionService
|
||||
from src.ai.services.question_service import QuestionService
|
||||
from src.ai.services.report_service import ReportService
|
||||
|
||||
from .conftest import MockProvider
|
||||
|
||||
@@ -70,13 +72,15 @@ class TestQuestionService:
|
||||
|
||||
async def test_generate_success(self) -> None:
|
||||
"""生成题目成功."""
|
||||
output = json.dumps({
|
||||
"question": "1+1等于几?",
|
||||
"answer": "2",
|
||||
"explanation": "基础加法",
|
||||
"difficulty": "easy",
|
||||
"question_type": "short_answer",
|
||||
})
|
||||
output = json.dumps(
|
||||
{
|
||||
"question": "1+1等于几?",
|
||||
"answer": "2",
|
||||
"explanation": "基础加法",
|
||||
"difficulty": "easy",
|
||||
"question_type": "short_answer",
|
||||
}
|
||||
)
|
||||
chain = _make_chain(MockProvider(response_content=output))
|
||||
gate = QualityGate(rule_validator=RuleValidator())
|
||||
svc = QuestionService(
|
||||
@@ -150,10 +154,12 @@ class TestExpressionService:
|
||||
|
||||
async def test_optimize_success(self) -> None:
|
||||
"""优化成功."""
|
||||
output = json.dumps({
|
||||
"optimized": "优化后的文字",
|
||||
"suggestions": ["建议1"],
|
||||
})
|
||||
output = json.dumps(
|
||||
{
|
||||
"optimized": "优化后的文字",
|
||||
"suggestions": ["建议1"],
|
||||
}
|
||||
)
|
||||
chain = _make_chain(MockProvider(response_content=output))
|
||||
svc = ExpressionService(failover_chain=chain)
|
||||
data = await svc.optimize(text="原始文字")
|
||||
@@ -192,3 +198,133 @@ class TestExpressionService:
|
||||
prompt = svc._fallback_prompt("文字", "上下文")
|
||||
assert "文字" in prompt
|
||||
assert "上下文" in prompt
|
||||
|
||||
|
||||
class TestReportService:
|
||||
"""ReportService 测试."""
|
||||
|
||||
async def test_generate_class_summary_success(self) -> None:
|
||||
"""班级学情总结报告生成成功."""
|
||||
chain = _make_chain(
|
||||
MockProvider(
|
||||
response_content=(
|
||||
"# 班级学情报告\n\n"
|
||||
"## 摘要\n班级平均分 78.5,及格率 85%。\n\n"
|
||||
"## 详细分析\n整体表现良好。\n\n"
|
||||
"## 教学建议\n- 加强函数概念\n- 增加练习题\n"
|
||||
),
|
||||
)
|
||||
)
|
||||
svc = ReportService(
|
||||
failover_chain=chain,
|
||||
data_ana_client=DataAnaClientMock(),
|
||||
default_model="gpt-4o-mini",
|
||||
)
|
||||
data = await svc.generate(
|
||||
class_id="c-1",
|
||||
report_type="class_summary",
|
||||
)
|
||||
assert data.degraded is False
|
||||
assert "班级学情报告" in data.content
|
||||
assert data.summary # 非空
|
||||
assert len(data.recommendations) == 2
|
||||
assert "函数概念" in data.recommendations[0]
|
||||
|
||||
async def test_generate_student_detail_with_student_id(self) -> None:
|
||||
"""学生详情报告(带 student_id)."""
|
||||
chain = _make_chain(
|
||||
MockProvider(
|
||||
response_content=(
|
||||
"# 学生学情详情\n\n"
|
||||
"## 摘要\n该生在函数概念上较薄弱。\n\n"
|
||||
"## 教学建议\n- 针对性练习\n"
|
||||
),
|
||||
)
|
||||
)
|
||||
svc = ReportService(
|
||||
failover_chain=chain,
|
||||
data_ana_client=DataAnaClientMock(),
|
||||
)
|
||||
data = await svc.generate(
|
||||
class_id="c-1",
|
||||
report_type="student_detail",
|
||||
student_id="s-001",
|
||||
)
|
||||
assert data.degraded is False
|
||||
assert "学生学情详情" in data.content
|
||||
|
||||
async def test_generate_degraded_llm_fail(self) -> None:
|
||||
"""LLM 不可用时降级."""
|
||||
chain = _make_chain(MockProvider(fail=True))
|
||||
svc = ReportService(
|
||||
failover_chain=chain,
|
||||
data_ana_client=DataAnaClientMock(),
|
||||
)
|
||||
data = await svc.generate(
|
||||
class_id="c-1",
|
||||
report_type="exam_analysis",
|
||||
)
|
||||
assert data.degraded is True
|
||||
assert "LLM unavailable" in data.degraded_reason
|
||||
assert data.content == ""
|
||||
|
||||
async def test_generate_degraded_no_data_ana_client(self) -> None:
|
||||
"""无 data-ana 客户端时上下文降级(但 LLM 仍可生成)."""
|
||||
chain = _make_chain(MockProvider(response_content="# 报告\n\n## 摘要\n无数据。"))
|
||||
svc = ReportService(
|
||||
failover_chain=chain,
|
||||
data_ana_client=None,
|
||||
)
|
||||
data = await svc.generate(
|
||||
class_id="c-1",
|
||||
report_type="class_summary",
|
||||
)
|
||||
# LLM 可用 → 报告生成成功(上下文降级但不影响 LLM 调用)
|
||||
assert data.degraded is False
|
||||
assert "报告" in data.content
|
||||
|
||||
def test_extract_summary_from_explicit_section(self) -> None:
|
||||
"""从「摘要」段落提取摘要."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
content = "# 报告\n\n## 摘要\n这是摘要内容。\n\n## 详细\n详情"
|
||||
summary = svc._extract_summary(content)
|
||||
assert "这是摘要内容" in summary
|
||||
|
||||
def test_extract_summary_fallback_first_200_chars(self) -> None:
|
||||
"""无「摘要」段落时取前 200 字."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
content = "这是一段没有摘要标题的报告内容。"
|
||||
summary = svc._extract_summary(content)
|
||||
assert summary == content
|
||||
|
||||
def test_extract_recommendations_from_dash_list(self) -> None:
|
||||
"""从「-」列表提取建议."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
content = "## 教学建议\n- 建议一\n- 建议二\n## 其他\n"
|
||||
recs = svc._extract_recommendations(content)
|
||||
assert recs == ["建议一", "建议二"]
|
||||
|
||||
def test_extract_recommendations_from_numbered_list(self) -> None:
|
||||
"""从数字列表提取建议."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
content = "## 建议\n1. 第一条\n2. 第二条\n"
|
||||
recs = svc._extract_recommendations(content)
|
||||
assert recs == ["第一条", "第二条"]
|
||||
|
||||
def test_build_prompt_with_context(self) -> None:
|
||||
"""构建 prompt 包含上下文数据."""
|
||||
chain = _make_chain(MockProvider())
|
||||
svc = ReportService(failover_chain=chain)
|
||||
context = {
|
||||
"class_id": "c-1",
|
||||
"report_type": "class_summary",
|
||||
"average_score": 78.5,
|
||||
"pass_rate": 0.85,
|
||||
}
|
||||
prompt = svc._build_prompt("class_summary", context)
|
||||
assert "class_summary" in prompt or "班级学情总结" in prompt
|
||||
assert "78.5" in prompt
|
||||
|
||||
Reference in New Issue
Block a user