feat: auto committed

This commit is contained in:
SpecialX
2026-07-10 18:57:39 +08:00
parent c09d6fb7d2
commit 9ea81f1bd7
96 changed files with 13000 additions and 392 deletions

View File

@@ -0,0 +1,276 @@
"""备课工作流服务测试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