feat(ai): temporal worker for lesson plan workflow

- deploy temporal server (postgresql + auto-setup + ui) in docker-compose
- new temporal/ module: workflow + activities + worker manager
- convert lesson plan 4-step orchestration to temporal workflow
- activities wrap existing analyze/recommend/generate/prepare_review steps
- worker injects failover_chain/content_client/data_ana_client via module globals
- start() uses temporal client.start_workflow, falls back to asyncio in dev
- register temporal ports 7233/8085 in port-allocation

Implements M6.5 of v2.1 migration plan (ADR-030).
This commit is contained in:
SpecialX
2026-07-15 02:34:34 +08:00
parent ce5aeec955
commit 47e950c664
11 changed files with 701 additions and 40 deletions

View File

@@ -346,6 +346,63 @@ services:
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 5 retries: 5
# ============================================================
# Temporal Server - AI 工作流引擎v2.1 §8.2 ADR-030
# 仅用于 AI 耗时工作流 + SagaCRUD 短事务禁止
# ============================================================
temporal-postgresql:
image: docker.m.daocloud.io/library/postgres:13
container_name: edu-temporal-postgres
profiles: ["p3", "p4", "p5", "p6"]
restart: unless-stopped
environment:
POSTGRES_USER: temporal
POSTGRES_PASSWORD: ${TEMPORAL_POSTGRES_PASSWORD:-temporal}
POSTGRES_DB: temporal
volumes:
- temporal_pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U temporal"]
interval: 10s
timeout: 5s
retries: 5
temporal:
image: temporalio/auto-setup:1.23
container_name: edu-temporal
profiles: ["p3", "p4", "p5", "p6"]
restart: unless-stopped
depends_on:
temporal-postgresql:
condition: service_healthy
environment:
DBHOST: temporal-postgresql
DBPORT: 5432
DBUSER: temporal
DBPASSWORD: ${TEMPORAL_POSTGRES_PASSWORD:-temporal}
DBNAME: temporal
DB_PLUGIN: postgres
# 暴露 frontend gRPC 端口 7233 供 Worker 连接
SERVICES: "frontend,history,matching,worker"
ports:
- "7233:7233"
healthcheck:
test: ["CMD", "tctl", "--address", "localhost:7233", "cluster", "health"]
interval: 15s
timeout: 5s
start_period: 30s
retries: 10
temporal-ui:
image: temporalio/ui:2.30.0
container_name: edu-temporal-ui
profiles: ["p3", "p4", "p5", "p6"]
restart: unless-stopped
depends_on:
- temporal
environment:
TEMPORAL_ADDRESS: temporal:7233
TEMPORAL_CORS_ORIGINS: "http://localhost:4000,http://localhost:4001,http://localhost:4002,http://localhost:4003"
ports:
- "8085:8080"
volumes: volumes:
mysql_data: mysql_data:
redis_data: redis_data:
@@ -356,3 +413,4 @@ volumes:
prometheus_data: prometheus_data:
alertmanager_data: alertmanager_data:
loki_data: loki_data:
temporal_pg_data:

View File

