277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""备课工作流服务测试(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"] == 3
|
||
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) == 3
|
||
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)
|
||
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
|