Files
Edu/services/ai/tests/test_lesson_workflow.py

292 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""备课工作流服务测试LessonPlanWorkflowService.
测试 4 步编排 + 状态机 + 降级路径。
使用 WorkflowStateStore(redis=None) 内存降级模式 + MockProvider + Mock 客户端。
"""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.ai.clients.content_client import ContentClientMock, CreatedQuestion
from src.ai.clients.data_ana_client import DataAnaClientMock
from src.ai.errors import AIWorkflowNotFoundError, AIWorkflowStateInvalidError
from src.ai.models.question import GeneratedQuestionData
from src.ai.prompt_service import PromptTemplateService
from src.ai.providers import ProviderFailoverChain
from src.ai.providers.circuit_breaker import CircuitBreaker
from src.ai.services.evaluation import QualityGate, RuleValidator
from src.ai.workflow.lesson_plan_workflow import LessonPlanWorkflowService
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",
}
)
def _make_chain(provider: MockProvider | None = None) -> ProviderFailoverChain:
"""构建含单个 MockProvider 的 failover chain."""
return ProviderFailoverChain([provider or MockProvider()], CircuitBreaker())
def _make_state(**overrides: object) -> WorkflowState:
"""构建测试用 WorkflowState."""
defaults: dict[str, object] = {
"user_id": "u-1",
"school_id": "s-1",
"class_id": "c-1",
"subject_id": "math",
"topic": "函数",
"target_difficulty": "medium",
"question_count": 1,
}
defaults.update(overrides)
return WorkflowState(**defaults) # type: ignore[arg-type]
def _make_service(
store: WorkflowStateStore | None = None,
provider: MockProvider | None = None,
content_client: object | None = None,
data_ana_client: object | None = None,
prompt_service: PromptTemplateService | MagicMock | None = None,
quality_gate: QualityGate | None = None,
) -> LessonPlanWorkflowService:
"""构建测试用 LessonPlanWorkflowService."""
return LessonPlanWorkflowService(
state_store=store or WorkflowStateStore(redis=None),
failover_chain=_make_chain(provider),
prompt_service=prompt_service,
quality_gate=quality_gate,
content_client=content_client, # type: ignore[arg-type]
data_ana_client=data_ana_client, # type: ignore[arg-type]
)
class TestLessonPlanWorkflowStart:
"""start / get_status 测试."""
async def test_start_creates_workflow(self) -> None:
svc = _make_service(provider=MockProvider(response_content=VALID_QUESTION_JSON))
state = await svc.start(
user_id="u-1",
school_id="s-1",
class_id="c-1",
subject_id="math",
topic="函数",
question_count=1,
)
assert state.status == "pending"
assert state.workflow_id != ""
assert state.topic == "函数"
async def test_start_and_wait_for_completion(self) -> None:
svc = _make_service(
provider=MockProvider(response_content=VALID_QUESTION_JSON),
content_client=ContentClientMock(),
data_ana_client=DataAnaClientMock(),
)
state = await svc.start(
user_id="u-1",
school_id="s-1",
class_id="c-1",
subject_id="math",
topic="函数",
question_count=1,
)
assert state.status == "pending"
# 等待后台任务完成
task = svc._background_tasks.get(state.workflow_id)
assert task is not None
await task
final = await svc.get_status(state.workflow_id)
assert final.status in ("pending_review", "failed")
if final.status == "pending_review":
assert len(final.questions) == 1
async def test_get_status_not_found_raises(self) -> None:
svc = _make_service()
with pytest.raises(AIWorkflowNotFoundError):
await svc.get_status("nonexistent-id")
class TestLessonPlanWorkflowConfirm:
"""confirm 测试."""
async def test_confirm_wrong_state_raises(self) -> None:
store = WorkflowStateStore(redis=None)
state = _make_state(status="pending")
await store.create(state)
svc = _make_service(store=store)
with pytest.raises(AIWorkflowStateInvalidError):
await svc.confirm(state.workflow_id)
async def test_confirm_success(self) -> None:
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"],
)
],
)
await store.create(state)
svc = _make_service(store=store, content_client=ContentClientMock())
result = await svc.confirm(state.workflow_id)
assert result["success"] is True
assert len(result["persisted_question_ids"]) == 1
# 验证状态更新为 persisted
final = await store.get(state.workflow_id)
assert final.status == "persisted"
async def test_confirm_no_content_client_returns_error(self) -> None:
store = WorkflowStateStore(redis=None)
state = _make_state(
status="pending_review",
questions=[GeneratedQuestionData(question="q1", answer="a1", explanation="e1")],
)
await store.create(state)
svc = _make_service(store=store, content_client=None)
result = await svc.confirm(state.workflow_id)
assert result["success"] is False
assert "content client not configured" in result["error"]
async def test_confirm_with_modifications(self) -> None:
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"],
)
],
)
await store.create(state)
content_client = AsyncMock()
content_client.create_questions.return_value = [
CreatedQuestion(id="q_1", question="modified question"),
]
svc = _make_service(store=store, content_client=content_client)
result = await svc.confirm(
state.workflow_id,
modifications={"0": "modified question"},
)
assert result["success"] is True
# 验证修改已应用到传入 content_client 的题目
call_args = content_client.create_questions.call_args
questions_passed = call_args.args[0]
assert questions_passed[0].question == "modified question"
class TestLessonPlanWorkflowSteps:
"""4 步编排内部方法测试."""
async def test_step1_analyze_success(self) -> None:
svc = _make_service(data_ana_client=DataAnaClientMock())
state = _make_state()
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"] == 2
assert "weak_students" in analysis
async def test_step1_analyze_no_client_degraded(self) -> None:
svc = _make_service(data_ana_client=None)
state = _make_state()
analysis = await svc._step1_analyze(state)
assert analysis["degraded"] is True
assert "not configured" in analysis["degraded_reason"]
async def test_step2_recommend_success(self) -> None:
svc = _make_service(content_client=ContentClientMock())
state = _make_state()
kps = await svc._step2_recommend(state)
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"]
async def test_step3_generate_success(self) -> None:
provider = MockProvider(response_content=VALID_QUESTION_JSON)
svc = _make_service(
provider=provider,
quality_gate=QualityGate(rule_validator=RuleValidator()),
)
state = _make_state(question_count=1, target_difficulty="medium")
questions = await svc._step3_generate(
state,
[{"id": "kp_1", "title": "KP1"}],
)
assert len(questions) == 1
assert questions[0].question == "什么是函数?"
assert questions[0].answer == "函数是一种对应关系"
assert questions[0].degraded is False
async def test_step3_generate_all_retries_fail(self) -> None:
provider = MockProvider(fail=True)
svc = _make_service(provider=provider)
state = _make_state(question_count=1)
questions = await svc._step3_generate(
state,
[{"id": "kp_1", "title": "KP1"}],
)
assert len(questions) == 1
assert questions[0].degraded is True
assert "max retries exceeded" in questions[0].degraded_reason
class TestLessonPlanWorkflowPrompt:
"""prompt 渲染测试."""
def test_render_generate_prompt_with_template(self) -> None:
prompt_service = MagicMock()
prompt_service.render.return_value = "rendered prompt with template"
svc = _make_service(prompt_service=prompt_service)
state = _make_state()
prompt = svc._render_generate_prompt(state, ["kp_1"], 0)
assert prompt == "rendered prompt with template"
prompt_service.render.assert_called_once()
args = prompt_service.render.call_args
assert args.args[0] == "lesson_plan_generate"
def test_render_generate_prompt_fallback(self) -> None:
svc = _make_service(prompt_service=None)
state = _make_state(topic="函数", subject_id="math", target_difficulty="medium")
prompt = svc._render_generate_prompt(state, ["kp_1"], 0)
assert "函数" in prompt
assert "math" in prompt
assert "medium" in prompt
assert "JSON" in prompt