@@ -94,7 +94,7 @@
## 6. 基础设施端口9080-9199 ## 6. 基础设施端口9080-9199
| 服务 | 端口 | 说明 | | 服务 | 端口 | 说明 |
| ---------------- | --------------------------------------------------------------------------- | ------------------------------------- | | ----------------- | --------------------------------------------------------------------------- | ------------------------------------- |
| MySQL | 3306 | 业务数据库external | | MySQL | 3306 | 业务数据库external |
| Redis | 6379 | 缓存 + 会话 + Pub/Sub | | Redis | 6379 | 缓存 + 会话 + Pub/Sub |
| Kafka | 9092OUTSIDE/ 29092INSIDE | 双监听器 | | Kafka | 9092OUTSIDE/ 29092INSIDE | 双监听器 |
@@ -111,6 +111,9 @@
| node-exporter | 9100 | 主机指标host.docker.internal:9100 | | node-exporter | 9100 | 主机指标host.docker.internal:9100 |
| mysqld-exporter | 9104 | MySQL 指标 | | mysqld-exporter | 9104 | MySQL 指标 |
| redis-exporter | 9121 | Redis 指标 | | redis-exporter | 9121 | Redis 指标 |
| Temporal | 7233 | gRPC frontendWorker 连接) |
| Temporal UI | 8085 | Temporal Web UI容器内 8080 |
| Temporal Postgres | 5433预留内部 5432 | Temporal 持久化存储(仅容器内) |
> **Grafana 端口冲突风险**Grafana 默认 3000与 teacher-bff 3003 不冲突(不同主机层),但开发环境若同主机部署需注意。建议 Grafana 改用 3030 避免混淆。 > **Grafana 端口冲突风险**Grafana 默认 3000与 teacher-bff 3003 不冲突(不同主机层),但开发环境若同主机部署需注意。建议 Grafana 改用 3030 避免混淆。
@@ -119,12 +122,13 @@
## 7. 变更记录 ## 7. 变更记录
| 日期 | 变更 | 决策者 | | 日期 | 变更 | 决策者 |
| ---------- | --------------------------------------------------------- | ------ | | ---------- | ----------------------------------------------------------------- | ------ |
| 2026-07-09 | 初始创建,登记全部 15 服务 + 基础设施端口 | coord | | 2026-07-09 | 初始创建,登记全部 15 服务 + 基础设施端口 | coord |
| 2026-07-09 | 仲裁 admin-portal 3003 → 4003MF 配置 3000 → 4000 | coord | | 2026-07-09 | 仲裁 admin-portal 3003 → 4003MF 配置 3000 → 4000 | coord |
| 2026-07-09 | 仲裁 push-gateway 豁免 gRPC释放 5005750058 让给 ai | coord | | 2026-07-09 | 仲裁 push-gateway 豁免 gRPC释放 5005750058 让给 ai | coord |
| 2026-07-09 | classes 3001 标记为历史(已合并入 core-edu | coord | | 2026-07-09 | classes 3001 标记为历史(已合并入 core-edu | coord |
| 2026-07-14 | M3 新增 config-service3011/50059ADR-026 从 iam 拆分) | coord | | 2026-07-14 | M3 新增 config-service3011/50059ADR-026 从 iam 拆分) | coord |
| 2026-07-14 | M6.5 新增 Temporal7233/UI 8085/PG 5433ADR-030 AI 工作流引擎) | coord |
--- ---

View File

@@ -55,3 +55,8 @@ ENCRYPTION_KEY=<replace-with-base64-32-byte-aes-key>
# 生成openssl rand -hex 32 # 生成openssl rand -hex 32
# 注意Router 和所有子图必须使用相同的密钥 # 注意Router 和所有子图必须使用相同的密钥
ROUTER_AUTH_SECRET=<replace-with-32-char-router-auth-secret> ROUTER_AUTH_SECRET=<replace-with-32-char-router-auth-secret>
# ---------- Temporal ----------
# 用途Temporal PostgreSQL 存储密码
# 最小长度24 字符
TEMPORAL_POSTGRES_PASSWORD=<replace-with-24-char-temporal-password>

View File

@@ -22,6 +22,8 @@ dependencies = [
"tenacity>=9.0.0", "tenacity>=9.0.0",
# GraphQL Federation 2 子图v2.1 M1Apollo Router 组合) # GraphQL Federation 2 子图v2.1 M1Apollo Router 组合)
"strawberry-graphql[asgi]>=0.257.0", "strawberry-graphql[asgi]>=0.257.0",
# Temporal 工作流引擎v2.1 §8.2 ADR-030AI 耗时工作流)
"temporalio>=1.7.0",
] ]
[tool.uv.sources] [tool.uv.sources]

View File

@@ -64,6 +64,13 @@ class Settings(BaseSettings):
workflow_ttl_seconds: int = 3600 # 1h workflow_ttl_seconds: int = 3600 # 1h
workflow_max_retries: int = 3 workflow_max_retries: int = 3
# Temporalv2.1 §8.2AI 耗时工作流引擎)
temporal_host: str = "localhost:7233"
temporal_namespace: str = "default"
temporal_task_queue: str = "ai-lesson-plan"
temporal_workflow_timeout_seconds: int = 3600 # 1h
temporal_activity_timeout_seconds: int = 300 # 5min per activity
# 评估 # 评估
evaluation_pass_threshold: float = 0.7 evaluation_pass_threshold: float = 0.7
evaluation_excellent_threshold: float = 0.85 evaluation_excellent_threshold: float = 0.85

View File

@@ -74,6 +74,7 @@ from .providers import create_failover_chain
from .rate_limiter import RateLimiter from .rate_limiter import RateLimiter
from .services import ChatService, ExpressionService, QuestionService, ReportService from .services import ChatService, ExpressionService, QuestionService, ReportService
from .services.evaluation import QualityGate, RuleValidator from .services.evaluation import QualityGate, RuleValidator
from .temporal import TemporalWorkerManager
from .usage import KafkaProducer, QuotaEnforcer, UsageRecorder from .usage import KafkaProducer, QuotaEnforcer, UsageRecorder
from .workflow import LessonPlanWorkflowService, WorkflowStateStore from .workflow import LessonPlanWorkflowService, WorkflowStateStore
@@ -178,6 +179,7 @@ _grpc_server = create_grpc_server(
) )
_redis: Redis | None = None _redis: Redis | None = None
_temporal_worker: TemporalWorkerManager | None = None
@asynccontextmanager @asynccontextmanager
@@ -215,6 +217,32 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
error=str(exc), error=str(exc),
) )
# Temporal Workerv2.1 M6.5 ADR-030备课工作流引擎
# 连接失败时降级LessonPlanWorkflowService 自动回退 asyncio不阻断启动
global _temporal_worker
try:
_temporal_worker = TemporalWorkerManager(
host=settings.temporal_host,
namespace=settings.temporal_namespace,
task_queue=settings.temporal_task_queue,
)
temporal_client = await _temporal_worker.start(
failover_chain=_failover_chain,
prompt_service=_prompt_service,
quality_gate=_quality_gate,
content_client=_content_client,
data_ana_client=_data_ana_client,
state_store=_state_store,
default_model=settings.default_question_model,
)
_workflow_service.set_temporal_client(
temporal_client,
settings.temporal_task_queue,
)
except Exception as exc: # noqa: BLE001
logger.warning("temporal_worker_start_failed_degraded", error=str(exc))
_temporal_worker = None
await _kafka_producer.start() await _kafka_producer.start()
await _grpc_server.start() await _grpc_server.start()
@@ -237,6 +265,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.info("ai_service_stopping") logger.info("ai_service_stopping")
await _grpc_server.stop() await _grpc_server.stop()
await _kafka_producer.stop() await _kafka_producer.stop()
if _temporal_worker is not None:
try:
await _temporal_worker.stop()
except Exception as exc: # noqa: BLE001
logger.warning("temporal_worker_stop_failed", error=str(exc))
for name, client in reversed(downstream_clients): for name, client in reversed(downstream_clients):
try: try:
await client.close() await client.close()

View File

@@ -0,0 +1,14 @@
"""Temporal 工作流引擎模块v2.1 §8.2 ADR-030.
ai 服务作为 Temporal Worker承载备课工作流4 步编排)。
边界:仅 AI 耗时工作流 + SagaCRUD 短事务禁止走 Temporal。
子模块:
- workflow: Temporal Workflow 定义LessonPlanWorkflow
- activities: 4 步编排 Activities
- worker: Worker 生命周期管理TemporalWorkerManager
"""
from .worker import TemporalWorkerManager
__all__ = ["TemporalWorkerManager"]

View File

@@ -0,0 +1,365 @@
"""Temporal Activities - 备课工作流 4 步编排v2.1 §8.2 ADR-030.
将 lesson_plan_workflow.py 的 4 个 step 方法提取为 Temporal Activities。
每个 Activity 封装一步业务逻辑,接收/返回可序列化的 dict。
Activity 通过模块级 _clients 字典访问依赖(由 worker.py 启动时注入):
- data_ana_client: 学情查询
- content_client: 知识点查询
- failover_chain: LLM 调用
- prompt_service: Prompt 模板渲染
- quality_gate: 题目评估三道防线
- state_store: Redis 工作流状态存储
- default_model: 默认 LLM 模型
"""
from typing import Any
import structlog
from temporalio import activity
from ..clients.content_client import ContentClient
from ..clients.data_ana_client import DataAnaClient
from ..errors import AIError
from ..models.question import GeneratedQuestionData
from ..prompt_service import PromptTemplateService
from ..providers import ProviderFailoverChain
from ..services.evaluation import QualityGate
from ..workflow.state_store import WorkflowState, WorkflowStateStore
logger = structlog.get_logger()
# 状态常量(与 lesson_plan_workflow.py 保持一致)
STATUS_ANALYZING = "analyzing"
STATUS_GENERATING = "generating"
STATUS_PENDING_REVIEW = "pending_review"
# 生成重试上限
MAX_GENERATE_RETRIES = 3
# 模块级依赖注册表(由 TemporalWorkerManager.start 注入)
_clients: dict[str, Any] = {}
def set_clients(**clients: Any) -> None:
"""注入依赖到 Activities由 worker 启动时调用)."""
_clients.update(clients)
def _get_state_store() -> WorkflowStateStore:
"""获取已注入的状态存储."""
store = _clients.get("state_store")
if store is None:
raise AIError("state_store not injected to temporal activities")
return store # type: ignore[return-value]
@activity.defn
async def analyze_activity(state_data: dict) -> dict:
"""Step 1: 分析学情.
调 data-ana 查询班级学情 + 学生薄弱点。
data-ana 不可用时降级(返回空分析)。
同时更新 Redis 状态status=analyzing + analysis。
"""
state = WorkflowState.from_dict(state_data)
store = _get_state_store()
await store.update(state.workflow_id, status=STATUS_ANALYZING)
data_ana_client: DataAnaClient | None = _clients.get("data_ana_client")
analysis: dict[str, Any] = {}
if data_ana_client is not None:
try:
performance = await data_ana_client.get_class_performance(
class_id=state.class_id,
subject_id=state.subject_id,
)
analysis["class_performance"] = {
"average_score": performance.average_score,
"pass_rate": performance.pass_rate,
"student_count": len(performance.scores),
}
analysis["weak_students"] = [
{"student_id": s.student_id, "score": s.score}
for s in performance.scores
if s.score < 60
]
except Exception as exc: # noqa: BLE001
logger.warning(
"activity_step1_analyze_degraded",
workflow_id=state.workflow_id,
error=str(exc),
)
analysis["degraded"] = True
analysis["degraded_reason"] = f"data-ana unavailable: {exc}"
else:
analysis["degraded"] = True
analysis["degraded_reason"] = "data-ana client not configured"
await store.update(state.workflow_id, analysis=analysis)
logger.info(
"activity_step1_completed",
workflow_id=state.workflow_id,
degraded=analysis.get("degraded", False),
)
return analysis
@activity.defn
async def recommend_activity(state_data: dict) -> list[dict]:
"""Step 2: 推荐知识点.
调 content 查询学习路径。content 不可用时降级(基于 topic 推导)。
"""
state = WorkflowState.from_dict(state_data)
content_client: ContentClient | None = _clients.get("content_client")
knowledge_points: list[dict[str, str]] = []
if content_client is not None:
try:
learning_path = await content_client.get_learning_path(
student_id=state.user_id,
subject_id=state.subject_id,
)
knowledge_points = [{"id": kp.id, "title": kp.title} for kp in learning_path]
except Exception as exc: # noqa: BLE001
logger.warning(
"activity_step2_recommend_degraded",
workflow_id=state.workflow_id,
error=str(exc),
)
# 降级:基于 topic 推导知识点
if not knowledge_points:
knowledge_points = [
{"id": "kp_default_1", "title": f"{state.topic} - 基础概念"},
{"id": "kp_default_2", "title": f"{state.topic} - 进阶应用"},
{"id": "kp_default_3", "title": f"{state.topic} - 综合题"},
]
logger.info(
"activity_step2_completed",
workflow_id=state.workflow_id,
knowledge_point_count=len(knowledge_points),
)
return knowledge_points
@activity.defn
async def generate_activity(
state_data: dict,
knowledge_points: list[dict],
) -> list[dict]:
"""Step 3: 生成题目.
使用 LLM 生成题目 + 评估三道防线。
评估未通过时重试(最多 MAX_GENERATE_RETRIES 次)。
返回 list[dict]GeneratedQuestionData.model_dump())。
"""
state = WorkflowState.from_dict(state_data)
failover_chain: ProviderFailoverChain | None = _clients.get("failover_chain")
prompt_service: PromptTemplateService | None = _clients.get("prompt_service")
quality_gate: QualityGate | None = _clients.get("quality_gate")
default_model: str = _clients.get("default_model", "gpt-4o-mini")
kp_ids = [kp["id"] for kp in knowledge_points]
if failover_chain is None or quality_gate is None:
logger.warning(
"activity_step3_degraded_no_llm",
workflow_id=state.workflow_id,
)
return [
GeneratedQuestionData(
question=f"[degraded] LLM 未配置,请手动添加题目:{state.topic}",
answer="",
explanation="LLM provider 或 quality_gate 未注入",
question_type="short_answer",
difficulty=state.target_difficulty,
knowledge_point_ids=kp_ids,
evaluation_score=0.0,
degraded=True,
degraded_reason="failover_chain or quality_gate not injected",
).model_dump(),
]
questions: list[GeneratedQuestionData] = []
for i in range(state.question_count):
question = await _generate_single_question(
state=state,
kp_ids=kp_ids,
question_index=i,
failover_chain=failover_chain,
prompt_service=prompt_service,
quality_gate=quality_gate,
default_model=default_model,
)
questions.append(question)
logger.info(
"activity_step3_completed",
workflow_id=state.workflow_id,
question_count=len(questions),
)
return [q.model_dump() for q in questions]
async def _generate_single_question(
state: WorkflowState,
kp_ids: list[str],
question_index: int,
failover_chain: ProviderFailoverChain,
prompt_service: PromptTemplateService | None,
quality_gate: QualityGate,
default_model: str,
) -> GeneratedQuestionData:
"""生成单道题目(含重试)."""
prompt = _render_generate_prompt(state, kp_ids, question_index, prompt_service)
messages = [
{"role": "system", "content": "你是一个专业的教育题目生成助手。"},
{"role": "user", "content": prompt},
]
for attempt in range(MAX_GENERATE_RETRIES):
try:
response = await failover_chain.chat(messages, default_model, 0.7)
evaluation = await quality_gate.evaluate(
llm_output=response.content,
expected_difficulty=state.target_difficulty,
expected_question_type="short_answer",
subject=state.subject_id,
)
parsed = evaluation.rule_result.parsed if evaluation.rule_result else None
if parsed and evaluation.passed:
return GeneratedQuestionData(
question=str(parsed.get("question", "")),
answer=str(parsed.get("answer", "")),
explanation=str(parsed.get("explanation", "")),
question_type=str(
parsed.get("question_type", "short_answer"),
),
difficulty=str(
parsed.get("difficulty", state.target_difficulty),
),
knowledge_point_ids=list(
parsed.get("knowledge_point_ids", kp_ids),
),
evaluation_score=evaluation.score,
)
logger.warning(
"activity_generate_retry",
workflow_id=state.workflow_id,
attempt=attempt + 1,
score=evaluation.score,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"activity_generate_error_retry",
workflow_id=state.workflow_id,
attempt=attempt + 1,
error=str(exc),
)
logger.warning(
"activity_generate_all_retries_failed",
workflow_id=state.workflow_id,
question_index=question_index,
)
return GeneratedQuestionData(
question=f"[degraded] 生成失败,请手动添加题目:{state.topic}",
answer="",
explanation="题目生成失败,已达到最大重试次数",
question_type="short_answer",
difficulty=state.target_difficulty,
knowledge_point_ids=kp_ids,
evaluation_score=0.0,
degraded=True,
degraded_reason="max retries exceeded",
)
def _render_generate_prompt(
state: WorkflowState,
kp_ids: list[str],
question_index: int,
prompt_service: PromptTemplateService | None,
) -> str:
"""渲染题目生成 prompt."""
if prompt_service is not None:
try:
return prompt_service.render(
"lesson_plan_generate",
{
"subject": state.subject_id,
"topic": state.topic,
"difficulty": state.target_difficulty,
"knowledge_points": kp_ids,
"knowledge_point_ids": kp_ids,
"question_index": question_index,
"analysis": state.analysis,
},
)
except Exception: # noqa: BLE001
pass
# 降级 prompt
return (
f"请为 {state.subject_id} 学科生成一道题目。\n"
f"主题:{state.topic}\n"
f"难度:{state.target_difficulty}\n"
f"知识点:{', '.join(kp_ids)}\n"
"请按 JSON 格式输出:"
'{"question":"...","answer":"...","explanation":"..."}'
)
@activity.defn
async def prepare_review_activity(
state_data: dict,
questions: list[dict],
) -> dict:
"""Step 4: 设置为待审核.
将生成的题目写入 Redis 状态,状态置为 pending_review。
"""
state = WorkflowState.from_dict(state_data)
store = _get_state_store()
questions_objs = [GeneratedQuestionData(**q) for q in questions]
await store.update(
state.workflow_id,
status=STATUS_PENDING_REVIEW,
questions=questions_objs,
)
logger.info(
"activity_step4_completed",
workflow_id=state.workflow_id,
question_count=len(questions_objs),
)
return {
"workflow_id": state.workflow_id,
"status": STATUS_PENDING_REVIEW,
"question_count": len(questions_objs),
}
@activity.defn
async def update_status_activity(payload: dict) -> dict:
"""更新 Redis 工作流状态(通用).
payload: {"workflow_id": str, "status": str}
"""
store = _get_state_store()
workflow_id = payload["workflow_id"]
status = payload["status"]
await store.update(workflow_id, status=status)
return {"workflow_id": workflow_id, "status": status}

View File

@@ -0,0 +1,80 @@
"""Temporal Worker 管理器v2.1 §8.2 ADR-030.
负责:
- 连接 Temporal Server
- 注入依赖到 Activitiesfailover_chain / content_client / data_ana_client 等)
- 启动 Worker注册 Workflow + Activities
- 优雅关闭
ai 服务作为 Temporal Workertask_queue=ai-lesson-plan。
"""
import asyncio
from typing import Any
import structlog
from temporalio.client import Client
from temporalio.worker import Worker
from . import activities, workflow
logger = structlog.get_logger()
class TemporalWorkerManager:
"""Temporal Worker 生命周期管理."""
def __init__(self, host: str, namespace: str, task_queue: str) -> None:
self._host = host
self._namespace = namespace
self._task_queue = task_queue
self._client: Client | None = None
self._worker: Worker | None = None
async def start(self, **clients: Any) -> Client:
"""启动 Worker.
Args:
**clients: 注入到 Activities 的依赖
- failover_chain: ProviderFailoverChain
- prompt_service: PromptTemplateService
- quality_gate: QualityGate
- content_client: ContentClient
- data_ana_client: DataAnaClient
- state_store: WorkflowStateStore
- default_model: str
Returns:
Temporal Client供 LessonPlanWorkflowService 启动 Workflow
"""
activities.set_clients(**clients)
self._client = await Client.connect(self._host, namespace=self._namespace)
self._worker = Worker(
self._client,
task_queue=self._task_queue,
workflows=[workflow.LessonPlanWorkflow],
activities=[
activities.analyze_activity,
activities.recommend_activity,
activities.generate_activity,
activities.prepare_review_activity,
activities.update_status_activity,
],
)
asyncio.create_task(self._worker.run())
logger.info(
"temporal_worker_started",
host=self._host,
namespace=self._namespace,
task_queue=self._task_queue,
)
return self._client
async def stop(self) -> None:
"""优雅关闭 Worker."""
if self._worker is not None:
await self._worker.shutdown()
logger.info("temporal_worker_stopped")
self._worker = None
self._client = None

View File

@@ -0,0 +1,69 @@
"""Temporal Workflow - 备课工作流编排v2.1 §8.2 ADR-030.
4 步编排(每步为独立 Activity可自动重试 + 状态持久化):
Step 1: 分析学情analyze_activity
Step 2: 推荐知识点recommend_activity
Step 3: 生成题目update_status_activity → generate_activity
Step 4: 设置待审核prepare_review_activity
边界约束spec §8.2):仅 AI 耗时工作流走 TemporalCRUD 短事务禁止。
"""
from datetime import timedelta
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from . import activities
@workflow.defn
class LessonPlanWorkflow:
"""备课工作流Temporal 编排)."""
@workflow.run
async def run(self, state_data: dict) -> dict:
"""执行 4 步编排.
Args:
state_data: WorkflowState.to_dict() 序列化数据
Returns:
{"workflow_id", "status", "question_count"}
"""
# Step 1: 分析学情
analysis = await workflow.execute_activity(
activities.analyze_activity,
state_data,
start_to_close_timeout=timedelta(seconds=300),
)
state_data["analysis"] = analysis
# Step 2: 推荐知识点
knowledge_points = await workflow.execute_activity(
activities.recommend_activity,
state_data,
start_to_close_timeout=timedelta(seconds=300),
)
# Step 3: 生成题目
await workflow.execute_activity(
activities.update_status_activity,
{"workflow_id": state_data["workflow_id"], "status": "generating"},
start_to_close_timeout=timedelta(seconds=10),
)
questions = await workflow.execute_activity(
activities.generate_activity,
state_data,
knowledge_points,
start_to_close_timeout=timedelta(seconds=600),
)
# Step 4: 设置待审核
result = await workflow.execute_activity(
activities.prepare_review_activity,
state_data,
questions,
start_to_close_timeout=timedelta(seconds=30),
)
return result

View File

@@ -10,6 +10,10 @@
P5 实现FastAPI BackgroundTasks + Redis 状态存储 P5 实现FastAPI BackgroundTasks + Redis 状态存储
P6+ 评估Temporal 工作流引擎 P6+ 评估Temporal 工作流引擎
v2.1 M6.5:迁移到 Temporal WorkflowADR-030
- temporal_client 可用时start_workflow 由 Temporal 编排
- temporal_client 不可用时:降级 asyncio.create_task开发模式
4 步编排: 4 步编排:
Step 1: 分析学情(调 data-ana.GetClassPerformance + GetStudentWeakness Step 1: 分析学情(调 data-ana.GetClassPerformance + GetStudentWeakness
Step 2: 推荐知识点(调 content.GetPrerequisites + GetLearningPath Step 2: 推荐知识点(调 content.GetPrerequisites + GetLearningPath
@@ -18,7 +22,7 @@ P6+ 评估Temporal 工作流引擎
""" """
import asyncio import asyncio
from typing import Any from typing import TYPE_CHECKING, Any
import structlog import structlog
@@ -31,6 +35,9 @@ from ..providers import ProviderFailoverChain
from ..services.evaluation import QualityGate from ..services.evaluation import QualityGate
from .state_store import WorkflowState, WorkflowStateStore from .state_store import WorkflowState, WorkflowStateStore
if TYPE_CHECKING:
from temporalio.client import Client
logger = structlog.get_logger() logger = structlog.get_logger()
# 状态常量(简化 proto WorkflowStatus内部用 # 状态常量(简化 proto WorkflowStatus内部用
@@ -51,8 +58,7 @@ ESTIMATED_COMPLETION_SECONDS = 60
class LessonPlanWorkflowService: class LessonPlanWorkflowService:
"""备课工作流服务. """备课工作流服务.
P5 使用 asyncio.create_task 在后台执行工作流 v2.1 M6.5temporal_client 可用时由 Temporal 编排,否则降级 asyncio.create_task。
P6+ 评估迁移到 Temporal。
""" """
def __init__( def __init__(
@@ -64,6 +70,8 @@ class LessonPlanWorkflowService:
content_client: ContentClient | None = None, content_client: ContentClient | None = None,
data_ana_client: DataAnaClient | None = None, data_ana_client: DataAnaClient | None = None,
default_model: str = "gpt-4o-mini", default_model: str = "gpt-4o-mini",
temporal_client: "Client | None" = None,
task_queue: str = "ai-lesson-plan",
) -> None: ) -> None:
self._store = state_store self._store = state_store
self._chain = failover_chain self._chain = failover_chain
@@ -73,6 +81,17 @@ class LessonPlanWorkflowService:
self._data_ana_client = data_ana_client self._data_ana_client = data_ana_client
self._default_model = default_model self._default_model = default_model
self._background_tasks: dict[str, asyncio.Task[Any]] = {} self._background_tasks: dict[str, asyncio.Task[Any]] = {}
self._temporal_client = temporal_client
self._task_queue = task_queue
def set_temporal_client(
self,
client: "Client",
task_queue: str = "ai-lesson-plan",
) -> None:
"""注入 Temporal Clientlifespan 中由 main.py 调用)."""
self._temporal_client = client
self._task_queue = task_queue
async def start( async def start(
self, self,
@@ -115,7 +134,18 @@ class LessonPlanWorkflowService:
) )
await self._store.create(state) await self._store.create(state)
# 启动后台任务执行工作流 if self._temporal_client is not None:
# v2.1 M6.5: 使用 Temporal Workflow 编排ADR-030
from ..temporal.workflow import LessonPlanWorkflow
await self._temporal_client.start_workflow(
LessonPlanWorkflow.run,
args=[state.to_dict()],
id=f"lesson-plan-{state.workflow_id}",
task_queue=self._task_queue,
)
else:
# 降级asyncio.create_task开发模式或 Temporal 不可用)
task = asyncio.create_task(self._run_workflow(state.workflow_id)) task = asyncio.create_task(self._run_workflow(state.workflow_id))
self._background_tasks[state.workflow_id] = task self._background_tasks[state.workflow_id] = task
@@ -124,6 +154,7 @@ class LessonPlanWorkflowService:
workflow_id=state.workflow_id, workflow_id=state.workflow_id,
user_id=user_id, user_id=user_id,
topic=topic, topic=topic,
engine="temporal" if self._temporal_client is not None else "asyncio",
) )
return state return state
@@ -361,10 +392,7 @@ class LessonPlanWorkflowService:
student_id=state.user_id, student_id=state.user_id,
subject_id=state.subject_id, subject_id=state.subject_id,
) )
knowledge_points = [ knowledge_points = [{"id": kp.id, "title": kp.title} for kp in learning_path]
{"id": kp.id, "title": kp.title}
for kp in learning_path
]
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning( logger.warning(
"workflow_step2_recommend_degraded", "workflow_step2_recommend_degraded",
@@ -444,11 +472,7 @@ class LessonPlanWorkflowService:
subject=state.subject_id, subject=state.subject_id,
) )
parsed = ( parsed = evaluation.rule_result.parsed if evaluation.rule_result else None
evaluation.rule_result.parsed
if evaluation.rule_result
else None
)
if parsed and evaluation.passed: if parsed and evaluation.passed:
return GeneratedQuestionData( return GeneratedQuestionData(