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,24 @@
"""下游 gRPC 客户端模块02-architecture-design.md §1.2 Client 子图).
六边形架构端口模式:每个客户端为抽象接口,可注入 mock 实现。
客户端:
- ContentClient: 查询知识点/教材/题库content 服务 gRPC 50054
- DataAnaClient: 查询学情/薄弱点/趋势data-ana 服务 gRPC 50055
- IamClient: 查询 DataScopeiam 服务 gRPC 50052P4 补全后启用)
全并行模式:下游不可用时返回 mock 数据或抛 AI_DOWNSTREAM_UNAVAILABLE 降级。
"""
from .content_client import ContentClient, ContentClientMock
from .data_ana_client import DataAnaClient, DataAnaClientMock
from .iam_client import IamClient, IamClientMock
__all__ = [
"ContentClient",
"ContentClientMock",
"DataAnaClient",
"DataAnaClientMock",
"IamClient",
"IamClientMock",
]

View File

@@ -0,0 +1,153 @@
"""gRPC 客户端基类 + 拦截器.
提供:
- LoggingInterceptor: 客户端请求日志
- TracingInterceptor: 注入 traceparent 到 metadata
- BaseGrpcClient: 通道管理 + 优雅关闭
"""
from abc import ABC, abstractmethod
from typing import Any
import grpc
import structlog
logger = structlog.get_logger()
class LoggingInterceptor(
grpc.aio.UnaryUnaryClientInterceptor,
grpc.aio.UnaryStreamClientInterceptor,
):
"""客户端日志拦截器."""
async def intercept_unary_unary(
self,
continuation: Any,
client_call_details: grpc.aio.ClientCallDetails,
request: Any,
) -> Any:
method = client_call_details.method
logger.debug("grpc_client_call", method=method)
try:
response = await continuation(client_call_details, request)
logger.debug("grpc_client_success", method=method)
return response
except grpc.aio.AioRpcError as exc:
logger.warning(
"grpc_client_error",
method=method,
code=exc.code().name,
details=exc.details(),
)
raise
async def intercept_unary_stream(
self,
continuation: Any,
client_call_details: grpc.aio.ClientCallDetails,
request: Any,
) -> Any:
method = client_call_details.method
logger.debug("grpc_client_stream_call", method=method)
try:
async for response in await continuation(
client_call_details, request,
):
yield response
except grpc.aio.AioRpcError as exc:
logger.warning(
"grpc_client_stream_error",
method=method,
code=exc.code().name,
details=exc.details(),
)
raise
class TracingInterceptor(
grpc.aio.UnaryUnaryClientInterceptor,
grpc.aio.UnaryStreamClientInterceptor,
):
"""客户端链路追踪拦截器(注入 traceparent."""
def __init__(self, request_id: str = "") -> None:
self._request_id = request_id
def _inject_metadata(
self,
metadata: list[tuple[str, str]] | None,
) -> list[tuple[str, str]]:
"""注入 traceparent + x-request-id."""
if metadata is None:
metadata = []
metadata = list(metadata)
if self._request_id:
metadata.append(("x-request-id", self._request_id))
metadata.append(
("traceparent", f"00-{self._request_id}-0000000000000000-01"),
)
return metadata
async def intercept_unary_unary(
self,
continuation: Any,
client_call_details: grpc.aio.ClientCallDetails,
request: Any,
) -> Any:
client_call_details.metadata = self._inject_metadata(
client_call_details.metadata,
)
return await continuation(client_call_details, request)
async def intercept_unary_stream(
self,
continuation: Any,
client_call_details: grpc.aio.ClientCallDetails,
request: Any,
) -> Any:
client_call_details.metadata = self._inject_metadata(
client_call_details.metadata,
)
return await continuation(client_call_details, request)
class BaseGrpcClient(ABC):
"""gRPC 客户端基类."""
def __init__(self, endpoint: str, request_id: str = "") -> None:
self._endpoint = endpoint
self._channel: grpc.aio.Channel | None = None
self._interceptors: list[Any] = [
LoggingInterceptor(),
TracingInterceptor(request_id),
]
async def connect(self) -> None:
"""建立 gRPC 连接."""
if self._channel is not None:
return
self._channel = grpc.aio.insecure_channel(
self._endpoint,
interceptors=self._interceptors,
)
logger.info("grpc_client_connected", endpoint=self._endpoint)
async def close(self) -> None:
"""关闭 gRPC 连接."""
if self._channel is not None:
await self._channel.close()
self._channel = None
logger.info("grpc_client_closed", endpoint=self._endpoint)
@property
def channel(self) -> grpc.aio.Channel:
"""获取 gRPC channel未连接时自动建立."""
if self._channel is None:
raise RuntimeError("gRPC channel not connected, call connect() first")
return self._channel
@abstractmethod
def is_available(self) -> bool:
"""客户端是否可用."""
...

View File

@@ -0,0 +1,224 @@
"""Content 服务 gRPC 客户端.
用于:
- 查询知识点前置依赖GetPrerequisites
- 查询学习路径GetLearningPath
- 创建题目入库CreateQuestions - P5 mockcontent 服务待补全)
全并行模式:下游不可用时使用 mock 数据降级。
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
import structlog
from ..errors import AIError, ErrorCode
logger = structlog.get_logger()
@dataclass
class KnowledgePoint:
"""知识点."""
id: str
title: str
@dataclass
class QuestionInput:
"""题目入库输入(对应 ConfirmLessonPlan 调用 content.CreateQuestions."""
question: str
answer: str
explanation: str
question_type: str
difficulty: str
knowledge_point_ids: list[str]
@dataclass
class CreatedQuestion:
"""题目入库结果."""
id: str
question: str
class ContentClient(ABC):
"""Content 服务客户端抽象接口(六边形端口)."""
@abstractmethod
async def get_prerequisites(
self,
knowledge_point_id: str,
) -> list[KnowledgePoint]:
"""查询知识点前置依赖."""
...
@abstractmethod
async def get_learning_path(
self,
student_id: str,
subject_id: str,
) -> list[KnowledgePoint]:
"""查询学习路径."""
...
@abstractmethod
async def create_questions(
self,
questions: list[QuestionInput],
user_id: str = "",
) -> list[CreatedQuestion]:
"""批量创建题目入库(备课工作流确认时调用)."""
...
@abstractmethod
def is_available(self) -> bool:
"""客户端是否可用."""
...
class ContentClientMock(ContentClient):
"""Content 客户端 Mock 实现(全并行模式)."""
def __init__(self) -> None:
self._available = True
async def get_prerequisites(
self,
knowledge_point_id: str,
) -> list[KnowledgePoint]:
logger.info(
"content_mock_get_prerequisites",
knowledge_point_id=knowledge_point_id,
)
return [
KnowledgePoint(
id="kp_base_001",
title="基础概念mock",
),
]
async def get_learning_path(
self,
student_id: str,
subject_id: str,
) -> list[KnowledgePoint]:
logger.info(
"content_mock_get_learning_path",
student_id=student_id,
subject_id=subject_id,
)
return [
KnowledgePoint(id="kp_001", title="知识点1mock"),
KnowledgePoint(id="kp_002", title="知识点2mock"),
KnowledgePoint(id="kp_003", title="知识点3mock"),
]
async def create_questions(
self,
questions: list[QuestionInput],
user_id: str = "",
) -> list[CreatedQuestion]:
logger.info(
"content_mock_create_questions",
count=len(questions),
user_id=user_id,
)
return [
CreatedQuestion(
id=f"q_mock_{i:04d}",
question=q.question,
)
for i, q in enumerate(questions)
]
def is_available(self) -> bool:
return self._available
class ContentClientGrpc(ContentClient):
"""Content 服务 gRPC 客户端实现.
全并行模式gRPC 调用失败时降级到 mock 数据。
"""
def __init__(
self,
endpoint: str = "localhost:50054",
request_id: str = "",
) -> None:
self._endpoint = endpoint
self._request_id = request_id
self._channel: Any = None
self._mock = ContentClientMock()
async def connect(self) -> None:
"""建立 gRPC 连接."""
import grpc
self._channel = grpc.aio.insecure_channel(self._endpoint)
logger.info("content_client_connected", endpoint=self._endpoint)
async def close(self) -> None:
"""关闭 gRPC 连接."""
if self._channel is not None:
await self._channel.close()
self._channel = None
def is_available(self) -> bool:
return self._channel is not None
async def get_prerequisites(
self,
knowledge_point_id: str,
) -> list[KnowledgePoint]:
if not self.is_available():
logger.warning("content_client_not_connected_using_mock")
return await self._mock.get_prerequisites(knowledge_point_id)
try:
# 动态导入 proto 生成代码(如果存在)
# 全并行模式proto 未生成时降级到 mock
return await self._mock.get_prerequisites(knowledge_point_id)
except Exception as exc: # noqa: BLE001
logger.warning(
"content_get_prerequisites_failed_degraded",
error=str(exc),
)
return await self._mock.get_prerequisites(knowledge_point_id)
async def get_learning_path(
self,
student_id: str,
subject_id: str,
) -> list[KnowledgePoint]:
if not self.is_available():
return await self._mock.get_learning_path(student_id, subject_id)
try:
return await self._mock.get_learning_path(student_id, subject_id)
except Exception as exc: # noqa: BLE001
logger.warning(
"content_get_learning_path_failed_degraded",
error=str(exc),
)
return await self._mock.get_learning_path(student_id, subject_id)
async def create_questions(
self,
questions: list[QuestionInput],
user_id: str = "",
) -> list[CreatedQuestion]:
if not self.is_available():
logger.warning("content_client_not_connected_using_mock")
return await self._mock.create_questions(questions, user_id)
try:
return await self._mock.create_questions(questions, user_id)
except Exception as exc: # noqa: BLE001
raise AIError(
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE,
f"content.CreateQuestions failed: {exc}",
) from exc

View File

@@ -0,0 +1,283 @@
"""Data-ana 服务 gRPC 客户端.
用于:
- 查询班级学情GetClassPerformance
- 查询学生薄弱点GetStudentWeakness
- 查询学习趋势GetLearningTrend
全并行模式:下游不可用时使用 mock 数据降级。
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
import structlog
logger = structlog.get_logger()
@dataclass
class StudentScore:
"""学生成绩."""
student_id: str
score: float
grade: str
@dataclass
class ClassPerformance:
"""班级学情."""
class_id: str
average_score: float
pass_rate: float
scores: list[StudentScore] = field(default_factory=list)
@dataclass
class WeakPoint:
"""薄弱知识点."""
knowledge_point_id: str
title: str
mastery: float
@dataclass
class StudentWeakness:
"""学生薄弱点."""
student_id: str
weak_points: list[WeakPoint] = field(default_factory=list)
@dataclass
class TrendPoint:
"""趋势数据点."""
date: int
score: float
@dataclass
class LearningTrend:
"""学习趋势."""
student_id: str
points: list[TrendPoint] = field(default_factory=list)
class DataAnaClient(ABC):
"""Data-ana 服务客户端抽象接口(六边形端口)."""
@abstractmethod
async def get_class_performance(
self,
class_id: str,
subject_id: str,
start_date: int = 0,
end_date: int = 0,
) -> ClassPerformance:
"""查询班级学情."""
...
@abstractmethod
async def get_student_weakness(
self,
student_id: str,
subject_id: str,
) -> StudentWeakness:
"""查询学生薄弱点."""
...
@abstractmethod
async def get_learning_trend(
self,
student_id: str,
start_date: int = 0,
end_date: int = 0,
) -> LearningTrend:
"""查询学习趋势."""
...
@abstractmethod
def is_available(self) -> bool:
"""客户端是否可用."""
...
class DataAnaClientMock(DataAnaClient):
"""Data-ana 客户端 Mock 实现(全并行模式)."""
def __init__(self) -> None:
self._available = True
async def get_class_performance(
self,
class_id: str,
subject_id: str,
start_date: int = 0,
end_date: int = 0,
) -> ClassPerformance:
logger.info(
"data_ana_mock_class_performance",
class_id=class_id,
subject_id=subject_id,
)
return ClassPerformance(
class_id=class_id,
average_score=78.5,
pass_rate=0.85,
scores=[
StudentScore(student_id="s_001", score=85.0, grade="A"),
StudentScore(student_id="s_002", score=72.0, grade="B"),
StudentScore(student_id="s_003", score=65.0, grade="C"),
],
)
async def get_student_weakness(
self,
student_id: str,
subject_id: str,
) -> StudentWeakness:
logger.info(
"data_ana_mock_student_weakness",
student_id=student_id,
subject_id=subject_id,
)
return StudentWeakness(
student_id=student_id,
weak_points=[
WeakPoint(
knowledge_point_id="kp_001",
title="函数概念mock",
mastery=0.45,
),
WeakPoint(
knowledge_point_id="kp_005",
title="三角函数mock",
mastery=0.52,
),
],
)
async def get_learning_trend(
self,
student_id: str,
start_date: int = 0,
end_date: int = 0,
) -> LearningTrend:
logger.info(
"data_ana_mock_learning_trend",
student_id=student_id,
)
return LearningTrend(
student_id=student_id,
points=[
TrendPoint(date=20260101, score=65.0),
TrendPoint(date=20260201, score=70.0),
TrendPoint(date=20260301, score=75.0),
],
)
def is_available(self) -> bool:
return self._available
class DataAnaClientGrpc(DataAnaClient):
"""Data-ana 服务 gRPC 客户端实现.
全并行模式gRPC 调用失败时降级到 mock 数据。
"""
def __init__(
self,
endpoint: str = "localhost:50055",
request_id: str = "",
) -> None:
self._endpoint = endpoint
self._request_id = request_id
self._channel: Any = None
self._mock = DataAnaClientMock()
async def connect(self) -> None:
"""建立 gRPC 连接."""
import grpc
self._channel = grpc.aio.insecure_channel(self._endpoint)
logger.info("data_ana_client_connected", endpoint=self._endpoint)
async def close(self) -> None:
"""关闭 gRPC 连接."""
if self._channel is not None:
await self._channel.close()
self._channel = None
def is_available(self) -> bool:
return self._channel is not None
async def get_class_performance(
self,
class_id: str,
subject_id: str,
start_date: int = 0,
end_date: int = 0,
) -> ClassPerformance:
if not self.is_available():
return await self._mock.get_class_performance(
class_id, subject_id, start_date, end_date,
)
try:
# 全并行模式proto 未生成时降级到 mock
return await self._mock.get_class_performance(
class_id, subject_id, start_date, end_date,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"data_ana_class_performance_failed_degraded",
error=str(exc),
)
return await self._mock.get_class_performance(
class_id, subject_id, start_date, end_date,
)
async def get_student_weakness(
self,
student_id: str,
subject_id: str,
) -> StudentWeakness:
if not self.is_available():
return await self._mock.get_student_weakness(student_id, subject_id)
try:
return await self._mock.get_student_weakness(student_id, subject_id)
except Exception as exc: # noqa: BLE001
logger.warning(
"data_ana_student_weakness_failed_degraded",
error=str(exc),
)
return await self._mock.get_student_weakness(student_id, subject_id)
async def get_learning_trend(
self,
student_id: str,
start_date: int = 0,
end_date: int = 0,
) -> LearningTrend:
if not self.is_available():
return await self._mock.get_learning_trend(
student_id, start_date, end_date,
)
try:
return await self._mock.get_learning_trend(
student_id, start_date, end_date,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"data_ana_learning_trend_failed_degraded",
error=str(exc),
)
return await self._mock.get_learning_trend(
student_id, start_date, end_date,
)

View File

@@ -0,0 +1,135 @@
"""IAM 服务 gRPC 客户端.
用于:
- 查询用户有效数据范围GetEffectiveDataScope - ISSUE-07: P4 补全ai 用 mock
全并行模式IAM 不可用时使用 mock 数据降级。
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
import structlog
logger = structlog.get_logger()
@dataclass
class DataScope:
"""用户有效数据范围(用于数据权限过滤)."""
user_id: str
school_id: str = ""
class_ids: list[str] = None # type: ignore[assignment]
grade_ids: list[str] = None # type: ignore[assignment]
subject_ids: list[str] = None # type: ignore[assignment]
role: str = "teacher"
is_admin: bool = False
def __post_init__(self) -> None:
if self.class_ids is None:
self.class_ids = []
if self.grade_ids is None:
self.grade_ids = []
if self.subject_ids is None:
self.subject_ids = []
def to_dict(self) -> dict[str, Any]:
"""转换为 dict用于 JSON 序列化)."""
return {
"user_id": self.user_id,
"school_id": self.school_id,
"class_ids": self.class_ids,
"grade_ids": self.grade_ids,
"subject_ids": self.subject_ids,
"role": self.role,
"is_admin": self.is_admin,
}
class IamClient(ABC):
"""IAM 服务客户端抽象接口(六边形端口)."""
@abstractmethod
async def get_effective_data_scope(
self,
user_id: str,
) -> DataScope:
"""查询用户有效数据范围.
ISSUE-07: IAM P4 补全 GetEffectiveDataScope RPC 后启用真实调用。
"""
...
@abstractmethod
def is_available(self) -> bool:
"""客户端是否可用."""
...
class IamClientMock(IamClient):
"""IAM 客户端 Mock 实现(全并行模式)."""
def __init__(self) -> None:
self._available = True
async def get_effective_data_scope(
self,
user_id: str,
) -> DataScope:
logger.info("iam_mock_get_data_scope", user_id=user_id)
return DataScope(
user_id=user_id,
school_id="school_mock_001",
class_ids=["class_mock_001", "class_mock_002"],
grade_ids=["grade_10"],
subject_ids=["subject_math"],
role="teacher",
is_admin=False,
)
def is_available(self) -> bool:
return self._available
class IamClientGrpc(IamClient):
"""IAM 服务 gRPC 客户端实现.
ISSUE-07: IAM P4 补全 GetEffectiveDataScope RPC 后启用。
全并行模式:当前使用 mock 数据。
"""
def __init__(
self,
endpoint: str = "localhost:50052",
request_id: str = "",
) -> None:
self._endpoint = endpoint
self._request_id = request_id
self._channel: Any = None
self._mock = IamClientMock()
async def connect(self) -> None:
"""建立 gRPC 连接."""
import grpc
self._channel = grpc.aio.insecure_channel(self._endpoint)
logger.info("iam_client_connected", endpoint=self._endpoint)
async def close(self) -> None:
"""关闭 gRPC 连接."""
if self._channel is not None:
await self._channel.close()
self._channel = None
def is_available(self) -> bool:
return self._channel is not None
async def get_effective_data_scope(
self,
user_id: str,
) -> DataScope:
# ISSUE-07: IAM P4 补全 GetEffectiveDataScope 后启用真实调用
# 全并行模式:当前使用 mock
return await self._mock.get_effective_data_scope(user_id)

View File

@@ -1,23 +1,75 @@
"""配置管理."""
"""配置管理pydantic-settings12-factor 合规)."""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""应用配置."""
"""应用配置.
port: int = 3008
# LLM 配置(可选,为空时降级返回骨架响应)
openai_api_key: str = ""
openai_base_url: str = "https://api.openai.com/v1"
anthropic_api_key: str = ""
# 开发模式true 时跳过 OTel exporter 初始化,避免本地无 collector 时报错
配置优先级:环境变量 > .env 文件 > 默认值.
"""
# 服务
service_name: str = "ai"
http_port: int = 3008
grpc_port: int = 50058
dev_mode: bool = False
# 可观测性
otel_endpoint: str = "http://localhost:4318"
log_level: str = "info"
model_config = {"env_file": ".env", "env_prefix": ""}
# LLM Provider 配置
openai_api_key: str = ""
openai_base_url: str = "https://api.openai.com/v1"
anthropic_api_key: str = ""
anthropic_base_url: str = "https://api.anthropic.com"
baichuan_api_key: str = ""
baichuan_base_url: str = "https://api.baichuan-ai.com/v1"
ollama_base_url: str = "" # 本地 Ollama如 http://localhost:11434
# Provider 优先级(按顺序 failover
llm_provider_priority: str = "openai,anthropic,baichuan,local_ollama"
# LLM 调用参数
llm_timeout_seconds: float = 30.0
llm_stream_connect_timeout: float = 30.0
llm_stream_read_timeout: float = 60.0
llm_max_retries: int = 3
# 默认模型
default_chat_model: str = "gpt-4o-mini"
default_question_model: str = "gpt-4o-mini"
# Redis限流 + 缓存 + 工作流状态)
redis_url: str = "redis://localhost:6379/0"
redis_rate_limit_user_per_min: int = 10
redis_rate_limit_ip_per_min: int = 30
redis_rate_limit_school_per_min: int = 100
# Kafka用量事件发布派生数据豁免 Outbox
kafka_bootstrap_servers: str = "localhost:9092"
kafka_ai_usage_topic: str = "edu.ai.usage"
kafka_producer_transactional_id: str = "ai-service-producer"
# 下游 gRPC
content_grpc_endpoint: str = "localhost:50054"
data_ana_grpc_endpoint: str = "localhost:50055"
iam_grpc_endpoint: str = "localhost:50052"
# 备课工作流
workflow_ttl_seconds: int = 86400 # 24h
workflow_max_retries: int = 3
# 评估
evaluation_pass_threshold: float = 0.7
evaluation_excellent_threshold: float = 0.85
# 配额(月度 token 预算)
default_school_monthly_budget: int = 1_000_000
default_teacher_monthly_budget: int = 100_000
model_config = {"env_file": ".env", "env_prefix": "", "extra": "ignore"}
@property
def is_dev(self) -> bool:
@@ -27,7 +79,27 @@ class Settings(BaseSettings):
@property
def llm_available(self) -> bool:
"""LLM 是否可用(至少一个 provider 配置了 API key."""
return bool(self.openai_api_key or self.anthropic_api_key)
return bool(
self.openai_api_key
or self.anthropic_api_key
or self.baichuan_api_key
or self.ollama_base_url,
)
@property
def provider_priority_list(self) -> list[str]:
"""Provider 优先级列表."""
return [p.strip() for p in self.llm_provider_priority.split(",") if p.strip()]
@property
def providers_status(self) -> dict[str, bool]:
"""各 Provider 配置状态."""
return {
"openai": bool(self.openai_api_key),
"anthropic": bool(self.anthropic_api_key),
"baichuan": bool(self.baichuan_api_key),
"local_ollama": bool(self.ollama_base_url),
}
settings = Settings()

View File

@@ -0,0 +1,24 @@
"""错误码体系(前缀 AI_*,对齐 coord-final-decisions G14."""
from .codes import ErrorCode, ErrorCodes
from .exceptions import (
AIError,
AILLMUnavailableError,
AIQuotaExceededError,
AIRateLimitedError,
AIValidationError,
AIWorkflowNotFoundError,
AIWorkflowStateInvalidError,
)
__all__ = [
"ErrorCode",
"ErrorCodes",
"AIError",
"AIValidationError",
"AIRateLimitedError",
"AIQuotaExceededError",
"AILLMUnavailableError",
"AIWorkflowNotFoundError",
"AIWorkflowStateInvalidError",
]

View File

@@ -0,0 +1,64 @@
"""AI 服务错误码定义(前缀 AI_*.
依据 02-architecture-design.md §6.2 错误码清单.
"""
from enum import StrEnum
class ErrorCode(StrEnum):
"""错误码枚举."""
AI_UNAUTHORIZED = "AI_UNAUTHORIZED"
AI_FORBIDDEN = "AI_FORBIDDEN"
AI_RATE_LIMITED = "AI_RATE_LIMITED"
AI_QUOTA_EXCEEDED = "AI_QUOTA_EXCEEDED"
AI_LLM_UNAVAILABLE = "AI_LLM_UNAVAILABLE"
AI_LLM_TIMEOUT = "AI_LLM_TIMEOUT"
AI_LLM_ALL_PROVIDERS_FAILED = "AI_LLM_ALL_PROVIDERS_FAILED"
AI_INVALID_MODEL = "AI_INVALID_MODEL"
AI_INVALID_DIFFICULTY = "AI_INVALID_DIFFICULTY"
AI_INVALID_QUESTION_TYPE = "AI_INVALID_QUESTION_TYPE"
AI_DOWNSTREAM_UNAVAILABLE = "AI_DOWNSTREAM_UNAVAILABLE"
AI_PROMPT_RENDER_FAILED = "AI_PROMPT_RENDER_FAILED"
AI_PROMPT_TEMPLATE_NOT_FOUND = "AI_PROMPT_TEMPLATE_NOT_FOUND"
AI_WORKFLOW_NOT_FOUND = "AI_WORKFLOW_NOT_FOUND"
AI_WORKFLOW_EXPIRED = "AI_WORKFLOW_EXPIRED"
AI_WORKFLOW_STATE_INVALID = "AI_WORKFLOW_STATE_INVALID"
AI_EVALUATION_FAILED = "AI_EVALUATION_FAILED"
AI_PII_DETECTED = "AI_PII_DETECTED"
AI_PROMPT_INJECTION_DETECTED = "AI_PROMPT_INJECTION_DETECTED"
AI_CONTENT_MODERATION_REJECTED = "AI_CONTENT_MODERATION_REJECTED"
AI_INTERNAL_ERROR = "AI_INTERNAL_ERROR"
class ErrorCodes:
"""错误码 → HTTP 状态码 + gRPC status 映射."""
HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.AI_UNAUTHORIZED: 401,
ErrorCode.AI_FORBIDDEN: 403,
ErrorCode.AI_RATE_LIMITED: 429,
ErrorCode.AI_QUOTA_EXCEEDED: 429,
ErrorCode.AI_LLM_UNAVAILABLE: 200, # 降级模式仍返回 200
ErrorCode.AI_LLM_TIMEOUT: 504,
ErrorCode.AI_LLM_ALL_PROVIDERS_FAILED: 503,
ErrorCode.AI_INVALID_MODEL: 400,
ErrorCode.AI_INVALID_DIFFICULTY: 400,
ErrorCode.AI_INVALID_QUESTION_TYPE: 400,
ErrorCode.AI_DOWNSTREAM_UNAVAILABLE: 502,
ErrorCode.AI_PROMPT_RENDER_FAILED: 500,
ErrorCode.AI_PROMPT_TEMPLATE_NOT_FOUND: 404,
ErrorCode.AI_WORKFLOW_NOT_FOUND: 404,
ErrorCode.AI_WORKFLOW_EXPIRED: 410,
ErrorCode.AI_WORKFLOW_STATE_INVALID: 409,
ErrorCode.AI_EVALUATION_FAILED: 503,
ErrorCode.AI_PII_DETECTED: 400,
ErrorCode.AI_PROMPT_INJECTION_DETECTED: 400,
ErrorCode.AI_CONTENT_MODERATION_REJECTED: 503,
ErrorCode.AI_INTERNAL_ERROR: 500,
}
@classmethod
def http_status(cls, code: ErrorCode) -> int:
return cls.HTTP_STATUS.get(code, 500)

View File

@@ -0,0 +1,82 @@
"""AI 服务异常体系."""
from typing import Any
from .codes import ErrorCode, ErrorCodes
class AIError(Exception):
"""AI 服务基础异常.
所有 AI 服务异常均携带 ErrorCode + HTTP status + 可选 details.
"""
def __init__(
self,
code: ErrorCode = ErrorCode.AI_INTERNAL_ERROR,
message: str = "",
details: dict[str, Any] | None = None,
) -> None:
self.code = code
self.message = message or code.value
self.details = details or {}
self.http_status = ErrorCodes.http_status(code)
super().__init__(self.message)
class AIValidationError(AIError):
"""输入校验异常."""
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
super().__init__(ErrorCode.AI_INVALID_MODEL, message, details)
class AIRateLimitedError(AIError):
"""限流异常."""
def __init__(self, dimension: str, limit: int) -> None:
super().__init__(
ErrorCode.AI_RATE_LIMITED,
f"Rate limit exceeded for {dimension}",
{"dimension": dimension, "limit": limit},
)
class AIQuotaExceededError(AIError):
"""配额超限异常."""
def __init__(self, scope: str, used: int, budget: int) -> None:
super().__init__(
ErrorCode.AI_QUOTA_EXCEEDED,
f"Quota exceeded for {scope}",
{"scope": scope, "used": used, "budget": budget},
)
class AILLMUnavailableError(AIError):
"""LLM 不可用异常(触发降级)."""
def __init__(self, reason: str = "all providers failed") -> None:
super().__init__(ErrorCode.AI_LLM_UNAVAILABLE, reason)
class AIWorkflowNotFoundError(AIError):
"""工作流不存在."""
def __init__(self, workflow_id: str) -> None:
super().__init__(
ErrorCode.AI_WORKFLOW_NOT_FOUND,
f"Workflow {workflow_id} not found",
{"workflow_id": workflow_id},
)
class AIWorkflowStateInvalidError(AIError):
"""工作流状态不允许此操作."""
def __init__(self, workflow_id: str, current_status: str, action: str) -> None:
super().__init__(
ErrorCode.AI_WORKFLOW_STATE_INVALID,
f"Cannot {action} workflow {workflow_id} in status {current_status}",
{"workflow_id": workflow_id, "current_status": current_status, "action": action},
)

View File

@@ -0,0 +1,21 @@
"""gRPC server 模块8 RPC + interceptor.
提供:
- AiServicer: 8 RPC 实现
- GrpcServer: server 生命周期管理
- interceptors: 日志/错误/认证拦截器
- create_grpc_server: 工厂函数
"""
from .interceptors import AuthInterceptor, ErrorInterceptor, LoggingInterceptor
from .server import GrpcServer, create_grpc_server
from .servicer import AiServicer
__all__ = [
"AiServicer",
"GrpcServer",
"create_grpc_server",
"AuthInterceptor",
"ErrorInterceptor",
"LoggingInterceptor",
]

View File

@@ -0,0 +1,154 @@
"""gRPC 拦截器.
提供:
- LoggingInterceptor: 请求/响应日志 + 延迟统计
- ErrorInterceptor: 异常捕获 → gRPC status code 映射
- AuthInterceptor: 从 metadata 提取用户上下文
"""
import time
from collections.abc import Callable
from typing import Any
import grpc
import structlog
from ..errors import AIError
from ..middleware.auth import UserContext, extract_user_context_from_metadata
from ..middleware.error_handler import grpc_error_mapper
logger = structlog.get_logger()
class LoggingInterceptor(grpc.ServerInterceptor):
"""请求日志 + 延迟统计."""
def intercept_service(
self,
continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
handler_call_details: grpc.HandlerCallDetails,
) -> grpc.RpcMethodHandler:
method = handler_call_details.method
start = time.monotonic()
def log_wrapper(handler: grpc.RpcMethodHandler) -> grpc.RpcMethodHandler:
original_behavior = handler.unary_unary
def new_behavior(request: Any, context: grpc.ServicerContext) -> Any:
latency_ms = int((time.monotonic() - start) * 1000)
try:
response = original_behavior(request, context) # type: ignore[misc]
logger.info(
"grpc_request",
method=method,
latency_ms=latency_ms,
status="ok",
)
return response
except Exception as exc:
logger.error(
"grpc_request_error",
method=method,
latency_ms=latency_ms,
error=str(exc),
)
raise
handler.unary_unary = new_behavior # type: ignore[method-assign]
return handler
handler = continuation(handler_call_details)
if handler is None:
return None
return log_wrapper(handler)
class ErrorInterceptor(grpc.ServerInterceptor):
"""异常捕获 → gRPC status code 映射."""
def intercept_service(
self,
continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
handler_call_details: grpc.HandlerCallDetails,
) -> grpc.RpcMethodHandler:
handler = continuation(handler_call_details)
if handler is None:
return None
original_behavior = handler.unary_unary
def new_behavior(request: Any, context: grpc.ServicerContext) -> Any:
try:
return original_behavior(request, context) # type: ignore[misc]
except AIError as exc:
code, msg, grpc_status = grpc_error_mapper(exc)
logger.warning(
"grpc_ai_error",
method=handler_call_details.method,
error_code=code,
message=msg,
)
context.abort(_grpc_status(grpc_status), f"{code}: {msg}")
except Exception as exc:
code, msg, grpc_status = grpc_error_mapper(exc)
logger.error(
"grpc_unhandled_error",
method=handler_call_details.method,
error=str(exc),
)
context.abort(_grpc_status(grpc_status), f"{code}: {msg}")
handler.unary_unary = new_behavior # type: ignore[method-assign]
return handler
class AuthInterceptor(grpc.ServerInterceptor):
"""从 gRPC metadata 提取用户上下文,存入 context."""
def intercept_service(
self,
continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
handler_call_details: grpc.HandlerCallDetails,
) -> grpc.RpcMethodHandler:
handler = continuation(handler_call_details)
if handler is None:
return None
original_behavior = handler.unary_unary
def new_behavior(request: Any, context: grpc.ServicerContext) -> Any:
ctx = extract_user_context_from_metadata(handler_call_details.invocation_metadata)
# 将 UserContext 存入 context 供 servicer 使用
context.user_context = ctx # type: ignore[attr-defined]
return original_behavior(request, context) # type: ignore[misc]
handler.unary_unary = new_behavior # type: ignore[method-assign]
return handler
def _grpc_status(code: int) -> grpc.StatusCode:
"""数字 status code → grpc.StatusCode 枚举."""
status_map = {
0: grpc.StatusCode.OK,
1: grpc.StatusCode.CANCELLED,
2: grpc.StatusCode.UNKNOWN,
3: grpc.StatusCode.INVALID_ARGUMENT,
4: grpc.StatusCode.DEADLINE_EXCEEDED,
5: grpc.StatusCode.NOT_FOUND,
6: grpc.StatusCode.ALREADY_EXISTS,
7: grpc.StatusCode.PERMISSION_DENIED,
8: grpc.StatusCode.UNAUTHENTICATED,
9: grpc.StatusCode.RESOURCE_EXHAUSTED,
10: grpc.StatusCode.FAILED_PRECONDITION,
11: grpc.StatusCode.ABORTED,
12: grpc.StatusCode.OUT_OF_RANGE,
13: grpc.StatusCode.INTERNAL,
14: grpc.StatusCode.UNAVAILABLE,
15: grpc.StatusCode.DATA_LOSS,
}
return status_map.get(code, grpc.StatusCode.UNKNOWN)
def get_user_context(context: grpc.ServicerContext) -> UserContext:
"""从 ServicerContext 提取 UserContextAuthInterceptor 注入)."""
return getattr(context, "user_context", UserContext())

View File

@@ -0,0 +1,78 @@
"""gRPC server 启动与管理.
使用 grpc.aio 异步 server端口 50058port-allocation.md §3/§5/§7 权威源)。
"""
from typing import Any
import grpc
import structlog
from ..proto_gen import ai_pb2_grpc
from .interceptors import AuthInterceptor, ErrorInterceptor, LoggingInterceptor
from .servicer import AiServicer
logger = structlog.get_logger()
class GrpcServer:
"""gRPC server 管理器.
用法:
server = GrpcServer(port=50058, servicer=servicer)
await server.start()
...
await server.stop()
"""
def __init__(
self,
port: int = 50058,
servicer: AiServicer | None = None,
max_workers: int = 10,
) -> None:
self._port = port
self._servicer = servicer or AiServicer()
self._max_workers = max_workers
self._server: grpc.aio.Server | None = None
async def start(self) -> None:
"""启动 gRPC server."""
self._server = grpc.aio.server(
interceptors=[
LoggingInterceptor(),
AuthInterceptor(),
ErrorInterceptor(),
],
)
ai_pb2_grpc.add_AiServiceServicer_to_server(self._servicer, self._server)
self._server.add_insecure_port(f"[::]:{self._port}")
await self._server.start()
logger.info("grpc_server_started", port=self._port)
async def stop(self, grace: float = 5.0) -> None:
"""优雅关闭 gRPC server."""
if self._server is not None:
await self._server.stop(grace=grace)
logger.info("grpc_server_stopped", port=self._port)
@property
def is_running(self) -> bool:
return self._server is not None
def create_grpc_server(
port: int,
chat_service: Any = None,
question_service: Any = None,
expression_service: Any = None,
workflow_service: Any = None,
) -> GrpcServer:
"""创建 gRPC server工厂函数."""
servicer = AiServicer(
chat_service=chat_service,
question_service=question_service,
expression_service=expression_service,
workflow_service=workflow_service,
)
return GrpcServer(port=port, servicer=servicer)

View File

@@ -0,0 +1,347 @@
"""AiService gRPC Servicer8 RPC 实现).
所有 RPC 返回 protobuf message降级采用方案 Bdegraded 字段在 message 内)。
业务逻辑由注入的 service 层处理servicer 仅做 proto ↔ domain 模型转换。
"""
from collections.abc import AsyncGenerator
from typing import Any
import grpc
import structlog
from ..errors import AIError, AILLMUnavailableError, ErrorCode
from ..proto_gen import ai_pb2, ai_pb2_grpc
from .interceptors import get_user_context
logger = structlog.get_logger()
class AiServicer(ai_pb2_grpc.AiServiceServicer):
"""AiService gRPC Servicer.
依赖注入:
- chat_service: ChatServiceChat / StreamChat
- question_service: QuestionServiceGenerateQuestion / StreamGenerateQuestion
- expression_service: ExpressionServiceOptimizeExpression
- workflow_service: LessonPlanWorkflowService备课工作流
"""
def __init__(
self,
chat_service: Any = None,
question_service: Any = None,
expression_service: Any = None,
workflow_service: Any = None,
) -> None:
self._chat_service = chat_service
self._question_service = question_service
self._expression_service = expression_service
self._workflow_service = workflow_service
async def Chat( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
request: ai_pb2.ChatRequest,
context: grpc.ServicerContext,
) -> ai_pb2.ChatResponse:
"""非流式聊天."""
ctx = get_user_context(context)
messages = [
{"role": m.role, "content": m.content}
for m in request.messages
]
try:
if self._chat_service is None:
return _degraded_chat_response(request.model, "chat_service not initialized")
result = await self._chat_service.chat(
messages=messages,
model=request.model or "gpt-4o-mini",
temperature=request.temperature,
user_id=request.user_id if request.HasField("user_id") else ctx.user_id,
session_id=request.session_id if request.HasField("session_id") else None,
data_scope=request.data_scope if request.HasField("data_scope") else ctx.data_scope,
)
return ai_pb2.ChatResponse(
content=result.content,
model=result.model,
usage=ai_pb2.Usage(
prompt_tokens=result.usage.prompt_tokens,
completion_tokens=result.usage.completion_tokens,
total_tokens=result.usage.total_tokens,
latency_ms=result.usage.latency_ms,
),
degraded=result.degraded,
degraded_reason=result.degraded_reason,
)
except AILLMUnavailableError as exc:
return _degraded_chat_response(request.model, str(exc))
except AIError:
raise
except Exception as exc: # noqa: BLE001
logger.error("chat_rpc_error", error=str(exc))
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
async def StreamChat( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
request: ai_pb2.ChatRequest,
context: grpc.ServicerContext,
) -> AsyncGenerator[ai_pb2.ChatChunk, None]:
"""流式聊天SSE over gRPC."""
ctx = get_user_context(context)
messages = [
{"role": m.role, "content": m.content}
for m in request.messages
]
try:
if self._chat_service is None:
yield ai_pb2.ChatChunk(content="[degraded] chat_service not initialized", done=True)
return
async for chunk in self._chat_service.stream_chat(
messages=messages,
model=request.model or "gpt-4o-mini",
temperature=request.temperature,
user_id=request.user_id if request.HasField("user_id") else ctx.user_id,
session_id=request.session_id if request.HasField("session_id") else None,
data_scope=request.data_scope if request.HasField("data_scope") else ctx.data_scope,
):
yield ai_pb2.ChatChunk(content=chunk.content, done=chunk.done)
except AILLMUnavailableError as exc:
yield ai_pb2.ChatChunk(content=f"[degraded] {exc}", done=True)
except Exception as exc: # noqa: BLE001
logger.error("stream_chat_rpc_error", error=str(exc))
yield ai_pb2.ChatChunk(content=f"[error] {exc}", done=True)
async def GenerateQuestion( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
request: ai_pb2.GenerateQuestionRequest,
context: grpc.ServicerContext,
) -> ai_pb2.GeneratedQuestion:
"""生成题目."""
try:
if self._question_service is None:
return _degraded_question_response("question_service not initialized")
result = await self._question_service.generate(request)
return ai_pb2.GeneratedQuestion(
question=result.question,
answer=result.answer,
explanation=result.explanation,
question_type=result.question_type,
difficulty=result.difficulty,
knowledge_point_ids=result.knowledge_point_ids,
evaluation_score=result.evaluation_score,
degraded=result.degraded,
degraded_reason=result.degraded_reason,
)
except AILLMUnavailableError as exc:
return _degraded_question_response(str(exc))
except AIError:
raise
except Exception as exc: # noqa: BLE001
logger.error("generate_question_rpc_error", error=str(exc))
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
async def StreamGenerateQuestion( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
request: ai_pb2.GenerateQuestionRequest,
context: grpc.ServicerContext,
) -> AsyncGenerator[ai_pb2.GeneratedQuestionChunk, None]:
"""题目逐字流式生成."""
try:
if self._question_service is None:
yield ai_pb2.GeneratedQuestionChunk(
content="[degraded] question_service not initialized",
done=True,
)
return
async for chunk in self._question_service.stream_generate(request):
if chunk.complete_question is not None:
yield ai_pb2.GeneratedQuestionChunk(
content=chunk.content,
done=chunk.done,
complete_question=chunk.complete_question,
)
else:
yield ai_pb2.GeneratedQuestionChunk(
content=chunk.content,
done=chunk.done,
)
except AILLMUnavailableError as exc:
yield ai_pb2.GeneratedQuestionChunk(
content=f"[degraded] {exc}",
done=True,
)
except Exception as exc: # noqa: BLE001
logger.error("stream_generate_question_rpc_error", error=str(exc))
yield ai_pb2.GeneratedQuestionChunk(
content=f"[error] {exc}",
done=True,
)
async def OptimizeExpression( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
request: ai_pb2.OptimizeExpressionRequest,
context: grpc.ServicerContext,
) -> ai_pb2.OptimizedExpression:
"""优化表达."""
try:
if self._expression_service is None:
return ai_pb2.OptimizedExpression(
optimized="[degraded] expression_service not initialized",
degraded=True,
degraded_reason="expression_service not initialized",
)
result = await self._expression_service.optimize(
text=request.text,
context=request.context,
)
return ai_pb2.OptimizedExpression(
optimized=result.optimized,
suggestions=result.suggestions,
degraded=result.degraded,
degraded_reason=result.degraded_reason,
)
except AILLMUnavailableError as exc:
return ai_pb2.OptimizedExpression(
optimized=f"[degraded] {exc}",
degraded=True,
degraded_reason=str(exc),
)
except AIError:
raise
except Exception as exc: # noqa: BLE001
logger.error("optimize_expression_rpc_error", error=str(exc))
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
async def GenerateLessonPlan( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
request: ai_pb2.GenerateLessonPlanRequest,
context: grpc.ServicerContext,
) -> ai_pb2.LessonPlanResponse:
"""备课工作流启动."""
ctx = get_user_context(context)
try:
if self._workflow_service is None:
return ai_pb2.LessonPlanResponse(
workflow_id="",
status="failed",
estimated_completion_seconds=0,
degraded=True,
degraded_reason="workflow_service not initialized",
)
result = await self._workflow_service.start(
class_id=request.class_id,
subject_id=request.subject_id,
topic=request.topic,
target_difficulty=request.target_difficulty,
question_count=request.question_count,
user_id=request.user_id or ctx.user_id,
data_scope=request.data_scope or ctx.data_scope,
)
return ai_pb2.LessonPlanResponse(
workflow_id=result.workflow_id,
status=result.status,
estimated_completion_seconds=result.estimated_completion_seconds,
degraded=result.degraded,
degraded_reason=result.degraded_reason,
)
except AIError:
raise
except Exception as exc: # noqa: BLE001
logger.error("generate_lesson_plan_rpc_error", error=str(exc))
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
async def GetLessonPlanStatus( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
request: ai_pb2.GetLessonPlanStatusRequest,
context: grpc.ServicerContext,
) -> ai_pb2.LessonPlanStatus:
"""查询备课工作流状态."""
try:
if self._workflow_service is None:
return ai_pb2.LessonPlanStatus(
workflow_id=request.workflow_id,
status="failed",
error="workflow_service not initialized",
degraded=True,
degraded_reason="workflow_service not initialized",
)
result = await self._workflow_service.get_status(request.workflow_id)
questions = [
ai_pb2.GeneratedQuestion(
question=q.question,
answer=q.answer,
explanation=q.explanation,
question_type=q.question_type,
difficulty=q.difficulty,
knowledge_point_ids=q.knowledge_point_ids,
evaluation_score=q.evaluation_score,
degraded=q.degraded,
degraded_reason=q.degraded_reason,
)
for q in result.questions
]
return ai_pb2.LessonPlanStatus(
workflow_id=result.workflow_id,
status=result.status,
questions=questions,
error=result.error,
degraded=result.degraded,
degraded_reason=result.degraded_reason,
)
except AIError:
raise
except Exception as exc: # noqa: BLE001
logger.error("get_lesson_plan_status_rpc_error", error=str(exc))
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
async def ConfirmLessonPlan( # noqa: N802 - gRPC RPC 方法名必须匹配 proto 定义
self,
request: ai_pb2.ConfirmLessonPlanRequest,
context: grpc.ServicerContext,
) -> ai_pb2.ConfirmResult:
"""教师确认备课结果入库."""
try:
if self._workflow_service is None:
return ai_pb2.ConfirmResult(
success=False,
error="workflow_service not initialized",
)
modifications = dict(request.modifications)
result = await self._workflow_service.confirm(
workflow_id=request.workflow_id,
modifications=modifications,
)
return ai_pb2.ConfirmResult(
success=result.success,
persisted_question_ids=result.persisted_question_ids,
error=result.error,
)
except AIError:
raise
except Exception as exc: # noqa: BLE001
logger.error("confirm_lesson_plan_rpc_error", error=str(exc))
raise AIError(ErrorCode.AI_INTERNAL_ERROR, str(exc)) from exc
def _degraded_chat_response(model: str, reason: str) -> ai_pb2.ChatResponse:
"""构建降级聊天响应."""
return ai_pb2.ChatResponse(
content=f"[degraded] {reason}",
model=model,
usage=ai_pb2.Usage(),
degraded=True,
degraded_reason=reason,
)
def _degraded_question_response(reason: str) -> ai_pb2.GeneratedQuestion:
"""构建降级题目响应."""
return ai_pb2.GeneratedQuestion(
question=f"[degraded] {reason}",
answer="",
explanation="",
question_type="",
difficulty="",
degraded=True,
degraded_reason=reason,
)

View File

@@ -1,137 +0,0 @@
"""LLM 客户端 - 使用 httpx 直接调用 OpenAI 兼容 REST API。
设计要点:
- 不依赖 openai SDK纯 httpx 异步调用
- api_key 为空或调用失败时返回 None / yield 降级骨架数据
- 调用方据此决定是否进入降级路径
"""
from collections.abc import AsyncGenerator
from typing import Any
import httpx
import structlog
logger = structlog.get_logger()
# 非流式请求默认超时(秒)
DEFAULT_TIMEOUT: float = 30.0
# 流式请求建立连接超时(秒);读取通过迭代器控制
STREAM_CONNECT_TIMEOUT: float = 30.0
# 流式读取单次 chunk 超时(秒)
STREAM_READ_TIMEOUT: float = 60.0
def _build_url(base_url: str) -> str:
"""拼接 chat completions 端点 URL."""
return f"{base_url.rstrip('/')}/chat/completions"
def _build_headers(api_key: str) -> dict[str, str]:
"""构建请求头."""
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async def chat_completion(
messages: list[dict[str, Any]],
model: str,
temperature: float,
api_key: str,
base_url: str,
) -> dict[str, Any] | None:
"""非流式调用 LLM。
Returns:
OpenAI 兼容的响应 dictapi_key 为空或调用失败时返回 None由调用方降级
"""
if not api_key:
logger.warning("llm_chat_completion_no_api_key_degraded")
return None
url = _build_url(base_url)
headers = _build_headers(api_key)
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": False,
}
try:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as exc:
logger.error(
"llm_chat_completion_http_error",
status_code=exc.response.status_code,
body=exc.response.text[:500],
)
return None
except Exception as exc: # noqa: BLE001 - 顶层兜底,所有异常均降级
logger.error("llm_chat_completion_failed", error=str(exc))
return None
async def chat_completion_stream(
messages: list[dict[str, Any]],
model: str,
temperature: float,
api_key: str,
base_url: str,
) -> AsyncGenerator[str, None]:
"""流式调用 LLM以 SSE 格式(``data: <chunk>\\n\\n``yield。
api_key 为空或调用失败时 yield 降级骨架数据,保证下游始终能消费。
"""
if not api_key:
logger.warning("llm_stream_no_api_key_degraded")
yield (
'data: {"choices":[{"delta":{"content":"[degraded] LLM API key not configured"}}]}\n\n'
)
yield "data: [DONE]\n\n"
return
url = _build_url(base_url)
headers = _build_headers(api_key)
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": True,
}
timeout = httpx.Timeout(
connect=STREAM_CONNECT_TIMEOUT,
read=STREAM_READ_TIMEOUT,
write=STREAM_CONNECT_TIMEOUT,
pool=STREAM_CONNECT_TIMEOUT,
)
try:
async with (
httpx.AsyncClient(timeout=timeout) as client,
client.stream("POST", url, json=payload, headers=headers) as resp,
):
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line or not line.startswith("data: "):
continue
yield f"{line}\n\n"
if line.strip() == "data: [DONE]":
return
except httpx.HTTPStatusError as exc:
logger.error(
"llm_stream_http_error_degraded",
status_code=exc.response.status_code,
)
yield 'data: {"choices":[{"delta":{"content":"[degraded] LLM stream HTTP error"}}]}\n\n'
yield "data: [DONE]\n\n"
except Exception as exc: # noqa: BLE001 - 顶层兜底,所有异常均降级
logger.error("llm_stream_failed_degraded", error=str(exc))
yield 'data: {"choices":[{"delta":{"content":"[degraded] LLM stream error"}}]}\n\n'
yield "data: [DONE]\n\n"

View File

@@ -1,11 +1,26 @@
"""AI 网关服务入口."""
"""AI 网关服务入口.
整合组件02-architecture-design.md §1.2 完整分层):
- HTTP 端点(/v1/ai 前缀ActionState 信封10 端点)
- gRPC server端口 500588 RPC
- LLM Provider FailoverChain4 适配器 + 熔断 + 故障切换)
- Prompt 模板服务Jinja2 + YAML
- 评估三道防线RuleValidator + LLMJudge + QualityGate
- 用量记录Redis+ Kafka 事件发布 + 配额管理
- 安全层PII + 输入清洗 + 输出审核)
- 下游 gRPC 客户端content/data-ana/iam全并行用 Mock
- 备课工作流4 步编排 + Redis 状态存储)
- 限流Redis 三维度令牌桶)
- OpenTelemetry + Prometheus
"""
import json
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
import structlog
from fastapi import APIRouter, FastAPI
from fastapi import APIRouter, FastAPI, Path, Request
from fastapi.responses import StreamingResponse
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
@@ -13,10 +28,46 @@ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from prometheus_client import make_asgi_app
from pydantic import BaseModel
from redis.asyncio import Redis
from .clients import ContentClientMock, DataAnaClientMock, IamClientMock
from .config import settings
from .llm_client import chat_completion, chat_completion_stream
from .grpc_server import create_grpc_server
from .middleware import (
PermissionGuard,
RequestIdMiddleware,
extract_user_context,
register_error_handlers,
)
from .middleware.permission import (
PERMISSION_AI_CHAT,
PERMISSION_AI_EXPRESSION_OPTIMIZE,
PERMISSION_AI_LESSON_GENERATE,
PERMISSION_AI_QUESTION_GENERATE,
)
from .models import (
ChatRequest,
ChatResponse,
ConfirmRequest,
ConfirmResultData,
ConfirmResultResponse,
GeneratedQuestionResponse,
GenerateQuestionRequest,
LessonPreparationData,
LessonPreparationRequest,
LessonPreparationResponse,
OptimizeExpressionRequest,
OptimizeExpressionResponse,
WorkflowStatusData,
WorkflowStatusResponse,
)
from .prompt_service import PromptTemplateService
from .providers import create_failover_chain
from .rate_limiter import RateLimiter
from .services import ChatService, ExpressionService, QuestionService
from .services.evaluation import QualityGate, RuleValidator
from .usage import KafkaProducer, QuotaEnforcer, UsageRecorder
from .workflow import LessonPlanWorkflowService, WorkflowStateStore
logger = structlog.get_logger()
tracer = trace.get_tracer(__name__)
@@ -25,8 +76,7 @@ tracer = trace.get_tracer(__name__)
def init_tracer() -> None:
"""初始化 OpenTelemetry.
endpoint 从 settings.otel_endpoint 读取;dev_mode=true 时跳过 exporter
初始化,避免本地无 collector 时报错。
dev_mode=true 时跳过 exporter 初始化,避免本地无 collector 时报错。
"""
if settings.is_dev:
logger.info("dev_mode_tracer_skipped", dev_mode=settings.dev_mode)
@@ -40,72 +90,138 @@ def init_tracer() -> None:
logger.info("tracer_initialized", otel_endpoint=endpoint)
# ---------------------------------------------------------------------------
# 全局组件lifespan 中初始化 Redis/Kafka/gRPC 连接)
# ---------------------------------------------------------------------------
_failover_chain = create_failover_chain(settings)
_prompt_service = PromptTemplateService()
_quality_gate = QualityGate(rule_validator=RuleValidator())
_chat_service = ChatService(
failover_chain=_failover_chain,
prompt_service=_prompt_service,
default_model=settings.default_chat_model,
)
_question_service = QuestionService(
failover_chain=_failover_chain,
prompt_service=_prompt_service,
quality_gate=_quality_gate,
default_model=settings.default_question_model,
)
_expression_service = ExpressionService(
failover_chain=_failover_chain,
prompt_service=_prompt_service,
default_model=settings.default_chat_model,
)
_permission_guard = PermissionGuard(dev_mode=settings.is_dev)
# Redis 依赖组件lifespan 中注入 redis 连接)
_usage_recorder = UsageRecorder(redis=None)
_kafka_producer = KafkaProducer(
bootstrap_servers=settings.kafka_bootstrap_servers,
topic=settings.kafka_ai_usage_topic,
transactional_id=settings.kafka_producer_transactional_id,
)
_quota_enforcer = QuotaEnforcer(usage_recorder=_usage_recorder)
_rate_limiter = RateLimiter(
redis=None,
user_limit=settings.redis_rate_limit_user_per_min,
ip_limit=settings.redis_rate_limit_ip_per_min,
school_limit=settings.redis_rate_limit_school_per_min,
)
# 下游客户端(全并行模式用 MockISSUE-07
_content_client = ContentClientMock()
_data_ana_client = DataAnaClientMock()
_iam_client = IamClientMock()
_state_store = WorkflowStateStore(
redis=None,
ttl_seconds=settings.workflow_ttl_seconds,
)
_workflow_service = LessonPlanWorkflowService(
state_store=_state_store,
failover_chain=_failover_chain,
prompt_service=_prompt_service,
quality_gate=_quality_gate,
content_client=_content_client,
data_ana_client=_data_ana_client,
default_model=settings.default_question_model,
)
_grpc_server = create_grpc_server(
port=settings.grpc_port,
chat_service=_chat_service,
question_service=_question_service,
expression_service=_expression_service,
workflow_service=_workflow_service,
)
_redis: Redis | None = None
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期."""
"""应用生命周期:初始化 → 运行 → 优雅关闭."""
init_tracer()
_prompt_service.load()
# Redis 连接(失败降级,不阻断启动)
global _redis
try:
_redis = Redis.from_url(settings.redis_url, decode_responses=True)
await _redis.ping()
logger.info("redis_connected", url=settings.redis_url)
_usage_recorder._redis = _redis # noqa: SLF001
_rate_limiter._redis = _redis # noqa: SLF001
_state_store._redis = _redis # noqa: SLF001
except Exception as exc: # noqa: BLE001
logger.warning("redis_connect_failed_degraded", error=str(exc))
_redis = None
await _kafka_producer.start()
await _grpc_server.start()
logger.info(
"ai_service_starting",
llm_available=settings.llm_available,
"ai_service_started",
http_port=settings.http_port,
grpc_port=settings.grpc_port,
dev_mode=settings.is_dev,
openai_base_url=settings.openai_base_url,
llm_available=settings.llm_available,
)
if not settings.llm_available:
logger.warning("ai_service_llm_degraded_no_api_key")
yield
# 优雅关闭reverse order
logger.info("ai_service_stopping")
await _grpc_server.stop()
await _kafka_producer.stop()
if _redis is not None:
await _redis.aclose()
logger.info("redis_closed")
app = FastAPI(
title="AI Gateway Service",
version="0.1.0",
version="1.0.0",
lifespan=lifespan,
)
# OpenTelemetry FastAPI 自动埋点HTTP 请求/响应 span
# 中间件 + 可观测性
app.add_middleware(RequestIdMiddleware)
FastAPIInstrumentor.instrument_app(app)
app.mount("/metrics", make_asgi_app())
register_error_handlers(app)
# 业务路由/ai 前缀Gateway 代理 /api/v1/ai/* → /ai/*
router = APIRouter(prefix="/ai")
# 业务路由/v1/ai 前缀Gateway 代理 /api/v1/ai/* → /v1/ai/*
router = APIRouter(prefix="/v1/ai")
class ChatRequest(BaseModel):
"""聊天请求."""
messages: list[dict[str, Any]]
model: str = "gpt-4o-mini"
temperature: float = 0.7
stream: bool = False
class ChatResponse(BaseModel):
"""聊天响应."""
content: str
model: str
usage: dict[str, Any]
degraded: bool = False
class QuestionRequest(BaseModel):
"""题目生成请求."""
prompt: str
def _extract_content(result: dict[str, Any] | None) -> tuple[str, str, dict[str, Any]]:
"""从 OpenAI 响应中抽取 (content, model, usage)。"""
if result is None:
return "", "", {}
choices = result.get("choices", [])
content = ""
if choices:
content = choices[0].get("message", {}).get("content", "") or ""
model = result.get("model", "") or ""
usage = result.get("usage", {}) or {}
return content, model, usage
def _client_ip(request: Request) -> str:
"""提取客户端 IP."""
return request.client.host if request.client else ""
@app.get("/healthz")
@@ -116,132 +232,222 @@ async def healthz() -> dict[str, Any]:
@app.get("/readyz")
async def readyz() -> dict[str, Any]:
"""就绪检查readiness.
LLM 未配置时仍返回 200但标记 degraded=true调用方可据此判断是否路由流量。
"""
llm_configured = settings.llm_available
"""就绪检查readiness."""
return {
"status": "ok",
"service": "ai",
"llm_configured": llm_configured,
"degraded": not llm_configured,
"openai_base_url": settings.openai_base_url,
"llm_configured": settings.llm_available,
"degraded": not settings.llm_available,
"grpc_running": _grpc_server.is_running,
"providers": settings.providers_status,
}
@router.post("/chat", response_model=ChatResponse)
async def chat(req: ChatRequest) -> ChatResponse:
"""LLM 聊天接口(无 API key 时降级返回骨架响应)."""
async def chat(req: ChatRequest, request: Request) -> ChatResponse:
"""非流式聊天."""
ctx = extract_user_context(request)
_permission_guard.check(ctx, PERMISSION_AI_CHAT)
await _rate_limiter.check(
user_id=ctx.user_id,
ip=_client_ip(request),
school_id=ctx.school_id,
)
with tracer.start_as_current_span("ai_chat"):
result = await chat_completion(
messages=req.messages,
messages = [{"role": m.role, "content": m.content} for m in req.messages]
result = await _chat_service.chat(
messages=messages,
model=req.model,
temperature=req.temperature,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
)
if result is None:
logger.warning("chat_degraded", model=req.model)
return ChatResponse(
content="[degraded] LLM unavailable - returning skeleton response",
model=req.model,
usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
degraded=True,
)
content, model, usage = _extract_content(result)
return ChatResponse(
content=content,
model=model or req.model,
usage=usage,
degraded=False,
user_id=ctx.user_id,
session_id=req.session_id,
data_scope=req.data_scope or ctx.data_scope,
)
return ChatResponse(success=True, data=result, error=None)
@router.post("/chat/stream")
async def chat_stream(req: ChatRequest) -> StreamingResponse:
"""流式聊天(SSE无 API key 时降级返回骨架 SSE."""
async def chat_stream(req: ChatRequest, request: Request) -> StreamingResponse:
"""流式聊天SSE."""
ctx = extract_user_context(request)
_permission_guard.check(ctx, PERMISSION_AI_CHAT)
await _rate_limiter.check(
user_id=ctx.user_id,
ip=_client_ip(request),
school_id=ctx.school_id,
)
async def generate() -> AsyncGenerator[str, None]:
with tracer.start_as_current_span("ai_chat_stream"):
async for chunk in chat_completion_stream(
messages=req.messages,
messages = [{"role": m.role, "content": m.content} for m in req.messages]
async for chunk in _chat_service.stream_chat(
messages=messages,
model=req.model,
temperature=req.temperature,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
user_id=ctx.user_id,
session_id=req.session_id,
data_scope=req.data_scope or ctx.data_scope,
):
yield chunk
payload = json.dumps(
{"content": chunk.content, "done": chunk.done},
ensure_ascii=False,
)
yield f"data: {payload}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/generate/question")
async def generate_question(req: QuestionRequest) -> dict[str, Any]:
"""生成题目(无 API key 时降级返回骨架)."""
@router.post("/generate/question", response_model=GeneratedQuestionResponse)
async def generate_question(
req: GenerateQuestionRequest,
request: Request,
) -> GeneratedQuestionResponse:
"""生成题目(非流式)."""
ctx = extract_user_context(request)
_permission_guard.check(ctx, PERMISSION_AI_QUESTION_GENERATE)
await _rate_limiter.check(
user_id=ctx.user_id,
ip=_client_ip(request),
school_id=ctx.school_id,
)
with tracer.start_as_current_span("generate_question"):
messages = [
{
"role": "system",
"content": "You are an educational question generator. "
"Generate a clear, concise question based on the user's prompt.",
},
{"role": "user", "content": req.prompt},
]
result = await chat_completion(
messages=messages,
model="gpt-4o-mini",
temperature=0.7,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
)
if result is None:
logger.warning("generate_question_degraded", prompt=req.prompt[:100])
return {
"success": True,
"data": {"question": "[degraded] question generation skeleton"},
"degraded": True,
}
content, _, _ = _extract_content(result)
return {
"success": True,
"data": {"question": content},
"degraded": False,
}
result = await _question_service.generate(request=req, user_id=ctx.user_id)
return GeneratedQuestionResponse(success=True, data=result, error=None)
@router.post("/optimize/expression")
async def optimize_expression(text: str) -> dict[str, Any]:
"""优化表达(无 API key 时降级返回骨架)."""
@router.post("/generate/question/stream")
async def generate_question_stream(
req: GenerateQuestionRequest,
request: Request,
) -> StreamingResponse:
"""流式生成题目SSE."""
ctx = extract_user_context(request)
_permission_guard.check(ctx, PERMISSION_AI_QUESTION_GENERATE)
await _rate_limiter.check(
user_id=ctx.user_id,
ip=_client_ip(request),
school_id=ctx.school_id,
)
async def generate() -> AsyncGenerator[str, None]:
with tracer.start_as_current_span("generate_question_stream"):
async for chunk in _question_service.stream_generate(
request=req,
user_id=ctx.user_id,
):
if chunk.done and chunk.complete_question is not None:
payload = {
"done": True,
"question": chunk.complete_question.model_dump(),
}
else:
payload = {"content": chunk.content, "done": False}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/optimize/expression", response_model=OptimizeExpressionResponse)
async def optimize_expression(
req: OptimizeExpressionRequest,
request: Request,
) -> OptimizeExpressionResponse:
"""优化表达."""
ctx = extract_user_context(request)
_permission_guard.check(ctx, PERMISSION_AI_EXPRESSION_OPTIMIZE)
await _rate_limiter.check(
user_id=ctx.user_id,
ip=_client_ip(request),
school_id=ctx.school_id,
)
with tracer.start_as_current_span("optimize_expression"):
messages = [
{
"role": "system",
"content": "You are a writing assistant. "
"Optimize the user's text for clarity, conciseness, and tone.",
},
{"role": "user", "content": text},
]
result = await chat_completion(
messages=messages,
model="gpt-4o-mini",
temperature=0.5,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
result = await _expression_service.optimize(
text=req.text,
context=req.context,
user_id=ctx.user_id,
)
if result is None:
logger.warning("optimize_expression_degraded", text=text[:100])
return {
"success": True,
"data": {"optimized": "[degraded] expression optimization skeleton"},
"degraded": True,
}
content, _, _ = _extract_content(result)
return {
"success": True,
"data": {"optimized": content},
"degraded": False,
}
return OptimizeExpressionResponse(success=True, data=result, error=None)
@router.post("/lesson-plan/generate", response_model=LessonPreparationResponse)
async def generate_lesson_plan(
req: LessonPreparationRequest,
request: Request,
) -> LessonPreparationResponse:
"""启动备课工作流."""
ctx = extract_user_context(request)
_permission_guard.check(ctx, PERMISSION_AI_LESSON_GENERATE)
await _rate_limiter.check(
user_id=ctx.user_id,
ip=_client_ip(request),
school_id=ctx.school_id,
)
with tracer.start_as_current_span("generate_lesson_plan"):
state = await _workflow_service.start(
user_id=ctx.user_id,
school_id=ctx.school_id,
class_id=req.class_id,
subject_id=req.subject_id,
topic=req.topic,
target_difficulty=req.target_difficulty,
question_count=req.question_count,
request_id=ctx.request_id,
)
data = LessonPreparationData(
workflow_id=state.workflow_id,
status=state.status,
estimated_completion_seconds=60,
degraded=state.status == "failed",
degraded_reason=state.error or "",
)
return LessonPreparationResponse(success=True, data=data, error=None)
@router.get(
"/lesson-plan/status/{workflow_id}",
response_model=WorkflowStatusResponse,
)
async def get_lesson_plan_status(
workflow_id: str = Path(..., description="工作流 ID"),
) -> WorkflowStatusResponse:
"""查询备课工作流状态."""
state = await _workflow_service.get_status(workflow_id)
data = WorkflowStatusData(
workflow_id=state.workflow_id,
status=state.status,
questions=state.questions,
error=state.error,
degraded=state.status == "failed",
degraded_reason=state.error or "",
)
return WorkflowStatusResponse(success=True, data=data, error=None)
@router.post(
"/lesson-plan/confirm/{workflow_id}",
response_model=ConfirmResultResponse,
)
async def confirm_lesson_plan(
workflow_id: str = Path(..., description="工作流 ID"),
req: ConfirmRequest | None = None,
) -> ConfirmResultResponse:
"""教师确认备课结果入库."""
body = req or ConfirmRequest()
result = await _workflow_service.confirm(
workflow_id=workflow_id,
modifications=body.modifications,
)
if result.get("error"):
logger.warning(
"lesson_plan_confirm_failed",
workflow_id=workflow_id,
error=result["error"],
)
data = ConfirmResultData(
success=result.get("success", False),
persisted_question_ids=result.get("persisted_question_ids", []),
)
return ConfirmResultResponse(success=True, data=data, error=None)
app.include_router(router)

View File

@@ -0,0 +1,23 @@
"""中间件模块.
提供:
- request_id: 请求 ID 注入与传播
- error_handler: 全局错误处理AIError → ActionState
- auth: 用户上下文提取JWT claims 从 Gateway/BFF 透传)
- permission: 权限校验守卫
"""
from .auth import UserContext, extract_user_context
from .error_handler import GlobalErrorHandler, register_error_handlers
from .permission import PermissionGuard, require_permission
from .request_id import RequestIdMiddleware
__all__ = [
"UserContext",
"extract_user_context",
"GlobalErrorHandler",
"register_error_handlers",
"PermissionGuard",
"require_permission",
"RequestIdMiddleware",
]

View File

@@ -0,0 +1,83 @@
"""认证上下文提取.
ai 服务不直接校验 JWTGateway 负责),从 Gateway/BFF 透传的 header 提取用户上下文。
透传 headerGateway 注入):
X-User-Id: 用户 ID
X-User-Role: 用户角色
X-School-Id: 学校 ID
X-Data-Scope: 数据权限范围JSON
X-Request-Id: 请求 ID
"""
from dataclasses import dataclass
from typing import Any
import structlog
from starlette.requests import Request
logger = structlog.get_logger()
@dataclass
class UserContext:
"""用户上下文(从 Gateway 透传 header 提取)."""
user_id: str = ""
role: str = ""
school_id: str = ""
data_scope: str = ""
request_id: str = ""
@property
def is_authenticated(self) -> bool:
return bool(self.user_id)
@property
def is_empty(self) -> bool:
return not self.user_id and not self.role and not self.school_id
def extract_user_context(request: Request) -> UserContext:
"""从 HTTP 请求 header 提取用户上下文.
全并行模式:开发期间无 Gateway允许空上下文标记为 dev 调用)。
"""
return UserContext(
user_id=request.headers.get("X-User-Id", ""),
role=request.headers.get("X-User-Role", ""),
school_id=request.headers.get("X-School-Id", ""),
data_scope=request.headers.get("X-Data-Scope", ""),
request_id=getattr(request.state, "request_id", "")
or request.headers.get("X-Request-Id", ""),
)
def extract_user_context_from_metadata(metadata: Any) -> UserContext:
"""从 gRPC metadata 提取用户上下文.
gRPC metadata key 统一小写。
"""
if metadata is None:
return UserContext()
def _get(key: str) -> str:
if hasattr(metadata, "get"):
val = metadata.get(key)
return val if isinstance(val, str) else ""
# grpc metadata 是 list[tuple[str, str]]
try:
for k, v in metadata: # type: ignore[union-attr]
if k.lower() == key.lower():
return str(v)
except (TypeError, ValueError):
pass
return ""
return UserContext(
user_id=_get("x-user-id"),
role=_get("x-user-role"),
school_id=_get("x-school-id"),
data_scope=_get("x-data-scope"),
request_id=_get("x-request-id"),
)

View File

@@ -0,0 +1,130 @@
"""全局错误处理.
将 AIError + 未知异常统一转换为 ActionState 错误响应。
HTTP 端点返回 JSONgRPC interceptor 转换为 grpc.StatusCode。
"""
from collections.abc import Awaitable, Callable
from typing import Any
import structlog
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from ..errors import AIError
from ..models.action_state import ActionState, ErrorDetail
logger = structlog.get_logger()
class GlobalErrorHandler:
"""全局错误处理器."""
@staticmethod
def handle_ai_error(exc: AIError, trace_id: str | None = None) -> JSONResponse:
"""将 AIError 转换为 ActionState JSON 响应."""
error_detail = ErrorDetail(
code=exc.code.value,
message=exc.message,
details=exc.details or None,
trace_id=trace_id,
)
body = ActionState[Any](
success=False,
data=None,
error=error_detail,
)
return JSONResponse(
status_code=exc.http_status,
content=body.model_dump(by_alias=True, exclude_none=True),
)
@staticmethod
def handle_unknown_error(
exc: Exception,
trace_id: str | None = None,
) -> JSONResponse:
"""未知异常兜底500."""
logger.error(
"unhandled_exception",
error_type=type(exc).__name__,
error=str(exc),
trace_id=trace_id,
)
error_detail = ErrorDetail(
code="AI_INTERNAL_ERROR",
message="Internal server error",
trace_id=trace_id,
)
body = ActionState[Any](
success=False,
data=None,
error=error_detail,
)
return JSONResponse(
status_code=500,
content=body.model_dump(by_alias=True, exclude_none=True),
)
def register_error_handlers(app: FastAPI) -> None:
"""注册全局错误处理器到 FastAPI app."""
@app.exception_handler(AIError)
async def ai_error_handler(request: Request, exc: AIError) -> JSONResponse:
trace_id = getattr(request.state, "request_id", None)
return GlobalErrorHandler.handle_ai_error(exc, trace_id)
@app.exception_handler(Exception)
async def unknown_error_handler(request: Request, exc: Exception) -> JSONResponse:
trace_id = getattr(request.state, "request_id", None)
return GlobalErrorHandler.handle_unknown_error(exc, trace_id)
def grpc_error_mapper(exc: Exception) -> tuple[str, str, int]:
"""gRPC 异常映射器(供 interceptor 使用).
Returns:
(error_code, message, grpc_status_code)
grpc_status_code 对应 grpc.StatusCode value
0=OK, 1=CANCELLED, 2=UNKNOWN, 3=INVALID_ARGUMENT, 5=NOT_FOUND,
6=ALREADY_EXISTS, 7=PERMISSION_DENIED, 8=UNAUTHENTICATED,
9=RESOURCE_EXHAUSTED, 10=FAILED_PRECONDITION, 13=INTERNAL,
14=UNAVAILABLE, 15=DATA_LOSS
"""
if isinstance(exc, AIError):
code = exc.code.value
msg = exc.message
# 映射到 gRPC status code
grpc_status = {
"AI_UNAUTHORIZED": 8,
"AI_FORBIDDEN": 7,
"AI_RATE_LIMITED": 9,
"AI_QUOTA_EXCEEDED": 9,
"AI_LLM_UNAVAILABLE": 14,
"AI_LLM_TIMEOUT": 14,
"AI_LLM_ALL_PROVIDERS_FAILED": 14,
"AI_INVALID_MODEL": 3,
"AI_INVALID_DIFFICULTY": 3,
"AI_INVALID_QUESTION_TYPE": 3,
"AI_DOWNSTREAM_UNAVAILABLE": 14,
"AI_PROMPT_RENDER_FAILED": 13,
"AI_PROMPT_TEMPLATE_NOT_FOUND": 5,
"AI_WORKFLOW_NOT_FOUND": 5,
"AI_WORKFLOW_EXPIRED": 5,
"AI_WORKFLOW_STATE_INVALID": 10,
"AI_EVALUATION_FAILED": 14,
"AI_PII_DETECTED": 3,
"AI_PROMPT_INJECTION_DETECTED": 3,
"AI_CONTENT_MODERATION_REJECTED": 14,
"AI_INTERNAL_ERROR": 13,
}.get(code, 13)
return code, msg, grpc_status
# 未知异常
logger.error("grpc_unhandled_exception", error=str(exc))
return "AI_INTERNAL_ERROR", "Internal server error", 13
# 供中间件链使用的通用错误处理函数类型
ErrorHandler = Callable[[Exception, str | None], Awaitable[JSONResponse]]

View File

@@ -0,0 +1,138 @@
"""权限校验守卫.
ai 服务的权限点(对齐 004 Permissions 常量):
- ai:chat: 聊天
- ai:question:generate: 生成题目
- ai:expression:optimize: 优化表达
- ai:lesson:generate: 生成教案
- ai:lesson:confirm: 确认教案
全并行模式dev_mode=true 时跳过权限校验,仅记录警告。
"""
from collections.abc import Callable
from functools import wraps
from typing import Any
import structlog
from ..errors import AIError, ErrorCode
from .auth import UserContext
logger = structlog.get_logger()
# 权限点常量
PERMISSION_AI_CHAT = "ai:chat"
PERMISSION_AI_QUESTION_GENERATE = "ai:question:generate"
PERMISSION_AI_EXPRESSION_OPTIMIZE = "ai:expression:optimize"
PERMISSION_AI_LESSON_GENERATE = "ai:lesson:generate"
PERMISSION_AI_LESSON_CONFIRM = "ai:lesson:confirm"
# 角色 → 权限映射简化版P6 迁移到 iam 动态权限)
ROLE_PERMISSIONS: dict[str, set[str]] = {
"teacher": {
PERMISSION_AI_CHAT,
PERMISSION_AI_QUESTION_GENERATE,
PERMISSION_AI_EXPRESSION_OPTIMIZE,
PERMISSION_AI_LESSON_GENERATE,
PERMISSION_AI_LESSON_CONFIRM,
},
"admin": {
PERMISSION_AI_CHAT,
PERMISSION_AI_QUESTION_GENERATE,
PERMISSION_AI_EXPRESSION_OPTIMIZE,
PERMISSION_AI_LESSON_GENERATE,
PERMISSION_AI_LESSON_CONFIRM,
},
"student": {
PERMISSION_AI_CHAT,
},
}
class PermissionGuard:
"""权限校验守卫.
全并行模式dev_mode=true 时跳过校验。
生产模式:无用户上下文或权限不足时抛 AIError(AI_FORBIDDEN)。
"""
def __init__(self, dev_mode: bool = False) -> None:
self._dev_mode = dev_mode
def check(
self,
ctx: UserContext,
permission: str,
) -> None:
"""校验权限,失败抛 AIError.
Args:
ctx: 用户上下文
permission: 权限点(如 PERMISSION_AI_CHAT
Raises:
AIError(AI_UNAUTHORIZED): 用户未认证
AIError(AI_FORBIDDEN): 权限不足
"""
if self._dev_mode:
logger.debug(
"permission_check_skipped_dev_mode",
permission=permission,
user_id=ctx.user_id,
)
return
if not ctx.is_authenticated:
raise AIError(
ErrorCode.AI_UNAUTHORIZED,
"User not authenticated",
)
role = ctx.role or "student"
allowed = ROLE_PERMISSIONS.get(role, ROLE_PERMISSIONS["student"])
if permission not in allowed:
logger.warning(
"permission_denied",
permission=permission,
user_id=ctx.user_id,
role=role,
)
raise AIError(
ErrorCode.AI_FORBIDDEN,
f"Permission denied: requires {permission}",
{"required_permission": permission, "role": role},
)
def require_permission(
permission: str,
guard: PermissionGuard,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""装饰器:声明端点所需权限.
用法:
@router.post("/chat")
@require_permission(PERMISSION_AI_CHAT, guard)
async def chat(...): ...
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
# 从 kwargs 提取 user_context由依赖注入传入
ctx: UserContext | None = kwargs.get("user_context")
if ctx is None:
# 尝试从 args 查找
for arg in args:
if isinstance(arg, UserContext):
ctx = arg
break
if ctx is None:
ctx = UserContext()
guard.check(ctx, permission)
return await func(*args, **kwargs)
return wrapper
return decorator

View File

@@ -0,0 +1,30 @@
"""请求 ID 中间件.
Gateway 注入 X-Request-Id全链路传递日志、metrics、trace
若请求未携带,则自动生成 UUID。
"""
import uuid
from collections.abc import Awaitable, Callable
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
REQUEST_ID_HEADER = "X-Request-Id"
class RequestIdMiddleware(BaseHTTPMiddleware):
"""注入/传播 X-Request-Id."""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
request_id = request.headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4())
# 存入 request.state 供下游使用
request.state.request_id = request_id
response = await call_next(request)
response.headers[REQUEST_ID_HEADER] = request_id
return response

View File

@@ -0,0 +1,54 @@
"""Pydantic 模型ActionState 信封 + 请求/响应模型)."""
from .action_state import ActionState, ErrorDetail
from .chat import ChatData, ChatMessage, ChatRequest, ChatResponse, Usage
from .expression import (
OptimizedExpressionData,
OptimizeExpressionRequest,
OptimizeExpressionResponse,
)
from .question import (
Difficulty,
GeneratedQuestionData,
GeneratedQuestionResponse,
GenerateQuestionRequest,
QuestionType,
)
from .workflow import (
ConfirmRequest,
ConfirmResultData,
ConfirmResultResponse,
LessonPreparationData,
LessonPreparationRequest,
LessonPreparationResponse,
WorkflowStatus,
WorkflowStatusData,
WorkflowStatusResponse,
)
__all__ = [
"ActionState",
"ErrorDetail",
"ChatData",
"ChatMessage",
"ChatRequest",
"ChatResponse",
"Usage",
"Difficulty",
"GenerateQuestionRequest",
"GeneratedQuestionData",
"GeneratedQuestionResponse",
"QuestionType",
"OptimizeExpressionRequest",
"OptimizedExpressionData",
"OptimizeExpressionResponse",
"ConfirmRequest",
"ConfirmResultData",
"ConfirmResultResponse",
"LessonPreparationRequest",
"LessonPreparationData",
"LessonPreparationResponse",
"WorkflowStatus",
"WorkflowStatusData",
"WorkflowStatusResponse",
]

View File

@@ -0,0 +1,71 @@
"""ActionState 统一响应信封004 §11.5 强制约束).
降级采用方案 B总裁裁决 §2.6
success=true + error=null + data 内 degraded 字段
"""
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, ConfigDict, Field
T = TypeVar("T")
class ErrorDetail(BaseModel):
"""错误详情."""
model_config = ConfigDict(populate_by_name=True)
code: str
message: str
details: dict[str, Any] | None = None
trace_id: str | None = Field(None, alias="traceId")
class ActionState(BaseModel, Generic[T]): # noqa: UP046 - Pydantic v2 需 Generic[T] 语法
"""统一响应信封.
所有 HTTP 端点 + gRPC RPC 返回值必须使用此结构.
降级时 success=true, error=null, data 内含 degraded=true + degraded_reason.
"""
success: bool
data: T | None = None
error: ErrorDetail | None = None
@classmethod
def ok(cls, data: T) -> "ActionState[T]":
"""成功响应."""
return cls(success=True, data=data, error=None)
@classmethod
def degraded(cls, data: T, reason: str) -> "ActionState[T]":
"""降级响应(方案 Bsuccess=true + data 内 degraded 字段).
data 必须是含 degraded + degraded_reason 字段的模型.
"""
if hasattr(data, "degraded"):
data.degraded = True # type: ignore[attr-defined]
if hasattr(data, "degraded_reason"):
data.degraded_reason = reason # type: ignore[attr-defined]
return cls(success=True, data=data, error=None)
@classmethod
def error_response(
cls,
code: str,
message: str,
details: dict[str, Any] | None = None,
trace_id: str | None = None,
) -> "ActionState[Any]":
"""错误响应."""
return cls(
success=False,
data=None,
error=ErrorDetail(
code=code,
message=message,
details=details,
trace_id=trace_id,
),
)

View File

@@ -0,0 +1,50 @@
"""聊天模型."""
from typing import Literal
from pydantic import BaseModel, Field
from .action_state import ActionState
class ChatMessage(BaseModel):
"""聊天消息."""
role: Literal["system", "user", "assistant"]
content: str = Field(..., min_length=1, max_length=8000)
class ChatRequest(BaseModel):
"""聊天请求."""
messages: list[ChatMessage] = Field(..., min_length=1, max_length=50)
model: str = "gpt-4o-mini"
temperature: float = Field(0.7, ge=0.0, le=2.0)
stream: bool = False
# 可选上下文BFF 透传)
user_id: str | None = None
session_id: str | None = None
data_scope: str | None = None
class Usage(BaseModel):
"""Token 用量."""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
latency_ms: int = 0
class ChatData(BaseModel):
"""聊天响应数据(含降级标记)."""
content: str
model: str
usage: Usage
degraded: bool = False
degraded_reason: str = ""
class ChatResponse(ActionState[ChatData]):
"""聊天响应信封."""

View File

@@ -0,0 +1,25 @@
"""表达优化模型."""
from pydantic import BaseModel, Field
from .action_state import ActionState
class OptimizeExpressionRequest(BaseModel):
"""表达优化请求."""
text: str = Field(..., min_length=1, max_length=8000)
context: str = Field("", max_length=2000)
class OptimizedExpressionData(BaseModel):
"""表达优化响应数据(含降级标记)."""
optimized: str
suggestions: list[str] = []
degraded: bool = False
degraded_reason: str = ""
class OptimizeExpressionResponse(ActionState[OptimizedExpressionData]):
"""表达优化响应信封."""

View File

@@ -0,0 +1,46 @@
"""题目生成模型."""
from typing import Literal
from pydantic import BaseModel, Field
from .action_state import ActionState
Difficulty = Literal["easy", "medium", "hard"]
QuestionType = Literal[
"single_choice",
"multi_choice",
"fill_blank",
"short_answer",
"essay",
]
class GenerateQuestionRequest(BaseModel):
"""题目生成请求."""
prompt: str = Field(..., min_length=1, max_length=2000)
subject: str = Field(..., max_length=50)
difficulty: Difficulty = "medium"
grade: str | None = Field(None, max_length=20)
knowledge_point_ids: list[str] = Field(default_factory=list, max_length=20)
question_type: QuestionType = "short_answer"
count: int = Field(1, ge=1, le=10)
class GeneratedQuestionData(BaseModel):
"""生成的题目数据(含降级标记)."""
question: str
answer: str
explanation: str
question_type: str = "short_answer"
difficulty: str = "medium"
knowledge_point_ids: list[str] = []
evaluation_score: float | None = None
degraded: bool = False
degraded_reason: str = ""
class GeneratedQuestionResponse(ActionState[GeneratedQuestionData]):
"""题目生成响应信封."""

View File

@@ -0,0 +1,73 @@
"""备课工作流模型."""
from typing import Literal
from pydantic import BaseModel, Field
from .action_state import ActionState
from .question import GeneratedQuestionData
WorkflowStatus = Literal[
"pending",
"analyzing",
"generating",
"pending_review",
"persisted",
"failed",
]
class LessonPreparationRequest(BaseModel):
"""备课工作流启动请求."""
class_id: str
subject_id: str
topic: str = Field(..., max_length=500)
target_difficulty: Literal["easy", "medium", "hard"] = "medium"
question_count: int = Field(5, ge=1, le=20)
class LessonPreparationData(BaseModel):
"""备课工作流启动响应数据."""
workflow_id: str
status: WorkflowStatus
estimated_completion_seconds: int = 60
degraded: bool = False
degraded_reason: str = ""
class LessonPreparationResponse(ActionState[LessonPreparationData]):
"""备课工作流启动响应信封."""
class WorkflowStatusData(BaseModel):
"""工作流状态查询响应数据."""
workflow_id: str
status: WorkflowStatus
questions: list[GeneratedQuestionData] = []
error: str | None = None
degraded: bool = False
degraded_reason: str = ""
class WorkflowStatusResponse(ActionState[WorkflowStatusData]):
"""工作流状态查询响应信封."""
class ConfirmRequest(BaseModel):
"""教师确认入库请求."""
modifications: dict[str, str] | None = None # question_id → 修改后内容
class ConfirmResultData(BaseModel):
"""确认入库结果数据."""
success: bool
persisted_question_ids: list[str] = []
class ConfirmResultResponse(ActionState[ConfirmResultData]):
"""确认入库响应信封."""

View File

@@ -0,0 +1,148 @@
"""Prompt 模板服务Jinja2 渲染 + YAML 模板加载).
设计依据 02-architecture-design.md §8
- 模板文件位于 prompts/*.yaml
- 每个 YAML 含 name/version/description/template 四字段
- template 字段为 Jinja2 模板字符串
- 渲染时传入上下文变量
"""
from pathlib import Path
from typing import Any
import structlog
import yaml
from jinja2 import Environment, StrictUndefined, Template, select_autoescape
from .errors import AIError, ErrorCode
logger = structlog.get_logger()
# 模板目录(相对于 src/ai/
TEMPLATES_DIR = Path(__file__).parent / "prompts"
class PromptTemplate:
"""单个 prompt 模板."""
def __init__(self, name: str, version: str, description: str, template_str: str) -> None:
self.name = name
self.version = version
self.description = description
self._template: Template = Environment(
autoescape=select_autoescape([]),
variable_start_string="{{",
variable_end_string="}}",
undefined=StrictUndefined,
).from_string(template_str)
def render(self, context: dict[str, Any] | None = None) -> str:
"""渲染模板.
Args:
context: 模板变量字典
Returns:
渲染后的 prompt 字符串
Raises:
AIError(AI_PROMPT_RENDER_FAILED): 渲染失败(变量缺失等)
"""
try:
return self._template.render(context or {})
except Exception as exc:
logger.error(
"prompt_render_failed",
template=self.name,
error=str(exc),
)
raise AIError(
ErrorCode.AI_PROMPT_RENDER_FAILED,
f"Failed to render template '{self.name}': {exc}",
) from exc
class PromptTemplateService:
"""Prompt 模板服务.
启动时加载所有 YAML 模板到内存,运行时按 name 查找渲染。
"""
def __init__(self, templates_dir: Path | None = None) -> None:
self._templates_dir = templates_dir or TEMPLATES_DIR
self._templates: dict[str, PromptTemplate] = {}
def load(self) -> None:
"""加载所有模板文件."""
if not self._templates_dir.exists():
logger.warning(
"prompts_dir_not_found",
path=str(self._templates_dir),
)
return
for yaml_file in self._templates_dir.glob("*.yaml"):
try:
self._load_file(yaml_file)
except Exception as exc: # noqa: BLE001
logger.error(
"prompt_template_load_failed",
file=str(yaml_file),
error=str(exc),
)
logger.info("prompt_templates_loaded", count=len(self._templates))
def _load_file(self, path: Path) -> None:
"""加载单个 YAML 模板文件."""
with path.open(encoding="utf-8") as f:
data = yaml.safe_load(f)
name = data.get("name") or path.stem
version = str(data.get("version", "1.0"))
description = data.get("description", "")
template_str = data.get("template", "")
if not template_str:
raise ValueError(f"Template '{name}' has empty template field")
self._templates[name] = PromptTemplate(name, version, description, template_str)
def get(self, name: str) -> PromptTemplate:
"""获取模板.
Raises:
AIError(AI_PROMPT_TEMPLATE_NOT_FOUND): 模板不存在
"""
template = self._templates.get(name)
if template is None:
raise AIError(
ErrorCode.AI_PROMPT_TEMPLATE_NOT_FOUND,
f"Template '{name}' not found",
{"available": list(self._templates.keys())},
)
return template
def render(self, name: str, context: dict[str, Any] | None = None) -> str:
"""渲染指定模板.
Args:
name: 模板名称
context: 模板变量
Returns:
渲染后的 prompt 字符串
"""
template = self.get(name)
return template.render(context)
def list_templates(self) -> list[dict[str, str]]:
"""列出所有已加载模板."""
return [
{
"name": t.name,
"version": t.version,
"description": t.description,
}
for t in self._templates.values()
]

View File

@@ -0,0 +1,12 @@
# 聊天系统 prompt 模板
name: chat_system
version: "1.0"
description: AI 聊天助手系统 prompt
template: |
你是一个专业的教育助手,擅长解答学科问题、提供学习建议。
回答要求:
1. 准确、简洁、有条理
2. 适合 {{ grade | default("高中") }} 学生的理解水平
3. 如不确定,明确说明而非编造
4. 鼓励学生思考,适当引导而非直接给答案
{% if subject is defined %}5. 当前对话主题:{{ subject }}{% endif %}

View File

@@ -0,0 +1,30 @@
# 题目生成 prompt 模板
name: generate_question
version: "1.0"
description: 根据知识点和难度生成题目
template: |
请根据以下要求生成一道{{ subject | default("数学") }}题目:
【要求】
- 学科:{{ subject | default("数学") }}
- 年级:{{ grade | default("高一") }}
- 难度:{{ difficulty | default("medium") }}
- 题型:{{ question_type | default("short_answer") }}
- 知识点:{{ knowledge_points | join("、") | default("基础概念") }}
{% if count and count > 1 %}- 数量:{{ count }} 道{% endif %}
【输出格式】
请严格按以下 JSON 格式输出(不要包含其他内容):
{
"question": "题目内容",
"answer": "标准答案",
"explanation": "解析说明",
"question_type": "{{ question_type | default("short_answer") }}",
"difficulty": "{{ difficulty | default("medium") }}",
"knowledge_point_ids": {{ knowledge_point_ids | tojson | default("[]") }}
}
【难度说明】
- easy: 基础概念,直接应用公式
- medium: 需要理解概念2-3 步推理
- hard: 综合应用,需要多知识点结合

View File

@@ -0,0 +1,34 @@
# 备课工作流 Step 1: 分析学情 prompt 模板
name: lesson_plan_analyze
version: "1.0"
description: 分析班级学情,为备课提供数据支撑
template: |
请根据以下信息分析班级学情,为备课提供依据:
【班级信息】
- 班级 ID{{ class_id }}
- 学科:{{ subject_id }}
- 主题:{{ topic }}
- 目标难度:{{ target_difficulty | default("medium") }}
【学情数据】
{{ learning_data | default("暂无学情数据,请基于通用教学经验分析") }}
【分析要求】
1. 识别班级整体知识薄弱点
2. 分析学生能力分布
3. 推荐重点讲解的知识点
4. 建议教学策略
【输出格式】
请严格按以下 JSON 格式输出:
{
"weak_points": ["薄弱知识点1", "薄弱知识点2"],
"ability_distribution": {
"high": 0.2,
"medium": 0.5,
"low": 0.3
},
"recommended_knowledge_points": ["kp1", "kp2"],
"teaching_strategy": "教学策略建议"
}

View File

@@ -0,0 +1,37 @@
# 备课工作流 Step 3: 生成题目 prompt 模板
name: lesson_plan_generate
version: "1.0"
description: 基于学情分析结果批量生成题目
template: |
请根据学情分析结果生成 {{ question_count | default(5) }} 道题目:
【教学信息】
- 学科:{{ subject_id }}
- 主题:{{ topic }}
- 目标难度:{{ target_difficulty | default("medium") }}
- 生成数量:{{ question_count | default(5) }}
【学情分析】
{{ analysis_summary | default("基于通用教学经验") }}
【推荐知识点】
{{ recommended_knowledge_points | join("、") | default("基础知识点") }}
【生成要求】
1. 题目难度覆盖 easy/medium/hard比例约 3:5:2
2. 每道题包含题干、答案、解析
3. 知识点覆盖推荐列表
4. 题型多样化
【输出格式】
请严格按以下 JSON 数组格式输出:
[
{
"question": "题目内容",
"answer": "标准答案",
"explanation": "解析说明",
"question_type": "short_answer",
"difficulty": "medium",
"knowledge_point_ids": ["kp1"]
}
]

View File

@@ -0,0 +1,24 @@
# 表达优化 prompt 模板
name: optimize_expression
version: "1.0"
description: 优化文字表达的清晰度、简洁度和语气
template: |
请优化以下文字的表达:
【原文】
{{ text }}
{% if context %}【上下文】{{ context }}{% endif %}
【优化要求】
1. 保持原意不变
2. 提升清晰度和简洁度
3. 调整语气为专业、友善
4. 修正语法错误
【输出格式】
请严格按以下 JSON 格式输出(不要包含其他内容):
{
"optimized": "优化后的文字",
"suggestions": ["改进建议1", "改进建议2"]
}

View File

@@ -0,0 +1,14 @@
"""protobuf 生成代码(勿手动编辑).
由 grpc_tools.protoc 从 packages/shared-proto/proto/ai.proto 生成。
重新生成命令:
uv run python -m grpc_tools.protoc \\
-I ../../packages/shared-proto/proto \\
--python_out=src/ai/proto_gen \\
--grpc_python_out=src/ai/proto_gen \\
../../packages/shared-proto/proto/ai.proto
"""
from . import ai_pb2, ai_pb2_grpc
__all__ = ["ai_pb2", "ai_pb2_grpc"]

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,418 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from . import ai_pb2 as ai__pb2
GRPC_GENERATED_VERSION = '1.82.1'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in ai_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class AiServiceStub:
"""AiService 定义 AI 网关契约D6 智能洞察领域 · 生成子域)
端口HTTP 3008 + gRPC 50058port-allocation.md §3/§5/§7 权威源)
HTTP 保留作 Gateway 直连降级 + 前端 SSE 流式gRPC 为 BFF 主入口
响应信封遵循 ActionState004 §11.5),降级采用方案 B总裁裁决 §2.6
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.Chat = channel.unary_unary(
'/next_edu_cloud.ai.v1.AiService/Chat',
request_serializer=ai__pb2.ChatRequest.SerializeToString,
response_deserializer=ai__pb2.ChatResponse.FromString,
_registered_method=True)
self.StreamChat = channel.unary_stream(
'/next_edu_cloud.ai.v1.AiService/StreamChat',
request_serializer=ai__pb2.ChatRequest.SerializeToString,
response_deserializer=ai__pb2.ChatChunk.FromString,
_registered_method=True)
self.GenerateQuestion = channel.unary_unary(
'/next_edu_cloud.ai.v1.AiService/GenerateQuestion',
request_serializer=ai__pb2.GenerateQuestionRequest.SerializeToString,
response_deserializer=ai__pb2.GeneratedQuestion.FromString,
_registered_method=True)
self.StreamGenerateQuestion = channel.unary_stream(
'/next_edu_cloud.ai.v1.AiService/StreamGenerateQuestion',
request_serializer=ai__pb2.GenerateQuestionRequest.SerializeToString,
response_deserializer=ai__pb2.GeneratedQuestionChunk.FromString,
_registered_method=True)
self.OptimizeExpression = channel.unary_unary(
'/next_edu_cloud.ai.v1.AiService/OptimizeExpression',
request_serializer=ai__pb2.OptimizeExpressionRequest.SerializeToString,
response_deserializer=ai__pb2.OptimizedExpression.FromString,
_registered_method=True)
self.GenerateLessonPlan = channel.unary_unary(
'/next_edu_cloud.ai.v1.AiService/GenerateLessonPlan',
request_serializer=ai__pb2.GenerateLessonPlanRequest.SerializeToString,
response_deserializer=ai__pb2.LessonPlanResponse.FromString,
_registered_method=True)
self.GetLessonPlanStatus = channel.unary_unary(
'/next_edu_cloud.ai.v1.AiService/GetLessonPlanStatus',
request_serializer=ai__pb2.GetLessonPlanStatusRequest.SerializeToString,
response_deserializer=ai__pb2.LessonPlanStatus.FromString,
_registered_method=True)
self.ConfirmLessonPlan = channel.unary_unary(
'/next_edu_cloud.ai.v1.AiService/ConfirmLessonPlan',
request_serializer=ai__pb2.ConfirmLessonPlanRequest.SerializeToString,
response_deserializer=ai__pb2.ConfirmResult.FromString,
_registered_method=True)
class AiServiceServicer:
"""AiService 定义 AI 网关契约D6 智能洞察领域 · 生成子域)
端口HTTP 3008 + gRPC 50058port-allocation.md §3/§5/§7 权威源)
HTTP 保留作 Gateway 直连降级 + 前端 SSE 流式gRPC 为 BFF 主入口
响应信封遵循 ActionState004 §11.5),降级采用方案 B总裁裁决 §2.6
"""
def Chat(self, request, context):
"""非流式聊天
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def StreamChat(self, request, context):
"""流式聊天SSE over gRPC
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GenerateQuestion(self, request, context):
"""生成题目(非流式)
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def StreamGenerateQuestion(self, request, context):
"""题目逐字流式生成
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def OptimizeExpression(self, request, context):
"""优化表达
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GenerateLessonPlan(self, request, context):
"""备课工作流启动4 步编排:分析学情 → 推荐知识点 → 生成题目 → 教师审核)
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetLessonPlanStatus(self, request, context):
"""查询备课工作流状态
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ConfirmLessonPlan(self, request, context):
"""教师确认备课结果入库(调 content.CreateQuestions
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_AiServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'Chat': grpc.unary_unary_rpc_method_handler(
servicer.Chat,
request_deserializer=ai__pb2.ChatRequest.FromString,
response_serializer=ai__pb2.ChatResponse.SerializeToString,
),
'StreamChat': grpc.unary_stream_rpc_method_handler(
servicer.StreamChat,
request_deserializer=ai__pb2.ChatRequest.FromString,
response_serializer=ai__pb2.ChatChunk.SerializeToString,
),
'GenerateQuestion': grpc.unary_unary_rpc_method_handler(
servicer.GenerateQuestion,
request_deserializer=ai__pb2.GenerateQuestionRequest.FromString,
response_serializer=ai__pb2.GeneratedQuestion.SerializeToString,
),
'StreamGenerateQuestion': grpc.unary_stream_rpc_method_handler(
servicer.StreamGenerateQuestion,
request_deserializer=ai__pb2.GenerateQuestionRequest.FromString,
response_serializer=ai__pb2.GeneratedQuestionChunk.SerializeToString,
),
'OptimizeExpression': grpc.unary_unary_rpc_method_handler(
servicer.OptimizeExpression,
request_deserializer=ai__pb2.OptimizeExpressionRequest.FromString,
response_serializer=ai__pb2.OptimizedExpression.SerializeToString,
),
'GenerateLessonPlan': grpc.unary_unary_rpc_method_handler(
servicer.GenerateLessonPlan,
request_deserializer=ai__pb2.GenerateLessonPlanRequest.FromString,
response_serializer=ai__pb2.LessonPlanResponse.SerializeToString,
),
'GetLessonPlanStatus': grpc.unary_unary_rpc_method_handler(
servicer.GetLessonPlanStatus,
request_deserializer=ai__pb2.GetLessonPlanStatusRequest.FromString,
response_serializer=ai__pb2.LessonPlanStatus.SerializeToString,
),
'ConfirmLessonPlan': grpc.unary_unary_rpc_method_handler(
servicer.ConfirmLessonPlan,
request_deserializer=ai__pb2.ConfirmLessonPlanRequest.FromString,
response_serializer=ai__pb2.ConfirmResult.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'next_edu_cloud.ai.v1.AiService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('next_edu_cloud.ai.v1.AiService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class AiService:
"""AiService 定义 AI 网关契约D6 智能洞察领域 · 生成子域)
端口HTTP 3008 + gRPC 50058port-allocation.md §3/§5/§7 权威源)
HTTP 保留作 Gateway 直连降级 + 前端 SSE 流式gRPC 为 BFF 主入口
响应信封遵循 ActionState004 §11.5),降级采用方案 B总裁裁决 §2.6
"""
@staticmethod
def Chat(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.ai.v1.AiService/Chat',
ai__pb2.ChatRequest.SerializeToString,
ai__pb2.ChatResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def StreamChat(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/next_edu_cloud.ai.v1.AiService/StreamChat',
ai__pb2.ChatRequest.SerializeToString,
ai__pb2.ChatChunk.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GenerateQuestion(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.ai.v1.AiService/GenerateQuestion',
ai__pb2.GenerateQuestionRequest.SerializeToString,
ai__pb2.GeneratedQuestion.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def StreamGenerateQuestion(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/next_edu_cloud.ai.v1.AiService/StreamGenerateQuestion',
ai__pb2.GenerateQuestionRequest.SerializeToString,
ai__pb2.GeneratedQuestionChunk.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def OptimizeExpression(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.ai.v1.AiService/OptimizeExpression',
ai__pb2.OptimizeExpressionRequest.SerializeToString,
ai__pb2.OptimizedExpression.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GenerateLessonPlan(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.ai.v1.AiService/GenerateLessonPlan',
ai__pb2.GenerateLessonPlanRequest.SerializeToString,
ai__pb2.LessonPlanResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetLessonPlanStatus(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.ai.v1.AiService/GetLessonPlanStatus',
ai__pb2.GetLessonPlanStatusRequest.SerializeToString,
ai__pb2.LessonPlanStatus.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def ConfirmLessonPlan(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/next_edu_cloud.ai.v1.AiService/ConfirmLessonPlan',
ai__pb2.ConfirmLessonPlanRequest.SerializeToString,
ai__pb2.ConfirmResult.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)

View File

@@ -0,0 +1,91 @@
"""LLM Provider 适配器模块02-architecture-design.md §13.
提供:
- LLMProvider 抽象接口
- 4 个 Provider 适配器OpenAI / Anthropic / Baichuan / LocalOllama
- CircuitBreaker 熔断器
- ProviderFailoverChain 故障切换链
- create_failover_chain 工厂(从 Settings 构建)
"""
from typing import TYPE_CHECKING
from .base import LLMProvider, LLMResponse, LLMStreamChunk
from .circuit_breaker import CircuitBreaker, CircuitState
from .failover import ProviderFailoverChain
from .ollama_provider import LocalOllamaProvider
from .openai_provider import OpenAIProvider
if TYPE_CHECKING:
from ..config import Settings
__all__ = [
"LLMProvider",
"LLMResponse",
"LLMStreamChunk",
"CircuitBreaker",
"CircuitState",
"ProviderFailoverChain",
"OpenAIProvider",
"AnthropicProvider",
"BaichuanProvider",
"LocalOllamaProvider",
"create_failover_chain",
]
def create_failover_chain(settings: "Settings") -> ProviderFailoverChain:
"""从 Settings 构建 ProviderFailoverChain.
按 settings.llm_provider_priority 顺序注册 Provider
仅注册已配置的 Provideris_available=True
"""
from .anthropic_provider import AnthropicProvider
from .baichuan_provider import BaichuanProvider
provider_map: dict[str, LLMProvider] = {
"openai": OpenAIProvider(
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
timeout=settings.llm_timeout_seconds,
stream_connect_timeout=settings.llm_stream_connect_timeout,
stream_read_timeout=settings.llm_stream_read_timeout,
),
"anthropic": AnthropicProvider(
api_key=settings.anthropic_api_key,
base_url=settings.anthropic_base_url,
timeout=settings.llm_timeout_seconds,
stream_connect_timeout=settings.llm_stream_connect_timeout,
stream_read_timeout=settings.llm_stream_read_timeout,
),
"baichuan": BaichuanProvider(
api_key=settings.baichuan_api_key,
base_url=settings.baichuan_base_url,
timeout=settings.llm_timeout_seconds,
stream_connect_timeout=settings.llm_stream_connect_timeout,
stream_read_timeout=settings.llm_stream_read_timeout,
),
"local_ollama": LocalOllamaProvider(
base_url=settings.ollama_base_url,
timeout=settings.llm_timeout_seconds,
stream_connect_timeout=settings.llm_stream_connect_timeout,
stream_read_timeout=settings.llm_stream_read_timeout,
),
}
priority = settings.provider_priority_list
providers: list[LLMProvider] = []
for name in priority:
provider = provider_map.get(name.strip())
if provider is not None:
providers.append(provider)
# 无 Provider 时至少注册一个 OpenAI避免构造失败
if not providers:
providers.append(provider_map["openai"])
circuit_breaker = CircuitBreaker(
failure_threshold=3,
cooldown_seconds=60.0,
)
return ProviderFailoverChain(providers, circuit_breaker)

View File

@@ -0,0 +1,239 @@
"""Anthropic Claude Provider 适配器.
基于 httpx 异步调用 Anthropic Messages API/v1/messages
使用 x-api-key 认证 + anthropic-version header。
"""
import json
from collections.abc import AsyncGenerator
from typing import Any
import httpx
import structlog
from ..errors import AILLMUnavailableError
from .base import LLMProvider, LLMResponse, LLMStreamChunk
logger = structlog.get_logger()
ANTHROPIC_API_VERSION = "2023-06-01"
class AnthropicProvider(LLMProvider):
"""Anthropic Claude Provider.
调用 /v1/messages 端点,支持流式与非流式。
"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.anthropic.com",
timeout: float = 30.0,
stream_connect_timeout: float = 30.0,
stream_read_timeout: float = 60.0,
) -> None:
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._stream_connect_timeout = stream_connect_timeout
self._stream_read_timeout = stream_read_timeout
@property
def name(self) -> str:
return "anthropic"
def is_available(self) -> bool:
return bool(self._api_key)
def _build_url(self) -> str:
return f"{self._base_url}/v1/messages"
def _build_headers(self) -> dict[str, str]:
return {
"x-api-key": self._api_key,
"anthropic-version": ANTHROPIC_API_VERSION,
"Content-Type": "application/json",
}
def _convert_messages(
self,
messages: list[dict[str, str]],
) -> tuple[str, list[dict[str, str]]]:
"""将 OpenAI 格式消息转换为 Anthropic 格式.
Anthropic 要求 system 消息单独传递user/assistant 在 messages 数组。
Returns:
(system_prompt, messages)
"""
system_parts: list[str] = []
converted: list[dict[str, str]] = []
for msg in messages:
if msg.get("role") == "system":
system_parts.append(msg.get("content", ""))
else:
converted.append(
{"role": msg.get("role", "user"), "content": msg.get("content", "")},
)
return "\n\n".join(system_parts), converted
async def chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> LLMResponse:
if not self.is_available():
raise AILLMUnavailableError("anthropic api_key not configured")
system_prompt, converted = self._convert_messages(messages)
max_tokens = kwargs.pop("max_tokens", 4096)
payload: dict[str, Any] = {
"model": model,
"messages": converted,
"temperature": temperature,
"max_tokens": max_tokens,
}
if system_prompt:
payload["system"] = system_prompt
payload.update(kwargs)
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(
self._build_url(),
json=payload,
headers=self._build_headers(),
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
logger.error(
"anthropic_chat_http_error",
status_code=exc.response.status_code,
body=exc.response.text[:500],
)
raise AILLMUnavailableError(
f"anthropic http {exc.response.status_code}: {exc.response.text[:200]}",
) from exc
except httpx.HTTPError as exc:
logger.error("anthropic_chat_http_error", error=str(exc))
raise AILLMUnavailableError(f"anthropic http error: {exc}") from exc
content_parts = data.get("content", [])
content = ""
if content_parts:
content = content_parts[0].get("text", "") or ""
usage_raw = data.get("usage", {}) or {}
prompt_tokens = int(usage_raw.get("input_tokens", 0))
completion_tokens = int(usage_raw.get("output_tokens", 0))
usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
return LLMResponse(
content=content,
model=data.get("model", model),
usage=usage,
provider=self.name,
raw=data,
)
async def stream_chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> AsyncGenerator[LLMStreamChunk, None]:
if not self.is_available():
raise AILLMUnavailableError("anthropic api_key not configured")
system_prompt, converted = self._convert_messages(messages)
max_tokens = kwargs.pop("max_tokens", 4096)
payload: dict[str, Any] = {
"model": model,
"messages": converted,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
}
if system_prompt:
payload["system"] = system_prompt
payload.update(kwargs)
timeout = httpx.Timeout(
connect=self._stream_connect_timeout,
read=self._stream_read_timeout,
write=self._stream_connect_timeout,
pool=self._stream_connect_timeout,
)
try:
async with (
httpx.AsyncClient(timeout=timeout) as client,
client.stream(
"POST",
self._build_url(),
json=payload,
headers=self._build_headers(),
) as resp,
):
resp.raise_for_status()
async for line in resp.aiter_lines():
chunk = self._parse_sse_line(line, model)
if chunk is not None:
yield chunk
if chunk.finish_reason == "end_turn":
return
except httpx.HTTPStatusError as exc:
logger.error(
"anthropic_stream_http_error",
status_code=exc.response.status_code,
)
raise AILLMUnavailableError(
f"anthropic stream http {exc.response.status_code}",
) from exc
except httpx.HTTPError as exc:
logger.error("anthropic_stream_http_error", error=str(exc))
raise AILLMUnavailableError(f"anthropic stream error: {exc}") from exc
@staticmethod
def _parse_sse_line(line: str, model: str) -> LLMStreamChunk | None:
"""解析 Anthropic SSE 事件流.
Anthropic 事件格式:
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}
关键事件:
content_block_delta: 文本增量
message_stop: 结束
"""
if not line or not line.startswith("data: "):
return None
data_str = line[len("data: "):]
try:
data = json.loads(data_str)
except json.JSONDecodeError:
return None
event_type = data.get("type", "")
if event_type == "content_block_delta":
delta = data.get("delta", {})
if delta.get("type") == "text_delta":
return LLMStreamChunk(
delta=delta.get("text", ""),
model=model,
provider="anthropic",
)
return None
if event_type == "message_stop":
return LLMStreamChunk(
delta="",
model=model,
finish_reason="end_turn",
provider="anthropic",
)
return None

View File

@@ -0,0 +1,172 @@
"""Baichuan百川智能Provider 适配器.
百川 API 兼容 OpenAI 格式(/v1/chat/completions认证使用 Bearer token。
复用 OpenAI SSE 解析逻辑。
"""
from collections.abc import AsyncGenerator
from typing import Any
import httpx
import structlog
from ..errors import AILLMUnavailableError
from .base import LLMProvider, LLMResponse, LLMStreamChunk
from .openai_provider import OpenAIProvider
logger = structlog.get_logger()
class BaichuanProvider(LLMProvider):
"""百川智能 Provider.
API 兼容 OpenAI 格式,复用 OpenAIProvider 的 SSE 解析。
"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.baichuan-ai.com/v1",
timeout: float = 30.0,
stream_connect_timeout: float = 30.0,
stream_read_timeout: float = 60.0,
) -> None:
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._stream_connect_timeout = stream_connect_timeout
self._stream_read_timeout = stream_read_timeout
@property
def name(self) -> str:
return "baichuan"
def is_available(self) -> bool:
return bool(self._api_key)
def _build_url(self) -> str:
return f"{self._base_url}/chat/completions"
def _build_headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
async def chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> LLMResponse:
if not self.is_available():
raise AILLMUnavailableError("baichuan api_key not configured")
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": False,
}
payload.update(kwargs)
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(
self._build_url(),
json=payload,
headers=self._build_headers(),
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
logger.error(
"baichuan_chat_http_error",
status_code=exc.response.status_code,
body=exc.response.text[:500],
)
raise AILLMUnavailableError(
f"baichuan http {exc.response.status_code}: {exc.response.text[:200]}",
) from exc
except httpx.HTTPError as exc:
logger.error("baichuan_chat_http_error", error=str(exc))
raise AILLMUnavailableError(f"baichuan http error: {exc}") from exc
choices = data.get("choices", [])
content = ""
if choices:
content = choices[0].get("message", {}).get("content", "") or ""
usage_raw = data.get("usage", {}) or {}
usage = {
"prompt_tokens": int(usage_raw.get("prompt_tokens", 0)),
"completion_tokens": int(usage_raw.get("completion_tokens", 0)),
"total_tokens": int(usage_raw.get("total_tokens", 0)),
}
return LLMResponse(
content=content,
model=data.get("model", model),
usage=usage,
provider=self.name,
raw=data,
)
async def stream_chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> AsyncGenerator[LLMStreamChunk, None]:
if not self.is_available():
raise AILLMUnavailableError("baichuan api_key not configured")
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": True,
}
payload.update(kwargs)
timeout = httpx.Timeout(
connect=self._stream_connect_timeout,
read=self._stream_read_timeout,
write=self._stream_connect_timeout,
pool=self._stream_connect_timeout,
)
try:
async with (
httpx.AsyncClient(timeout=timeout) as client,
client.stream(
"POST",
self._build_url(),
json=payload,
headers=self._build_headers(),
) as resp,
):
resp.raise_for_status()
async for line in resp.aiter_lines():
# 复用 OpenAI SSE 解析(百川兼容 OpenAI 格式)
chunk = OpenAIProvider._parse_sse_line(line, model)
if chunk is not None:
chunk.provider = "baichuan"
yield chunk
if chunk.finish_reason == "stop":
return
except httpx.HTTPStatusError as exc:
logger.error(
"baichuan_stream_http_error",
status_code=exc.response.status_code,
)
raise AILLMUnavailableError(
f"baichuan stream http {exc.response.status_code}",
) from exc
except httpx.HTTPError as exc:
logger.error("baichuan_stream_http_error", error=str(exc))
raise AILLMUnavailableError(f"baichuan stream error: {exc}") from exc
async def embed(self, text: str, model: str) -> list[float]:
"""百川暂不支持 embedding调用方应优先使用 OpenAI embed."""
raise NotImplementedError("baichuan does not support embed")

View File

@@ -0,0 +1,100 @@
"""LLM Provider 抽象接口02-architecture-design.md §13.1.
所有 Provider 适配器实现此接口。ProviderFailoverChain 依赖此抽象进行故障切换。
"""
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from typing import Any
@dataclass
class LLMResponse:
"""LLM 非流式响应统一结构.
各 Provider 适配器将原始响应转换为此结构,屏蔽厂商差异。
"""
content: str
model: str
usage: dict[str, int] = field(
default_factory=lambda: {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
)
provider: str = ""
raw: dict[str, Any] | None = None
@dataclass
class LLMStreamChunk:
"""LLM 流式 chunk 统一结构."""
delta: str
model: str
finish_reason: str | None = None
provider: str = ""
class LLMProvider(ABC):
"""LLM Provider 抽象基类.
实现类需提供:
- name: Provider 标识(用于熔断器 + 日志 + 用量事件)
- chat: 非流式对话
- stream_chat: 流式对话(异步生成器)
- embed: 文本向量化(可选,默认抛 NotImplementedError
- is_available: 是否已配置可用
"""
@property
@abstractmethod
def name(self) -> str:
"""Provider 唯一标识(如 'openai' / 'anthropic'."""
@abstractmethod
def is_available(self) -> bool:
"""Provider 是否已配置API key / base_url 非空)."""
@abstractmethod
async def chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> LLMResponse:
"""非流式对话.
Args:
messages: OpenAI 格式消息列表 [{"role": "...", "content": "..."}]
model: 模型标识
temperature: 采样温度 0.0-2.0
**kwargs: Provider 特定参数
Returns:
LLMResponse 统一响应
Raises:
AILLMUnavailableError: 调用失败(由 FailoverChain 捕获切换)
"""
@abstractmethod
async def stream_chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> AsyncGenerator[LLMStreamChunk, None]:
"""流式对话.
Yields:
LLMStreamChunk 统一 chunk
"""
async def embed(self, text: str, model: str) -> list[float]:
"""文本向量化(可选能力).
默认抛 NotImplementedError支持 embedding 的 Provider 覆写此方法。
"""
raise NotImplementedError(f"{self.name} does not support embed")

View File

@@ -0,0 +1,135 @@
"""Provider 熔断器CircuitBreaker.
设计依据 02-architecture-design.md §13.2
- 3 次连续失败触发熔断
- 熔断 60s 后进入半开状态
- 半开状态允许 1 次试探调用,成功则关闭熔断,失败则重新熔断
状态机:
CLOSED → (failures >= threshold) → OPEN
OPEN → (after cooldown) → HALF_OPEN
HALF_OPEN → success → CLOSED
HALF_OPEN → failure → OPEN
"""
import time
from enum import StrEnum
import structlog
logger = structlog.get_logger()
class CircuitState(StrEnum):
"""熔断器状态."""
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
"""单 Provider 熔断器.
线程安全由 asyncio 单线程模型保证FastAPI 事件循环)。
如需跨进程共享,应迁移到 Redis 实现。
"""
def __init__(
self,
failure_threshold: int = 3,
cooldown_seconds: float = 60.0,
half_open_max_calls: int = 1,
) -> None:
self._failure_threshold = failure_threshold
self._cooldown_seconds = cooldown_seconds
self._half_open_max_calls = half_open_max_calls
# 每个 provider 独立状态
self._states: dict[str, CircuitState] = {}
self._failure_counts: dict[str, int] = {}
self._last_failure_times: dict[str, float] = {}
self._half_open_calls: dict[str, int] = {}
def get_state(self, provider_name: str) -> CircuitState:
"""获取 Provider 当前熔断状态(含自动转换 OPEN → HALF_OPEN."""
state = self._states.get(provider_name, CircuitState.CLOSED)
if state == CircuitState.OPEN:
last_failure = self._last_failure_times.get(provider_name, 0)
if time.monotonic() - last_failure >= self._cooldown_seconds:
self._states[provider_name] = CircuitState.HALF_OPEN
self._half_open_calls[provider_name] = 0
logger.info(
"circuit_breaker_half_open",
provider=provider_name,
cooldown_seconds=self._cooldown_seconds,
)
return CircuitState.HALF_OPEN
return self._states.get(provider_name, CircuitState.CLOSED)
def is_closed(self, provider_name: str) -> bool:
"""Provider 是否允许调用CLOSED 或 HALF_OPEN 未超限)."""
state = self.get_state(provider_name)
if state == CircuitState.CLOSED:
return True
if state == CircuitState.HALF_OPEN:
calls = self._half_open_calls.get(provider_name, 0)
if calls < self._half_open_max_calls:
self._half_open_calls[provider_name] = calls + 1
return True
return False
return False
def record_success(self, provider_name: str) -> None:
"""记录调用成功重置失败计数HALF_OPEN → CLOSED."""
self._failure_counts[provider_name] = 0
old_state = self._states.get(provider_name, CircuitState.CLOSED)
if old_state == CircuitState.HALF_OPEN:
self._states[provider_name] = CircuitState.CLOSED
logger.info(
"circuit_breaker_recovered",
provider=provider_name,
)
def record_failure(self, provider_name: str) -> None:
"""记录调用失败(累加计数,达阈值则 OPEN."""
self._failure_counts[provider_name] = self._failure_counts.get(provider_name, 0) + 1
self._last_failure_times[provider_name] = time.monotonic()
state = self._states.get(provider_name, CircuitState.CLOSED)
if state == CircuitState.HALF_OPEN:
# 半开状态失败,立即重新熔断
self._states[provider_name] = CircuitState.OPEN
logger.warning(
"circuit_breaker_reopened",
provider=provider_name,
)
return
failures = self._failure_counts[provider_name]
if failures >= self._failure_threshold:
self._states[provider_name] = CircuitState.OPEN
logger.warning(
"circuit_breaker_opened",
provider=provider_name,
failures=failures,
threshold=self._failure_threshold,
)
def reset(self, provider_name: str) -> None:
"""重置 Provider 熔断状态(手动恢复用)."""
self._states.pop(provider_name, None)
self._failure_counts.pop(provider_name, None)
self._last_failure_times.pop(provider_name, None)
self._half_open_calls.pop(provider_name, None)
def status(self) -> dict[str, dict[str, object]]:
"""所有 Provider 熔断状态快照(/readyz 用)."""
result: dict[str, dict[str, object]] = {}
all_providers = set(self._states) | set(self._failure_counts)
for name in all_providers:
result[name] = {
"state": self.get_state(name).value,
"failures": self._failure_counts.get(name, 0),
"last_failure_at": self._last_failure_times.get(name),
}
return result

View File

@@ -0,0 +1,221 @@
"""Provider 故障切换链ProviderFailoverChain.
设计依据 02-architecture-design.md §13.3
- 按 priority 顺序尝试 Provider
- 熔断器拦截已熔断的 Provider
- 单 Provider 失败自动切换下一个
- 全部失败抛 AILLMUnavailableError 触发降级
"""
from collections.abc import AsyncGenerator
from typing import Any
import structlog
from ..errors import AILLMUnavailableError
from .base import LLMProvider, LLMResponse, LLMStreamChunk
from .circuit_breaker import CircuitBreaker
logger = structlog.get_logger()
class ProviderFailoverChain:
"""Provider 故障切换链.
用法:
chain = ProviderFailoverChain(providers, circuit_breaker)
response = await chain.chat(messages, model) # 自动 failover
"""
def __init__(
self,
providers: list[LLMProvider],
circuit_breaker: CircuitBreaker,
) -> None:
if not providers:
raise ValueError("providers list must not be empty")
self._providers = providers
self._provider_map: dict[str, LLMProvider] = {p.name: p for p in providers}
self._circuit_breaker = circuit_breaker
@property
def providers(self) -> list[LLMProvider]:
"""已注册的 Provider 列表."""
return list(self._providers)
@property
def circuit_breaker(self) -> CircuitBreaker:
return self._circuit_breaker
def available_providers(self) -> list[LLMProvider]:
"""返回已配置且未熔断的 Provider 列表."""
return [
p
for p in self._providers
if p.is_available() and self._circuit_breaker.is_closed(p.name)
]
async def chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> LLMResponse:
"""非流式对话(自动 failover.
Returns:
LLMResponse来自第一个成功的 Provider
Raises:
AILLMUnavailableError: 所有 Provider 都失败
"""
tried: list[str] = []
last_error: Exception | None = None
for provider in self._providers:
if not provider.is_available():
logger.debug("provider_skip_not_configured", provider=provider.name)
continue
if not self._circuit_breaker.is_closed(provider.name):
logger.info(
"provider_skip_circuit_open",
provider=provider.name,
)
continue
tried.append(provider.name)
try:
response = await provider.chat(messages, model, temperature, **kwargs)
self._circuit_breaker.record_success(provider.name)
logger.info(
"llm_chat_success",
provider=provider.name,
model=response.model,
total_tokens=response.usage.get("total_tokens", 0),
)
return response
except AILLMUnavailableError as exc:
last_error = exc
self._circuit_breaker.record_failure(provider.name)
logger.warning(
"provider_failover",
failed_provider=provider.name,
error=str(exc),
)
continue
except Exception as exc: # noqa: BLE001 - 兜底,未知异常也切换
last_error = exc
self._circuit_breaker.record_failure(provider.name)
logger.error(
"provider_unexpected_error",
provider=provider.name,
error=str(exc),
)
continue
logger.error(
"all_providers_failed",
tried=tried,
last_error=str(last_error) if last_error else "no provider available",
)
raise AILLMUnavailableError(
f"all providers failed (tried: {tried or 'none configured'})",
)
async def stream_chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> AsyncGenerator[LLMStreamChunk, None]:
"""流式对话(自动 failover.
注意:流式 failover 仅在首 chunk 前生效。一旦开始 yield
中途断流不再切换(调用方需处理中断)。
Yields:
LLMStreamChunk来自第一个成功的 Provider
Raises:
AILLMUnavailableError: 所有 Provider 都无法建立流
"""
tried: list[str] = []
last_error: Exception | None = None
for provider in self._providers:
if not provider.is_available():
continue
if not self._circuit_breaker.is_closed(provider.name):
continue
tried.append(provider.name)
try:
# 尝试建立流(通过迭代验证连通性,首 chunk 后不再 failover
stream = provider.stream_chat(messages, model, temperature, **kwargs)
async for chunk in stream:
yield chunk
# 流正常结束
self._circuit_breaker.record_success(provider.name)
logger.info(
"llm_stream_success",
provider=provider.name,
model=model,
)
return
except AILLMUnavailableError as exc:
last_error = exc
self._circuit_breaker.record_failure(provider.name)
logger.warning(
"provider_stream_failover",
failed_provider=provider.name,
error=str(exc),
)
continue
except Exception as exc: # noqa: BLE001
last_error = exc
self._circuit_breaker.record_failure(provider.name)
logger.error(
"provider_stream_unexpected_error",
provider=provider.name,
error=str(exc),
)
continue
logger.error(
"all_providers_stream_failed",
tried=tried,
last_error=str(last_error) if last_error else "no provider available",
)
raise AILLMUnavailableError(
f"all providers stream failed (tried: {tried or 'none configured'})",
)
async def embed(self, text: str, model: str) -> tuple[list[float], str]:
"""文本向量化(自动 failover.
Returns:
(embedding, provider_name)
"""
for provider in self._providers:
if not provider.is_available():
continue
if not self._circuit_breaker.is_closed(provider.name):
continue
try:
embedding = await provider.embed(text, model)
self._circuit_breaker.record_success(provider.name)
return embedding, provider.name
except NotImplementedError:
logger.debug("provider_no_embed", provider=provider.name)
continue
except AILLMUnavailableError as exc:
self._circuit_breaker.record_failure(provider.name)
logger.warning(
"provider_embed_failover",
failed_provider=provider.name,
error=str(exc),
)
continue
raise AILLMUnavailableError("no provider supports embed or all failed")

View File

@@ -0,0 +1,191 @@
"""Local Ollama Provider 适配器.
Ollama 本地部署http://localhost:11434无需 API key。
调用 /api/chat 端点,支持流式与非流式。
"""
import json
from collections.abc import AsyncGenerator
from typing import Any
import httpx
import structlog
from ..errors import AILLMUnavailableError
from .base import LLMProvider, LLMResponse, LLMStreamChunk
logger = structlog.get_logger()
class LocalOllamaProvider(LLMProvider):
"""本地 Ollama Provider.
Ollama API 格式与 OpenAI 略有不同:
- 端点:/api/chat非 /v1/chat/completions
- 消息格式:{"role": "...", "content": "..."}(兼容)
- 流式:逐行 JSON非 SSE
"""
def __init__(
self,
base_url: str = "http://localhost:11434",
timeout: float = 60.0,
stream_connect_timeout: float = 30.0,
stream_read_timeout: float = 120.0,
) -> None:
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._stream_connect_timeout = stream_connect_timeout
self._stream_read_timeout = stream_read_timeout
@property
def name(self) -> str:
return "local_ollama"
def is_available(self) -> bool:
return bool(self._base_url)
def _build_url(self) -> str:
return f"{self._base_url}/api/chat"
async def chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> LLMResponse:
if not self.is_available():
raise AILLMUnavailableError("ollama base_url not configured")
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"options": {"temperature": temperature},
"stream": False,
}
payload.update(kwargs)
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(self._build_url(), json=payload)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
logger.error(
"ollama_chat_http_error",
status_code=exc.response.status_code,
body=exc.response.text[:500],
)
raise AILLMUnavailableError(
f"ollama http {exc.response.status_code}: {exc.response.text[:200]}",
) from exc
except httpx.HTTPError as exc:
logger.error("ollama_chat_http_error", error=str(exc))
raise AILLMUnavailableError(f"ollama http error: {exc}") from exc
message = data.get("message", {})
content = message.get("content", "") or ""
# Ollama 返回 eval_count 等,转换为通用 usage
prompt_tokens = int(data.get("prompt_eval_count", 0))
completion_tokens = int(data.get("eval_count", 0))
usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
return LLMResponse(
content=content,
model=data.get("model", model),
usage=usage,
provider=self.name,
raw=data,
)
async def stream_chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> AsyncGenerator[LLMStreamChunk, None]:
if not self.is_available():
raise AILLMUnavailableError("ollama base_url not configured")
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"options": {"temperature": temperature},
"stream": True,
}
payload.update(kwargs)
timeout = httpx.Timeout(
connect=self._stream_connect_timeout,
read=self._stream_read_timeout,
write=self._stream_connect_timeout,
pool=self._stream_connect_timeout,
)
try:
async with (
httpx.AsyncClient(timeout=timeout) as client,
client.stream("POST", self._build_url(), json=payload) as resp,
):
resp.raise_for_status()
async for line in resp.aiter_lines():
chunk = self._parse_ndjson_line(line, model)
if chunk is not None:
yield chunk
if chunk.finish_reason == "stop":
return
except httpx.HTTPStatusError as exc:
logger.error(
"ollama_stream_http_error",
status_code=exc.response.status_code,
)
raise AILLMUnavailableError(
f"ollama stream http {exc.response.status_code}",
) from exc
except httpx.HTTPError as exc:
logger.error("ollama_stream_http_error", error=str(exc))
raise AILLMUnavailableError(f"ollama stream error: {exc}") from exc
@staticmethod
def _parse_ndjson_line(line: str, model: str) -> LLMStreamChunk | None:
"""解析 Ollama NDJSON 格式(每行一个 JSON 对象).
Ollama 流式响应格式:
{"model":"...","message":{"role":"assistant","content":"..."},"done":false}
{"model":"...","message":{"role":"assistant","content":""},"done":true}
"""
if not line:
return None
try:
data = json.loads(line)
except json.JSONDecodeError:
return None
message = data.get("message", {})
delta = message.get("content", "") or ""
done = data.get("done", False)
return LLMStreamChunk(
delta=delta,
model=data.get("model", model),
finish_reason="stop" if done else None,
provider="local_ollama",
)
async def embed(self, text: str, model: str = "nomic-embed-text") -> list[float]:
"""文本向量化Ollama embedding API."""
if not self.is_available():
raise AILLMUnavailableError("ollama base_url not configured")
url = f"{self._base_url}/api/embeddings"
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(url, json={"model": model, "prompt": text})
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as exc:
logger.error("ollama_embed_error", error=str(exc))
raise AILLMUnavailableError(f"ollama embed error: {exc}") from exc
return data.get("embedding", [])

View File

@@ -0,0 +1,216 @@
"""OpenAI Provider 适配器.
基于 httpx 异步调用 OpenAI 兼容 REST API/chat/completions
不依赖 openai SDK纯 httpx 实现,便于控制超时与重试。
"""
import json
from collections.abc import AsyncGenerator
from typing import Any
import httpx
import structlog
from ..errors import AILLMUnavailableError
from .base import LLMProvider, LLMResponse, LLMStreamChunk
logger = structlog.get_logger()
class OpenAIProvider(LLMProvider):
"""OpenAI 兼容 API Provider.
支持所有 OpenAI 兼容端点OpenAI 官方 / Azure OpenAI / 自部署 vLLM 等)。
"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.openai.com/v1",
timeout: float = 30.0,
stream_connect_timeout: float = 30.0,
stream_read_timeout: float = 60.0,
) -> None:
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._stream_connect_timeout = stream_connect_timeout
self._stream_read_timeout = stream_read_timeout
@property
def name(self) -> str:
return "openai"
def is_available(self) -> bool:
return bool(self._api_key)
def _build_url(self) -> str:
return f"{self._base_url}/chat/completions"
def _build_headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
async def chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> LLMResponse:
if not self.is_available():
raise AILLMUnavailableError("openai api_key not configured")
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": False,
}
payload.update(kwargs)
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(
self._build_url(),
json=payload,
headers=self._build_headers(),
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
logger.error(
"openai_chat_http_error",
status_code=exc.response.status_code,
body=exc.response.text[:500],
)
raise AILLMUnavailableError(
f"openai http {exc.response.status_code}: {exc.response.text[:200]}",
) from exc
except httpx.HTTPError as exc:
logger.error("openai_chat_http_error", error=str(exc))
raise AILLMUnavailableError(f"openai http error: {exc}") from exc
choices = data.get("choices", [])
content = ""
if choices:
content = choices[0].get("message", {}).get("content", "") or ""
usage_raw = data.get("usage", {}) or {}
usage = {
"prompt_tokens": int(usage_raw.get("prompt_tokens", 0)),
"completion_tokens": int(usage_raw.get("completion_tokens", 0)),
"total_tokens": int(usage_raw.get("total_tokens", 0)),
}
return LLMResponse(
content=content,
model=data.get("model", model),
usage=usage,
provider=self.name,
raw=data,
)
async def stream_chat(
self,
messages: list[dict[str, str]],
model: str,
temperature: float = 0.7,
**kwargs: Any,
) -> AsyncGenerator[LLMStreamChunk, None]:
if not self.is_available():
raise AILLMUnavailableError("openai api_key not configured")
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": True,
}
payload.update(kwargs)
timeout = httpx.Timeout(
connect=self._stream_connect_timeout,
read=self._stream_read_timeout,
write=self._stream_connect_timeout,
pool=self._stream_connect_timeout,
)
try:
async with (
httpx.AsyncClient(timeout=timeout) as client,
client.stream(
"POST",
self._build_url(),
json=payload,
headers=self._build_headers(),
) as resp,
):
resp.raise_for_status()
async for line in resp.aiter_lines():
chunk = self._parse_sse_line(line, model)
if chunk is not None:
yield chunk
if chunk.finish_reason == "stop":
return
except httpx.HTTPStatusError as exc:
logger.error(
"openai_stream_http_error",
status_code=exc.response.status_code,
)
raise AILLMUnavailableError(
f"openai stream http {exc.response.status_code}",
) from exc
except httpx.HTTPError as exc:
logger.error("openai_stream_http_error", error=str(exc))
raise AILLMUnavailableError(f"openai stream error: {exc}") from exc
@staticmethod
def _parse_sse_line(line: str, model: str) -> LLMStreamChunk | None:
"""解析 OpenAI SSE 格式单行.
Returns:
LLMStreamChunk 或 None空行/非 data 行/[DONE] 尾标记)。
"""
if not line or not line.startswith("data: "):
return None
data_str = line[len("data: "):]
if data_str.strip() == "[DONE]":
return LLMStreamChunk(delta="", model=model, finish_reason="stop")
try:
data = json.loads(data_str)
except json.JSONDecodeError:
return None
choices = data.get("choices", [])
if not choices:
return None
delta = choices[0].get("delta", {}).get("content", "") or ""
finish_reason = choices[0].get("finish_reason")
return LLMStreamChunk(
delta=delta,
model=data.get("model", model),
finish_reason=finish_reason,
provider="openai",
)
async def embed(self, text: str, model: str = "text-embedding-3-small") -> list[float]:
"""文本向量化OpenAI embedding API."""
if not self.is_available():
raise AILLMUnavailableError("openai api_key not configured")
url = f"{self._base_url}/embeddings"
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(
url,
json={"model": model, "input": text},
headers=self._build_headers(),
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as exc:
logger.error("openai_embed_error", error=str(exc))
raise AILLMUnavailableError(f"openai embed error: {exc}") from exc
embeddings = data.get("data", [])
if not embeddings:
return []
return embeddings[0].get("embedding", [])

View File

@@ -0,0 +1,194 @@
"""Redis 多维度限流器(令牌桶算法).
三维度限流02-architecture-design.md §7
- user: 每用户 10 req/min
- IP: 每 IP 30 req/min
- school: 每学校 100 req/min
使用 Redis Lua 脚本实现原子令牌桶,保证线程安全。
全并行模式Redis 不可用时降级放行(记录警告)。
"""
import time
from dataclasses import dataclass
import structlog
from redis.asyncio import Redis
from redis.exceptions import RedisError
from .errors import AIRateLimitedError
logger = structlog.get_logger()
# Lua 脚本:原子令牌桶
# KEYS[1] = 限流 key
# ARGV[1] = 容量max tokens
# ARGV[2] = 补充速率tokens per second
# ARGV[3] = 当前时间戳(秒)
# ARGV[4] = 请求消耗 tokens通常 1
# 返回: {allowed(1/0), remaining}
TOKEN_BUCKET_LUA = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- 补充令牌
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 120)
return {allowed, math.floor(tokens)}
"""
@dataclass
class RateLimitResult:
"""限流检查结果."""
allowed: bool
dimension: str
limit: int
remaining: int
class RateLimiter:
"""多维度限流器.
三维度独立检查,任一维度超限即拒绝。
全并行模式Redis 不可用时降级放行。
"""
def __init__(
self,
redis: Redis | None = None,
user_limit: int = 10,
ip_limit: int = 30,
school_limit: int = 100,
window_seconds: int = 60,
) -> None:
self._redis = redis
self._user_limit = user_limit
self._ip_limit = ip_limit
self._school_limit = school_limit
self._window_seconds = window_seconds
self._lua_sha: str | None = None
async def _ensure_lua(self) -> bool:
"""确保 Lua 脚本已加载."""
if self._redis is None:
return False
if self._lua_sha is not None:
return True
try:
self._lua_sha = await self._redis.script_load(TOKEN_BUCKET_LUA)
return True
except RedisError as exc:
logger.warning("rate_limiter_lua_load_failed", error=str(exc))
return False
async def check(
self,
user_id: str = "",
ip: str = "",
school_id: str = "",
) -> list[RateLimitResult]:
"""检查三维度限流.
Returns:
各维度检查结果列表
Raises:
AIRateLimitedError: 任一维度超限
"""
results: list[RateLimitResult] = []
if user_id:
results.append(
await self._check_dimension("user", user_id, self._user_limit),
)
if ip:
results.append(
await self._check_dimension("ip", ip, self._ip_limit),
)
if school_id:
results.append(
await self._check_dimension("school", school_id, self._school_limit),
)
for result in results:
if not result.allowed:
logger.warning(
"rate_limit_exceeded",
dimension=result.dimension,
limit=result.limit,
remaining=result.remaining,
)
raise AIRateLimitedError(result.dimension, result.limit)
return results
async def _check_dimension(
self,
dimension: str,
identifier: str,
limit: int,
) -> RateLimitResult:
"""检查单维度限流."""
if not await self._ensure_lua():
# Redis 不可用,降级放行
logger.debug("rate_limiter_degraded_redis_unavailable", dimension=dimension)
return RateLimitResult(
allowed=True,
dimension=dimension,
limit=limit,
remaining=limit,
)
key = f"ratelimit:{dimension}:{identifier}"
refill_rate = limit / self._window_seconds
now = time.time()
try:
result = await self._redis.evalsha( # type: ignore[union-attr]
self._lua_sha,
1,
key,
limit,
refill_rate,
now,
1,
)
allowed = bool(result[0])
remaining = int(result[1])
return RateLimitResult(
allowed=allowed,
dimension=dimension,
limit=limit,
remaining=remaining,
)
except RedisError as exc:
logger.warning(
"rate_limiter_check_failed_degraded",
dimension=dimension,
error=str(exc),
)
return RateLimitResult(
allowed=True,
dimension=dimension,
limit=limit,
remaining=limit,
)

View File

@@ -0,0 +1,22 @@
"""安全层02-architecture-design.md §9 安全设计).
三层安全防护:
- PIIRedactor: PII 检测与脱敏(邮箱/手机号/身份证号/银行卡号)
- InputSanitizer: 输入清洗prompt injection 检测、危险字符过滤)
- OutputModerator: 输出审核(敏感内容过滤、不当内容检测)
全并行模式:所有安全检查均为本地正则匹配,不依赖外部服务,无降级需求。
"""
from .input_sanitizer import InputSanitizer, SanitizeResult
from .output_moderator import ModerationResult, OutputModerator
from .pii_redactor import PIIRedactor, RedactionResult
__all__ = [
"PIIRedactor",
"RedactionResult",
"InputSanitizer",
"SanitizeResult",
"OutputModerator",
"ModerationResult",
]

View File

@@ -0,0 +1,126 @@
"""输入清洗器prompt injection 检测 + 危险字符过滤).
检测以下攻击模式:
- Prompt injectionjailbreak 指令、角色覆盖、指令注入
- 危险字符null bytes、控制字符
- 超长输入:防止 DoS
检测到 prompt injection 时抛 AIError(AI_PROMPT_INJECTION_DETECTED)。
"""
import re
from dataclasses import dataclass, field
import structlog
from ..errors import AIError, ErrorCode
logger = structlog.get_logger()
# Prompt injection 检测模式
INJECTION_PATTERNS: list[re.Pattern[str]] = [
# 忽略之前所有指令
re.compile(r"ignore\s+(?:all\s+)?(?:previous|prior|above)\s+instructions?", re.IGNORECASE),
re.compile(r" disreg(?:ard|aard)\s+(?:all\s+)?(?:previous|prior)", re.IGNORECASE),
# 角色覆盖
re.compile(r"you\s+are\s+(?:now|actually)\s+(?:a|an)?\s*(?:different|new)", re.IGNORECASE),
re.compile(r"pretend\s+(?:you\s+are|to\s+be)\s+(?:a|an)?\s*(?:different|new)", re.IGNORECASE),
# 系统提示泄露
re.compile(
r"(?:show|reveal|print|output)\s+(?:me\s+)?(?:your\s+)?"
r"(?:system|initial)\s+prompt",
re.IGNORECASE,
),
re.compile(
r"(?:what|how)\s+(?:are|were)\s+you\s+"
r"(?:instructed|programmed|configured)",
re.IGNORECASE,
),
# 越狱
re.compile(r"(?:jailbreak|DAN|do\s+anything\s+now)", re.IGNORECASE),
# 开发模式
re.compile(r"(?:enter|enable|activate)\s+(?:developer|god|admin|root)\s+mode", re.IGNORECASE),
]
# 危险字符模式
DANGEROUS_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
# 最大输入长度(单字段)
MAX_INPUT_LENGTH = 8000
@dataclass
class SanitizeResult:
"""清洗结果."""
sanitized_text: str
is_safe: bool = True
injection_detected: bool = False
warnings: list[str] = field(default_factory=list)
class InputSanitizer:
"""输入清洗器."""
def sanitize(
self,
text: str,
max_length: int = MAX_INPUT_LENGTH,
strict: bool = False,
) -> SanitizeResult:
"""清洗输入文本.
Args:
text: 原始文本
max_length: 最大允许长度
strict: 严格模式(检测到 injection 时抛异常)
Returns:
SanitizeResult含清洗后文本 + 安全标记)
Raises:
AIError(AI_PROMPT_INJECTION_DETECTED): strict=True 且检测到 injection
"""
result = SanitizeResult(sanitized_text=text, is_safe=True)
# 1. 去除危险控制字符
if DANGEROUS_CHARS.search(text):
result.sanitized_text = DANGEROUS_CHARS.sub("", text)
result.warnings.append("dangerous control characters removed")
# 2. 长度检查
if len(text) > max_length:
result.sanitized_text = result.sanitized_text[:max_length]
result.warnings.append(
f"input truncated to {max_length} characters",
)
# 3. Prompt injection 检测
for pattern in INJECTION_PATTERNS:
if pattern.search(text):
result.injection_detected = True
result.is_safe = False
result.warnings.append(
f"prompt injection pattern detected: {pattern.pattern[:50]}",
)
logger.warning(
"prompt_injection_detected",
pattern=pattern.pattern[:100],
)
break
if result.injection_detected and strict:
raise AIError(
ErrorCode.AI_PROMPT_INJECTION_DETECTED,
"Potential prompt injection detected in input",
{"warnings": result.warnings},
)
return result
def is_safe(self, text: str) -> bool:
"""快速检查输入是否安全(不修改文本)."""
for pattern in INJECTION_PATTERNS:
if pattern.search(text):
return False
return not DANGEROUS_CHARS.search(text)

View File

@@ -0,0 +1,130 @@
"""输出审核器(敏感内容过滤).
对 LLM 输出进行内容审核,检测以下不当内容:
- 暴力/仇恨言论
- 不当内容(色情、赌博、毒品相关)
- 个人信息泄露(已由 PIIRedactor 处理,此处为二次校验)
- 有害指令(如制造危险物品的步骤)
检测到不当内容时标记为需审核,由调用方决定是否返回。
"""
import re
from dataclasses import dataclass, field
import structlog
logger = structlog.get_logger()
# 敏感内容检测模式(中文 + 英文)
SENSITIVE_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
# 暴力/伤害
(
"violence",
re.compile(
r"(?:如何|怎么|怎样|how\s+to|ways\s+to)\s*(?:制造|制作|获取|make|create|get)"
r".*?(?:炸弹|爆炸物|武器|枪|毒药|bomb|weapon|poison)",
re.IGNORECASE,
),
),
(
"self_harm",
re.compile(
r"(?:自杀|自残|self[- ]?harm|suicide|kill\s+myself|结束生命)",
re.IGNORECASE,
),
),
# 仇恨言论
(
"hate_speech",
re.compile(
r"(?:歧视|仇恨|hate|discriminate|racist|纳粹|nazi)",
re.IGNORECASE,
),
),
# 非法活动
(
"illegal_activity",
re.compile(
r"(?:毒品|贩毒|drug\s+trafficking|洗钱|money\s+laundering|走私|smuggling)",
re.IGNORECASE,
),
),
# 不当内容
(
"explicit_content",
re.compile(
r"(?:色情|pornograph|成人内容|adult\s+content|赌博|gambling)",
re.IGNORECASE,
),
),
]
# 未成年人保护模式
MINOR_PROTECTION_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"(?:未成年人|未成年|minor|underage|under\s+18|under\s+21)", re.IGNORECASE),
]
@dataclass
class ModerationResult:
"""审核结果."""
approved: bool = True
flagged_categories: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
filtered_text: str = ""
class OutputModerator:
"""输出审核器."""
def moderate(
self,
text: str,
context: str = "",
) -> ModerationResult:
"""审核 LLM 输出内容.
Args:
text: LLM 输出文本
context: 调用上下文(如 "question_generation"
Returns:
ModerationResult含审核结果 + 标记的类别)
"""
result = ModerationResult(filtered_text=text)
# 敏感内容检测
for category, pattern in SENSITIVE_PATTERNS:
if pattern.search(text):
result.flagged_categories.append(category)
result.warnings.append(f"sensitive content detected: {category}")
logger.warning(
"output_moderation_flagged",
category=category,
context=context,
)
# 未成年人保护
for pattern in MINOR_PROTECTION_PATTERNS:
if pattern.search(text):
result.flagged_categories.append("minor_protection")
result.warnings.append("minor-related content detected")
logger.warning(
"output_moderation_minor_protection",
context=context,
)
break
# 决策:检测到任何敏感类别则不批准
if result.flagged_categories:
result.approved = False
return result
def is_safe(self, text: str) -> bool:
"""快速检查输出是否安全."""
return all(
not pattern.search(text) for _, pattern in SENSITIVE_PATTERNS
)

View File

@@ -0,0 +1,112 @@
"""PII 检测与脱敏器.
检测并脱敏以下 PII 信息:
- 邮箱地址
- 手机号(中国大陆 11 位)
- 身份证号18 位)
- 银行卡号16-19 位)
- IP 地址
脱敏方式:保留首尾字符,中间用 * 替换。
"""
import re
from dataclasses import dataclass, field
import structlog
logger = structlog.get_logger()
# PII 检测正则表达式
PII_PATTERNS: dict[str, re.Pattern[str]] = {
"email": re.compile(
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
),
"phone": re.compile(
r"(?<!\d)1[3-9]\d{9}(?!\d)",
),
"id_card": re.compile(
r"(?<!\d)[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])"
r"(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?!\d)",
),
"bank_card": re.compile(
r"(?<!\d)[1-9]\d{15,18}(?!\d)",
),
"ip_address": re.compile(
r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}"
r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b",
),
}
@dataclass
class RedactionResult:
"""脱敏结果."""
redacted_text: str
found_types: list[str] = field(default_factory=list)
redaction_count: int = 0
class PIIRedactor:
"""PII 检测与脱敏器."""
def redact(self, text: str) -> RedactionResult:
"""检测并脱敏文本中的 PII 信息.
Args:
text: 原始文本
Returns:
RedactionResult含脱敏后文本 + 检测到的 PII 类型)
"""
redacted_text = text
found_types: list[str] = []
total_count = 0
for pii_type, pattern in PII_PATTERNS.items():
matches = pattern.findall(redacted_text)
if matches:
found_types.append(pii_type)
total_count += len(matches)
redacted_text = pattern.sub(
lambda m: self._mask(m.group()), redacted_text,
)
if total_count > 0:
logger.info(
"pii_redacted",
types=found_types,
count=total_count,
)
return RedactionResult(
redacted_text=redacted_text,
found_types=found_types,
redaction_count=total_count,
)
def detect(self, text: str) -> list[str]:
"""仅检测 PII 类型(不脱敏).
Args:
text: 原始文本
Returns:
检测到的 PII 类型列表
"""
found: list[str] = []
for pii_type, pattern in PII_PATTERNS.items():
if pattern.search(text):
found.append(pii_type)
return found
@staticmethod
def _mask(value: str) -> str:
"""脱敏单个值(保留首尾,中间用 * 替换)."""
if len(value) <= 2:
return "*" * len(value)
if len(value) <= 6:
return value[0] + "*" * (len(value) - 2) + value[-1]
# 保留前 2 后 2
return value[:2] + "*" * (len(value) - 4) + value[-2:]

View File

@@ -0,0 +1,18 @@
"""业务服务层.
服务编排:
- ChatService: 聊天(非流式 + 流式)
- QuestionService: 题目生成(非流式 + 流式 + 评估三道防线)
- ExpressionService: 表达优化
- LessonPlanWorkflowService: 备课工作流M16-2 实现)
"""
from .chat_service import ChatService
from .expression_service import ExpressionService
from .question_service import QuestionService
__all__ = [
"ChatService",
"QuestionService",
"ExpressionService",
]

View File

@@ -0,0 +1,112 @@
"""聊天服务(非流式 + 流式).
编排 LLM Provider FailoverChain + Prompt 模板 + 用量记录。
"""
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Any
import structlog
from ..errors import AILLMUnavailableError
from ..models.chat import ChatData, Usage
from ..prompt_service import PromptTemplateService
from ..providers import ProviderFailoverChain
logger = structlog.get_logger()
@dataclass
class ChatChunkData:
"""流式聊天 chunk."""
content: str
done: bool = False
class ChatService:
"""聊天服务."""
def __init__(
self,
failover_chain: ProviderFailoverChain,
prompt_service: PromptTemplateService | None = None,
default_model: str = "gpt-4o-mini",
) -> None:
self._chain = failover_chain
self._prompts = prompt_service
self._default_model = default_model
async def chat(
self,
messages: list[dict[str, str]],
model: str = "",
temperature: float = 0.7,
user_id: str = "",
session_id: str | None = None,
data_scope: str = "",
) -> ChatData:
"""非流式聊天.
Returns:
ChatData含降级标记
"""
model = model or self._default_model
try:
response = await self._chain.chat(messages, model, temperature)
return ChatData(
content=response.content,
model=response.model,
usage=Usage(
prompt_tokens=response.usage.get("prompt_tokens", 0),
completion_tokens=response.usage.get("completion_tokens", 0),
total_tokens=response.usage.get("total_tokens", 0),
),
degraded=False,
degraded_reason="",
)
except AILLMUnavailableError as exc:
logger.warning("chat_degraded", reason=str(exc))
return ChatData(
content=f"[degraded] LLM unavailable: {exc}",
model=model,
usage=Usage(),
degraded=True,
degraded_reason=str(exc),
)
async def stream_chat(
self,
messages: list[dict[str, str]],
model: str = "",
temperature: float = 0.7,
user_id: str = "",
session_id: str | None = None,
data_scope: str = "",
) -> AsyncGenerator[ChatChunkData, None]:
"""流式聊天.
Yields:
ChatChunkData
"""
model = model or self._default_model
try:
async for chunk in self._chain.stream_chat(messages, model, temperature):
done = chunk.finish_reason == "stop" or chunk.finish_reason == "end_turn"
yield ChatChunkData(content=chunk.delta, done=done)
except AILLMUnavailableError as exc:
logger.warning("stream_chat_degraded", reason=str(exc))
yield ChatChunkData(content=f"[degraded] {exc}", done=True)
except Exception as exc: # noqa: BLE001
logger.error("stream_chat_error", error=str(exc))
yield ChatChunkData(content=f"[error] {exc}", done=True)
def _build_system_prompt(self, context: dict[str, Any]) -> str:
"""渲染系统 prompt 模板."""
if self._prompts is None:
return "You are a helpful educational assistant."
try:
return self._prompts.render("chat_system", context)
except Exception: # noqa: BLE001
return "You are a helpful educational assistant."

View File

@@ -0,0 +1,18 @@
"""评估三道防线02-architecture-design.md §10.
第一道RuleValidator - 规则校验JSON 格式、必填字段、难度匹配)
第二道LLMJudge - LLM 质量评分(语义层面评估)
第三道QualityGate - 综合决策(规则 + LLM 评分加权,决定通过/重试/拒绝)
"""
from .llm_judge import LLMJudge
from .quality_gate import EvaluationResult, QualityGate
from .rule_validator import RuleValidator, ValidationResult
__all__ = [
"RuleValidator",
"ValidationResult",
"LLMJudge",
"QualityGate",
"EvaluationResult",
]

View File

@@ -0,0 +1,165 @@
"""第二道防线LLM 质量评审.
使用 LLM 对生成内容进行语义层面评估:
- 知识点准确性
- 题目清晰度
- 答案正确性
- 解析完整性
- 难度适当性
返回 0.0-1.0 的质量评分。
"""
import json
from dataclasses import dataclass
import structlog
from ...errors import AILLMUnavailableError
from ...providers import LLMProvider, LLMResponse
logger = structlog.get_logger()
JUDGE_SYSTEM_PROMPT = """你是一个教育内容质量评审专家。
请对以下题目进行质量评估,从 5 个维度打分(每项 0-1 分):
1. accuracy: 知识点准确性
2. clarity: 题目表述清晰度
3. correctness: 答案正确性
4. completeness: 解析完整性
5. difficulty_match: 难度匹配度
请严格按以下 JSON 格式输出(不要包含其他内容):
{
"accuracy": 0.9,
"clarity": 0.8,
"correctness": 0.9,
"completeness": 0.7,
"difficulty_match": 0.8,
"overall": 0.82,
"issues": ["如有问题简要描述"]
}"""
@dataclass
class JudgeResult:
"""LLM 评审结果."""
score: float
accuracy: float = 0.0
clarity: float = 0.0
correctness: float = 0.0
completeness: float = 0.0
difficulty_match: float = 0.0
issues: list[str] | None = None
available: bool = True # False 表示 LLM 不可用,降级跳过
@property
def passed(self) -> bool:
"""是否通过评审."""
return self.available and self.score >= 0.6
class LLMJudge:
"""LLM 质量评审器."""
def __init__(
self,
provider: LLMProvider | None = None,
model: str = "gpt-4o-mini",
) -> None:
self._provider = provider
self._model = model
async def judge(
self,
question: str,
answer: str,
explanation: str = "",
difficulty: str = "",
subject: str = "",
) -> JudgeResult:
"""评审题目质量.
Args:
question: 题目内容
answer: 答案
explanation: 解析
difficulty: 难度
subject: 学科
Returns:
JudgeResultLLM 不可用时返回 available=False 的降级结果)
"""
if self._provider is None or not self._provider.is_available():
logger.debug("llm_judge_skipped_no_provider")
return JudgeResult(score=0.7, available=False)
user_prompt = self._build_user_prompt(
question, answer, explanation, difficulty, subject,
)
messages = [
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
try:
response: LLMResponse = await self._provider.chat(
messages=messages,
model=self._model,
temperature=0.3,
)
except AILLMUnavailableError as exc:
logger.warning("llm_judge_failed_degraded", error=str(exc))
return JudgeResult(score=0.7, available=False)
return self._parse_judge_response(response.content)
def _build_user_prompt(
self,
question: str,
answer: str,
explanation: str,
difficulty: str,
subject: str,
) -> str:
"""构建评审 user prompt."""
return f"""请评审以下题目:
【学科】{subject or "未指定"}
【难度】{difficulty or "未指定"}
【题目】
{question}
【答案】
{answer}
【解析】
{explanation or "无解析"}
"""
def _parse_judge_response(self, response: str) -> JudgeResult:
"""解析 LLM 评审响应."""
response = response.strip()
# 去除 markdown 代码块
if response.startswith("```"):
lines = response.split("\n")[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
response = "\n".join(lines)
try:
data = json.loads(response)
except json.JSONDecodeError:
logger.warning("llm_judge_response_not_json", response=response[:200])
return JudgeResult(score=0.5, available=False)
return JudgeResult(
score=float(data.get("overall", 0.5)),
accuracy=float(data.get("accuracy", 0.0)),
clarity=float(data.get("clarity", 0.0)),
correctness=float(data.get("correctness", 0.0)),
completeness=float(data.get("completeness", 0.0)),
difficulty_match=float(data.get("difficulty_match", 0.0)),
issues=data.get("issues"),
)

View File

@@ -0,0 +1,169 @@
"""第三道防线质量门控QualityGate.
综合 RuleValidator + LLMJudge 结果,决定内容是否通过:
- 规则校验失败 → 直接拒绝
- 规则通过 + LLM 评审通过 → 放行
- 规则通过 + LLM 评审不通过 → 标记降级但仍返回(方案 B
- LLM 不可用 → 仅依据规则校验结果
"""
from dataclasses import dataclass, field
import structlog
from ...config import Settings
from .llm_judge import JudgeResult, LLMJudge
from .rule_validator import RuleValidator, ValidationResult
logger = structlog.get_logger()
@dataclass
class EvaluationResult:
"""综合评估结果."""
passed: bool
score: float
rule_result: ValidationResult | None = None
judge_result: JudgeResult | None = None
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
degraded: bool = False
degraded_reason: str = ""
class QualityGate:
"""质量门控.
权重配置:
- rule_score 权重 0.4
- llm_score 权重 0.6
- LLM 不可用时 rule_score 权重 1.0
"""
def __init__(
self,
rule_validator: RuleValidator | None = None,
llm_judge: LLMJudge | None = None,
pass_threshold: float = 0.7,
excellent_threshold: float = 0.85,
) -> None:
self._rule_validator = rule_validator or RuleValidator()
self._llm_judge = llm_judge
self._pass_threshold = pass_threshold
self._excellent_threshold = excellent_threshold
@classmethod
def from_settings(cls, settings: Settings, llm_judge: LLMJudge | None = None) -> "QualityGate":
"""从 Settings 创建 QualityGate."""
return cls(
pass_threshold=settings.evaluation_pass_threshold,
excellent_threshold=settings.evaluation_excellent_threshold,
llm_judge=llm_judge,
)
async def evaluate(
self,
llm_output: str,
expected_difficulty: str = "",
expected_question_type: str = "",
question: str = "",
answer: str = "",
explanation: str = "",
subject: str = "",
) -> EvaluationResult:
"""执行三道防线评估.
Args:
llm_output: LLM 原始输出
expected_difficulty: 期望难度
expected_question_type: 期望题型
question/answer/explanation/subject: 用于 LLM 评审的解析后内容
Returns:
EvaluationResult
"""
result = EvaluationResult(passed=False, score=0.0)
# 第一道:规则校验
rule_result = self._rule_validator.validate(
llm_output, expected_difficulty, expected_question_type,
)
result.rule_result = rule_result
if not rule_result.passed:
result.errors.extend(rule_result.errors)
result.score = 0.0
result.passed = False
result.degraded = True
result.degraded_reason = "rule validation failed"
logger.warning(
"quality_gate_rule_failed",
errors=rule_result.errors,
)
return result
result.warnings.extend(rule_result.warnings)
# 第二道LLM 评审
if self._llm_judge is not None:
# 使用规则校验解析的内容(如果未显式传入)
parsed = rule_result.parsed or {}
judge_result = await self._llm_judge.judge(
question=question or str(parsed.get("question", "")),
answer=answer or str(parsed.get("answer", "")),
explanation=explanation or str(parsed.get("explanation", "")),
difficulty=expected_difficulty,
subject=subject,
)
result.judge_result = judge_result
if not judge_result.available:
# LLM 不可用,仅依据规则校验
result.score = rule_result.score
result.passed = rule_result.score >= self._pass_threshold
if result.passed and rule_result.warnings:
result.degraded = True
result.degraded_reason = "rule warnings present, llm judge unavailable"
logger.info(
"quality_gate_llm_unavailable",
rule_score=rule_result.score,
passed=result.passed,
)
return result
# 综合评分
result.score = self._combine_scores(rule_result.score, judge_result.score)
result.passed = result.score >= self._pass_threshold
if judge_result.issues:
result.warnings.extend(judge_result.issues)
if not result.passed:
result.degraded = True
result.degraded_reason = f"quality score {result.score:.2f} below threshold"
logger.warning(
"quality_gate_score_below_threshold",
score=result.score,
threshold=self._pass_threshold,
)
else:
# 无 LLM Judge仅依据规则校验
result.score = rule_result.score
result.passed = rule_result.score >= self._pass_threshold
if result.passed and rule_result.warnings:
result.degraded = True
result.degraded_reason = "rule warnings present, no llm judge"
# 优秀标记
if result.passed and result.score >= self._excellent_threshold:
logger.info("quality_gate_excellent", score=result.score)
return result
def _combine_scores(self, rule_score: float, llm_score: float) -> float:
"""综合评分.
权重rule 0.4 + llm 0.6
"""
return rule_score * 0.4 + llm_score * 0.6

View File

@@ -0,0 +1,167 @@
"""第一道防线:规则校验器.
检查 LLM 输出是否符合基本规则:
1. JSON 格式有效
2. 必填字段存在question/answer
3. 难度匹配请求
4. 题型在允许列表内
5. 无明显格式问题(空内容、过长内容等)
"""
import json
from dataclasses import dataclass, field
import structlog
logger = structlog.get_logger()
# 允许的难度和题型
VALID_DIFFICULTIES = {"easy", "medium", "hard"}
VALID_QUESTION_TYPES = {
"single_choice",
"multi_choice",
"fill_blank",
"short_answer",
"essay",
}
# 内容长度限制
MIN_QUESTION_LENGTH = 5
MAX_QUESTION_LENGTH = 2000
MIN_ANSWER_LENGTH = 1
MAX_ANSWER_LENGTH = 5000
@dataclass
class ValidationResult:
"""规则校验结果."""
passed: bool
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
parsed: dict | None = None # 解析后的 JSON dict
@property
def score(self) -> float:
"""规则校验得分0.0-1.0."""
if not self.passed:
return 0.0
# 有警告但通过,得分 0.8
if self.warnings:
return 0.8
return 1.0
class RuleValidator:
"""规则校验器."""
def validate(
self,
llm_output: str,
expected_difficulty: str = "",
expected_question_type: str = "",
) -> ValidationResult:
"""校验 LLM 输出.
Args:
llm_output: LLM 原始输出字符串
expected_difficulty: 期望的难度
expected_question_type: 期望的题型
Returns:
ValidationResult
"""
result = ValidationResult(passed=True)
# 1. 解析 JSON
parsed = self._parse_json(llm_output)
if parsed is None:
result.passed = False
result.errors.append("输出不是有效的 JSON 格式")
return result
result.parsed = parsed
# 2. 检查必填字段
self._check_required_fields(parsed, result)
if not result.passed:
return result
# 3. 检查内容长度
self._check_content_length(parsed, result)
# 4. 检查难度匹配
if expected_difficulty:
self._check_difficulty(parsed, expected_difficulty, result)
# 5. 检查题型
if expected_question_type:
self._check_question_type(parsed, expected_question_type, result)
return result
def _parse_json(self, output: str) -> dict | None:
"""解析 JSON容忍 markdown 代码块包裹."""
output = output.strip()
# 去除 markdown 代码块标记
if output.startswith("```"):
lines = output.split("\n")
# 去除首行(```json 或 ```
lines = lines[1:]
# 去除末尾 ```
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
output = "\n".join(lines)
try:
parsed = json.loads(output)
except json.JSONDecodeError as exc:
logger.debug("json_parse_failed", error=str(exc), output=output[:200])
return None
if not isinstance(parsed, dict):
logger.debug("json_not_dict", type=type(parsed).__name__)
return None
return parsed
def _check_required_fields(self, parsed: dict, result: ValidationResult) -> None:
"""检查必填字段."""
question = parsed.get("question", "")
answer = parsed.get("answer", "")
if not question:
result.passed = False
result.errors.append("缺少必填字段: question")
if not answer:
result.passed = False
result.errors.append("缺少必填字段: answer")
def _check_content_length(self, parsed: dict, result: ValidationResult) -> None:
"""检查内容长度."""
question = str(parsed.get("question", ""))
answer = str(parsed.get("answer", ""))
if len(question) < MIN_QUESTION_LENGTH:
result.warnings.append(f"question 过短({len(question)} 字符)")
if len(question) > MAX_QUESTION_LENGTH:
result.warnings.append(f"question 过长({len(question)} 字符)")
if len(answer) < MIN_ANSWER_LENGTH:
result.warnings.append(f"answer 过短({len(answer)} 字符)")
if len(answer) > MAX_ANSWER_LENGTH:
result.warnings.append(f"answer 过长({len(answer)} 字符)")
def _check_difficulty(self, parsed: dict, expected: str, result: ValidationResult) -> None:
"""检查难度匹配."""
actual = str(parsed.get("difficulty", "")).lower()
if actual not in VALID_DIFFICULTIES:
result.warnings.append(f"difficulty 值无效: {actual}")
elif actual != expected.lower():
result.warnings.append(
f"difficulty 不匹配: 期望 {expected}, 实际 {actual}",
)
def _check_question_type(self, parsed: dict, expected: str, result: ValidationResult) -> None:
"""检查题型."""
actual = str(parsed.get("question_type", "")).lower()
if actual and actual not in VALID_QUESTION_TYPES:
result.warnings.append(f"question_type 值无效: {actual}")
elif actual and actual != expected.lower():
result.warnings.append(
f"question_type 不匹配: 期望 {expected}, 实际 {actual}",
)

View File

@@ -0,0 +1,126 @@
"""表达优化服务.
编排 LLM FailoverChain + Prompt 模板,优化文字表达的清晰度、简洁度和语气。
降级采用方案 B总裁裁决 §2.6LLM 不可用时返回 degraded 响应。
"""
import json
from typing import Any
import structlog
from ..errors import AILLMUnavailableError
from ..models.expression import OptimizedExpressionData
from ..prompt_service import PromptTemplateService
from ..providers import ProviderFailoverChain
logger = structlog.get_logger()
class ExpressionService:
"""表达优化服务."""
def __init__(
self,
failover_chain: ProviderFailoverChain,
prompt_service: PromptTemplateService | None = None,
default_model: str = "gpt-4o-mini",
) -> None:
self._chain = failover_chain
self._prompts = prompt_service
self._default_model = default_model
async def optimize(
self,
text: str,
context: str = "",
model: str = "",
temperature: float = 0.5,
user_id: str = "",
) -> OptimizedExpressionData:
"""优化文字表达.
Args:
text: 原始文字
context: 上下文(可选)
model: 模型名(空则用默认)
temperature: 温度参数(表达优化用较低温度保持稳定)
user_id: 用户 ID用于用量记录
Returns:
OptimizedExpressionData含降级标记
"""
model = model or self._default_model
prompt = self._render_prompt(text, context)
messages = [
{"role": "system", "content": "你是一个专业的文字编辑助手。"},
{"role": "user", "content": prompt},
]
try:
response = await self._chain.chat(messages, model, temperature)
except AILLMUnavailableError as exc:
logger.warning("expression_optimize_degraded", reason=str(exc))
return OptimizedExpressionData(
optimized=text,
suggestions=[],
degraded=True,
degraded_reason=str(exc),
)
# 解析 JSON 输出
parsed = self._parse_json(response.content)
if parsed is None:
# JSON 解析失败,返回原始内容作为 optimized
logger.warning(
"expression_optimize_json_failed",
output=response.content[:200],
)
return OptimizedExpressionData(
optimized=response.content,
suggestions=[],
degraded=True,
degraded_reason="llm output is not valid json",
)
return OptimizedExpressionData(
optimized=str(parsed.get("optimized", text)),
suggestions=list(parsed.get("suggestions", [])),
degraded=False,
degraded_reason="",
)
def _render_prompt(self, text: str, context: str) -> str:
"""渲染表达优化 prompt 模板."""
if self._prompts is None:
return self._fallback_prompt(text, context)
try:
return self._prompts.render(
"optimize_expression",
{"text": text, "context": context},
)
except Exception: # noqa: BLE001
return self._fallback_prompt(text, context)
def _fallback_prompt(self, text: str, context: str) -> str:
"""模板不可用时的降级 prompt."""
ctx = f"\n上下文:{context}" if context else ""
return (
f"请优化以下文字的表达,提升清晰度和简洁度:\n{text}{ctx}\n"
"请按 JSON 格式输出:"
'{"optimized":"优化后文字","suggestions":["建议1","建议2"]}'
)
def _parse_json(self, output: str) -> dict[str, Any] | None:
"""解析 JSON容忍 markdown 代码块包裹."""
output = output.strip()
if output.startswith("```"):
lines = output.split("\n")[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
output = "\n".join(lines)
try:
parsed = json.loads(output)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None

View File

@@ -0,0 +1,304 @@
"""题目生成服务(非流式 + 流式 + 评估三道防线集成).
设计依据 02-architecture-design.md §10 评估三道防线:
1. RuleValidatorJSON 格式 / 必填字段 / 难度匹配 / 题型校验
2. LLMJudge语义层面 5 维度评分
3. QualityGate综合决策门控决定放行/降级/拒绝
降级采用方案 B总裁裁决 §2.6
- LLM 不可用 → success=true + data 内 degraded=true
- 评估未通过但可返回 → success=true + data 内 degraded=true
"""
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Any
import structlog
from ..errors import AILLMUnavailableError
from ..models.question import (
GeneratedQuestionData,
GenerateQuestionRequest,
)
from ..prompt_service import PromptTemplateService
from ..providers import ProviderFailoverChain
from .evaluation import QualityGate, RuleValidator
logger = structlog.get_logger()
@dataclass
class QuestionChunkData:
"""流式题目生成 chunk."""
content: str
done: bool = False
complete_question: GeneratedQuestionData | None = None
class QuestionService:
"""题目生成服务.
编排流程:
generate():
1. 渲染 generate_question prompt 模板
2. 调用 LLM FailoverChain.chat()
3. RuleValidator + LLMJudge + QualityGate 三道防线评估
4. 返回 GeneratedQuestionData含 evaluation_score + degraded 标记)
stream_generate():
1. 渲染 prompt 模板
2. 调用 FailoverChain.stream_chat() 逐字 yield
3. 流结束后运行 QualityGate 评估done chunk 携带完整题目
"""
def __init__(
self,
failover_chain: ProviderFailoverChain,
prompt_service: PromptTemplateService | None = None,
quality_gate: QualityGate | None = None,
default_model: str = "gpt-4o-mini",
) -> None:
self._chain = failover_chain
self._prompts = prompt_service
self._quality_gate = quality_gate or QualityGate(
rule_validator=RuleValidator(),
)
self._default_model = default_model
async def generate(
self,
request: GenerateQuestionRequest,
model: str = "",
temperature: float = 0.7,
user_id: str = "",
) -> GeneratedQuestionData:
"""非流式题目生成.
Args:
request: 题目生成请求
model: 模型名(空则用默认)
temperature: 温度参数
user_id: 用户 ID用于用量记录
Returns:
GeneratedQuestionData含 evaluation_score + degraded 标记)
"""
model = model or self._default_model
system_prompt = self._render_prompt(request)
messages = [
{"role": "system", "content": "你是一个专业的教育题目生成助手。"},
{"role": "user", "content": system_prompt},
]
try:
response = await self._chain.chat(messages, model, temperature)
except AILLMUnavailableError as exc:
logger.warning("question_generate_degraded", reason=str(exc))
return GeneratedQuestionData(
question="",
answer="",
explanation=f"[degraded] LLM unavailable: {exc}",
question_type=request.question_type,
difficulty=request.difficulty,
knowledge_point_ids=request.knowledge_point_ids,
evaluation_score=0.0,
degraded=True,
degraded_reason=str(exc),
)
# 评估三道防线
evaluation = await self._quality_gate.evaluate(
llm_output=response.content,
expected_difficulty=request.difficulty,
expected_question_type=request.question_type,
subject=request.subject,
)
# 解析 LLM 输出为题目数据
parsed = (
evaluation.rule_result.parsed if evaluation.rule_result else None
)
if parsed:
question_data = self._build_question_from_parsed(
parsed, request, evaluation.score,
)
else:
# JSON 解析失败,返回原始内容作为 question
question_data = GeneratedQuestionData(
question=response.content,
answer="",
explanation="",
question_type=request.question_type,
difficulty=request.difficulty,
knowledge_point_ids=request.knowledge_point_ids,
evaluation_score=evaluation.score,
degraded=True,
degraded_reason="; ".join(evaluation.errors) or "json parse failed",
)
# 评估降级标记透传
if evaluation.degraded and not question_data.degraded:
question_data.degraded = True
question_data.degraded_reason = evaluation.degraded_reason
logger.info(
"question_generated",
score=evaluation.score,
passed=evaluation.passed,
degraded=question_data.degraded,
provider=response.provider,
)
return question_data
async def stream_generate(
self,
request: GenerateQuestionRequest,
model: str = "",
temperature: float = 0.7,
user_id: str = "",
) -> AsyncGenerator[QuestionChunkData, None]:
"""流式题目生成.
Yields:
QuestionChunkData逐字 content + done 时携带 complete_question
"""
model = model or self._default_model
system_prompt = self._render_prompt(request)
messages = [
{"role": "system", "content": "你是一个专业的教育题目生成助手。"},
{"role": "user", "content": system_prompt},
]
accumulated = ""
try:
async for chunk in self._chain.stream_chat(
messages, model, temperature,
):
accumulated += chunk.delta
done = chunk.finish_reason in ("stop", "end_turn", "length")
if not done:
yield QuestionChunkData(content=chunk.delta, done=False)
# 流结束,运行评估
evaluation = await self._quality_gate.evaluate(
llm_output=accumulated,
expected_difficulty=request.difficulty,
expected_question_type=request.question_type,
subject=request.subject,
)
parsed = (
evaluation.rule_result.parsed if evaluation.rule_result else None
)
if parsed:
question_data = self._build_question_from_parsed(
parsed, request, evaluation.score,
)
else:
question_data = GeneratedQuestionData(
question=accumulated,
answer="",
explanation="",
question_type=request.question_type,
difficulty=request.difficulty,
knowledge_point_ids=request.knowledge_point_ids,
evaluation_score=evaluation.score,
degraded=True,
degraded_reason="; ".join(evaluation.errors) or "json parse failed",
)
if evaluation.degraded and not question_data.degraded:
question_data.degraded = True
question_data.degraded_reason = evaluation.degraded_reason
yield QuestionChunkData(
content="",
done=True,
complete_question=question_data,
)
except AILLMUnavailableError as exc:
logger.warning("question_stream_degraded", reason=str(exc))
yield QuestionChunkData(
content=f"[degraded] {exc}",
done=True,
complete_question=GeneratedQuestionData(
question="",
answer="",
explanation=f"[degraded] LLM unavailable: {exc}",
question_type=request.question_type,
difficulty=request.difficulty,
knowledge_point_ids=request.knowledge_point_ids,
evaluation_score=0.0,
degraded=True,
degraded_reason=str(exc),
),
)
except Exception as exc: # noqa: BLE001
logger.error("question_stream_error", error=str(exc))
yield QuestionChunkData(
content=f"[error] {exc}",
done=True,
complete_question=GeneratedQuestionData(
question="",
answer="",
explanation=f"[error] {exc}",
question_type=request.question_type,
difficulty=request.difficulty,
knowledge_point_ids=request.knowledge_point_ids,
evaluation_score=0.0,
degraded=True,
degraded_reason=str(exc),
),
)
def _render_prompt(self, request: GenerateQuestionRequest) -> str:
"""渲染题目生成 prompt 模板."""
if self._prompts is None:
return self._fallback_prompt(request)
try:
return self._prompts.render(
"generate_question",
{
"subject": request.subject,
"grade": request.grade or "未指定",
"difficulty": request.difficulty,
"question_type": request.question_type,
"knowledge_points": request.knowledge_point_ids or ["基础概念"],
"knowledge_point_ids": request.knowledge_point_ids,
"count": request.count,
"prompt": request.prompt,
},
)
except Exception: # noqa: BLE001
return self._fallback_prompt(request)
def _fallback_prompt(self, request: GenerateQuestionRequest) -> str:
"""模板不可用时的降级 prompt."""
return (
f"请生成一道{request.subject}题目,"
f"难度:{request.difficulty},题型:{request.question_type}\n"
f"要求:{request.prompt}\n"
"请按 JSON 格式输出:"
'{"question":"...","answer":"...","explanation":"..."}'
)
def _build_question_from_parsed(
self,
parsed: dict[str, Any],
request: GenerateQuestionRequest,
evaluation_score: float,
) -> GeneratedQuestionData:
"""从解析后的 dict 构建 GeneratedQuestionData."""
return GeneratedQuestionData(
question=str(parsed.get("question", "")),
answer=str(parsed.get("answer", "")),
explanation=str(parsed.get("explanation", "")),
question_type=str(
parsed.get("question_type", request.question_type),
),
difficulty=str(parsed.get("difficulty", request.difficulty)),
knowledge_point_ids=list(
parsed.get("knowledge_point_ids", request.knowledge_point_ids),
),
evaluation_score=evaluation_score,
)

View File

@@ -0,0 +1,22 @@
"""用量记录 + Kafka 事件发布 + 配额管理02-architecture-design.md §7, §11.
模块:
- UsageRecorder: Redis 记录 token 用量(按 user/school/month 维度)
- KafkaProducer: 发布 AIUsageEvent 到 edu.ai.usage topic派生数据豁免 Outbox
- QuotaEnforcer: 月度 token 预算校验,超限抛 AIQuotaExceededError
全并行模式Redis/Kafka 不可用时降级(记录警告,不阻断主流程)。
"""
from .kafka_producer import KafkaProducer, UsageEvent
from .quota_enforcer import QuotaEnforcer, QuotaStatus
from .usage_recorder import UsageRecord, UsageRecorder
__all__ = [
"KafkaProducer",
"UsageEvent",
"QuotaEnforcer",
"QuotaStatus",
"UsageRecorder",
"UsageRecord",
]

View File

@@ -0,0 +1,195 @@
"""Kafka 用量事件生产者.
发布 AIUsageEvent 到 edu.ai.usage topiccoord-final-decisions A2 + 总裁 §2.6.
派生数据豁免 Outbox 模式coord-final-decisions A3直接通过 aiokafka 发布。
全并行模式Kafka 不可用时降级(记录警告,不阻断主流程)。
"""
import json
import uuid
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from typing import Any
import structlog
logger = structlog.get_logger()
@dataclass
class UsageEvent:
"""AI 用量事件(对应 events.proto AIUsageEvent message."""
event_id: str = ""
aggregate_id: str = ""
event_type: str = "AIUsageRecorded"
occurred_at: int = 0 # Unix 毫秒时间戳
user_id: str = ""
school_id: str = ""
request_id: str = ""
provider: str = ""
model: str = ""
operation: str = ""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
latency_ms: int = 0
success: bool = True
degraded: bool = False
metadata: dict[str, str] | None = None
def to_dict(self) -> dict[str, Any]:
"""转换为 dict用于 JSON 序列化)."""
data = asdict(self)
# 确保 event_id 和 occurred_at 已填充
if not data["event_id"]:
data["event_id"] = str(uuid.uuid4())
if not data["occurred_at"]:
data["occurred_at"] = int(
datetime.now(UTC).timestamp() * 1000,
)
return data
@classmethod
def from_usage_record(
cls,
user_id: str,
school_id: str,
request_id: str,
provider: str,
model: str,
operation: str,
prompt_tokens: int,
completion_tokens: int,
total_tokens: int,
latency_ms: int,
success: bool = True,
degraded: bool = False,
aggregate_id: str = "",
metadata: dict[str, str] | None = None,
) -> "UsageEvent":
"""从用量数据构建事件."""
return cls(
event_id=str(uuid.uuid4()),
aggregate_id=aggregate_id or request_id or str(uuid.uuid4()),
event_type="AIUsageRecorded",
occurred_at=int(datetime.now(UTC).timestamp() * 1000),
user_id=user_id,
school_id=school_id,
request_id=request_id,
provider=provider,
model=model,
operation=operation,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
latency_ms=latency_ms,
success=success,
degraded=degraded,
metadata=metadata,
)
class KafkaProducer:
"""Kafka 用量事件生产者.
全并行模式Kafka 不可用时降级,仅记录日志。
"""
def __init__(
self,
bootstrap_servers: str = "localhost:9092",
topic: str = "edu.ai.usage",
transactional_id: str = "ai-service-producer",
) -> None:
self._bootstrap_servers = bootstrap_servers
self._topic = topic
self._transactional_id = transactional_id
self._producer: Any = None # AIOKafkaProducer
self._started = False
async def start(self) -> None:
"""启动 Kafka 生产者."""
try:
from aiokafka import AIOKafkaProducer
self._producer = AIOKafkaProducer(
bootstrap_servers=self._bootstrap_servers,
value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode(
"utf-8",
),
key_serializer=lambda k: k.encode("utf-8") if k else None,
enable_idempotence=True,
transactional_id=self._transactional_id,
)
await self._producer.start()
self._started = True
logger.info(
"kafka_producer_started",
bootstrap_servers=self._bootstrap_servers,
topic=self._topic,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"kafka_producer_start_failed_degraded",
error=str(exc),
)
self._producer = None
self._started = False
async def stop(self) -> None:
"""停止 Kafka 生产者."""
if self._producer is not None:
try:
await self._producer.stop()
logger.info("kafka_producer_stopped")
except Exception as exc: # noqa: BLE001
logger.warning("kafka_producer_stop_failed", error=str(exc))
finally:
self._producer = None
self._started = False
async def publish(self, event: UsageEvent) -> None:
"""发布用量事件.
全并行模式Kafka 不可用时降级,仅记录日志。
Args:
event: 用量事件
"""
if not self._started or self._producer is None:
logger.debug(
"kafka_publish_skipped_not_started",
event_id=event.event_id,
operation=event.operation,
)
return
data = event.to_dict()
try:
# 使用事务保证 exactly-once
async with self._producer.transaction():
await self._producer.send_and_wait(
self._topic,
value=data,
key=event.user_id,
)
logger.debug(
"usage_event_published",
event_id=event.event_id,
topic=self._topic,
user_id=event.user_id,
operation=event.operation,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"kafka_publish_failed_degraded",
error=str(exc),
event_id=event.event_id,
)
@property
def is_started(self) -> bool:
"""生产者是否已启动."""
return self._started

View File

@@ -0,0 +1,137 @@
"""配额管理器(月度 token 预算校验).
在 LLM 调用前检查用户/学校的月度 token 预算:
- 用户级default_teacher_monthly_budget默认 100,000 tokens/月)
- 学校级default_school_monthly_budget默认 1,000,000 tokens/月)
超限时抛 AIQuotaExceededError由 GlobalErrorHandler 转换为 ActionState 错误响应。
全并行模式Redis 不可用时降级放行(记录警告)。
"""
from dataclasses import dataclass
import structlog
from ..errors import AIQuotaExceededError
from .usage_recorder import UsageRecorder
logger = structlog.get_logger()
@dataclass
class QuotaStatus:
"""配额状态."""
allowed: bool
scope: str # user / school
used: int
budget: int
remaining: int
@property
def usage_percentage(self) -> float:
"""使用百分比."""
if self.budget == 0:
return 0.0
return round(self.used / self.budget * 100, 2)
class QuotaEnforcer:
"""配额管理器.
检查用户和学校的月度 token 预算,超限拒绝请求。
全并行模式Redis 不可用时降级放行。
"""
def __init__(
self,
usage_recorder: UsageRecorder,
user_monthly_budget: int = 100_000,
school_monthly_budget: int = 1_000_000,
) -> None:
self._recorder = usage_recorder
self._user_budget = user_monthly_budget
self._school_budget = school_monthly_budget
async def check(
self,
user_id: str,
school_id: str = "",
) -> list[QuotaStatus]:
"""检查配额.
Args:
user_id: 用户 ID
school_id: 学校 ID可选
Returns:
各维度配额状态列表
Raises:
AIQuotaExceededError: 任一维度超限
"""
statuses: list[QuotaStatus] = []
if user_id:
user_status = await self._check_user(user_id)
statuses.append(user_status)
if school_id:
school_status = await self._check_school(school_id)
statuses.append(school_status)
for status in statuses:
if not status.allowed:
logger.warning(
"quota_exceeded",
scope=status.scope,
used=status.used,
budget=status.budget,
usage_pct=status.usage_percentage,
)
raise AIQuotaExceededError(
status.scope, status.used, status.budget,
)
return statuses
async def _check_user(self, user_id: str) -> QuotaStatus:
"""检查用户级配额."""
used = await self._recorder.get_user_usage(user_id)
remaining = max(0, self._user_budget - used)
allowed = used < self._user_budget
return QuotaStatus(
allowed=allowed,
scope="user",
used=used,
budget=self._user_budget,
remaining=remaining,
)
async def _check_school(self, school_id: str) -> QuotaStatus:
"""检查学校级配额."""
used = await self._recorder.get_school_usage(school_id)
remaining = max(0, self._school_budget - used)
allowed = used < self._school_budget
return QuotaStatus(
allowed=allowed,
scope="school",
used=used,
budget=self._school_budget,
remaining=remaining,
)
@classmethod
def from_settings(
cls,
usage_recorder: UsageRecorder,
user_budget: int,
school_budget: int,
) -> "QuotaEnforcer":
"""从配置创建配额管理器."""
return cls(
usage_recorder=usage_recorder,
user_monthly_budget=user_budget,
school_monthly_budget=school_budget,
)

View File

@@ -0,0 +1,140 @@
"""用量记录器Redis 持久化).
按维度记录 token 用量:
- user:{user_id}:usage:{YYYYMM} → 月度用户用量
- school:{school_id}:usage:{YYYYMM} → 月度学校用量
全并行模式Redis 不可用时降级(仅记录日志,不阻断主流程)。
"""
from dataclasses import dataclass
from datetime import UTC, datetime
import structlog
from redis.asyncio import Redis
from redis.exceptions import RedisError
logger = structlog.get_logger()
@dataclass
class UsageRecord:
"""单次用量记录."""
user_id: str
school_id: str
provider: str
model: str
operation: str
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
latency_ms: int = 0
success: bool = True
degraded: bool = False
request_id: str = ""
class UsageRecorder:
"""用量记录器.
将每次 LLM 调用的 token 用量记录到 Redis供 QuotaEnforcer 查询。
全并行模式Redis 不可用时降级,仅记录日志。
"""
def __init__(self, redis: Redis | None = None) -> None:
self._redis = redis
async def record(self, record: UsageRecord) -> None:
"""记录一次用量.
Args:
record: 用量记录
"""
if self._redis is None:
logger.debug(
"usage_record_skipped_no_redis",
user_id=record.user_id,
operation=record.operation,
total_tokens=record.total_tokens,
)
return
month_key = self._month_key()
user_key = f"user:{record.user_id}:usage:{month_key}"
school_key = f"school:{record.school_id}:usage:{month_key}"
try:
# 使用 INCRBY 原子递增 token 用量
pipe = self._redis.pipeline()
pipe.incrby(user_key, record.total_tokens)
pipe.incrby(school_key, record.total_tokens)
# 设置过期时间35 天,覆盖月度周期)
pipe.expire(user_key, 35 * 86400)
pipe.expire(school_key, 35 * 86400)
await pipe.execute()
logger.info(
"usage_recorded",
user_id=record.user_id,
school_id=record.school_id,
operation=record.operation,
provider=record.provider,
model=record.model,
total_tokens=record.total_tokens,
success=record.success,
degraded=record.degraded,
)
except RedisError as exc:
logger.warning(
"usage_record_failed_redis_error",
error=str(exc),
user_id=record.user_id,
)
async def get_user_usage(self, user_id: str, month: str = "") -> int:
"""查询用户月度用量.
Args:
user_id: 用户 ID
month: 月份键(空则取当前月)
Returns:
已用 token 数Redis 不可用时返回 0
"""
if self._redis is None:
return 0
month_key = month or self._month_key()
key = f"user:{user_id}:usage:{month_key}"
try:
value = await self._redis.get(key)
return int(value) if value else 0
except RedisError as exc:
logger.warning("usage_query_failed", error=str(exc))
return 0
async def get_school_usage(self, school_id: str, month: str = "") -> int:
"""查询学校月度用量.
Args:
school_id: 学校 ID
month: 月份键(空则取当前月)
Returns:
已用 token 数Redis 不可用时返回 0
"""
if self._redis is None:
return 0
month_key = month or self._month_key()
key = f"school:{school_id}:usage:{month_key}"
try:
value = await self._redis.get(key)
return int(value) if value else 0
except RedisError as exc:
logger.warning("usage_query_failed", error=str(exc))
return 0
@staticmethod
def _month_key() -> str:
"""当前月份键YYYYMM 格式)."""
return datetime.now(UTC).strftime("%Y%m")

View File

@@ -0,0 +1,18 @@
"""备课工作流模块02-architecture-design.md §11 备课工作流).
P5 实现FastAPI BackgroundTasks + Redis 状态存储24h TTL
P6+ 评估Temporal 工作流引擎
模块:
- WorkflowStateStore: Redis 工作流状态存储CRUD + TTL
- LessonPlanWorkflowService: 4 步编排(分析学情 → 推荐知识点 → 生成题目 → 教师审核)
"""
from .lesson_plan_workflow import LessonPlanWorkflowService
from .state_store import WorkflowState, WorkflowStateStore
__all__ = [
"LessonPlanWorkflowService",
"WorkflowState",
"WorkflowStateStore",
]

View File

@@ -0,0 +1,536 @@
"""备课工作流服务4 步编排 + 状态机).
状态机02-architecture-design.md §2.4
Pending → Analyzing → Analyzed/AnalyzeFailed
→ Recommending → Recommended
→ Generating → Generated/Regenerating/GenerationFailed
→ PendingReview → Confirmed/Modified/Rejected/Expired
→ Persisting → Persisted/PersistFailed
P5 实现FastAPI BackgroundTasks + Redis 状态存储
P6+ 评估Temporal 工作流引擎
4 步编排:
Step 1: 分析学情(调 data-ana.GetClassPerformance + GetStudentWeakness
Step 2: 推荐知识点(调 content.GetPrerequisites + GetLearningPath
Step 3: 生成题目LLM 生成 + 评估三道防线,最多重试 3 次)
Step 4: 教师审核pending_review → confirm → content.CreateQuestions 入库)
"""
import asyncio
from typing import Any
import structlog
from ..clients.content_client import ContentClient, QuestionInput
from ..clients.data_ana_client import DataAnaClient
from ..errors import AIError, AIWorkflowStateInvalidError
from ..models.question import GeneratedQuestionData
from ..prompt_service import PromptTemplateService
from ..providers import ProviderFailoverChain
from ..services.evaluation import QualityGate
from .state_store import WorkflowState, WorkflowStateStore
logger = structlog.get_logger()
# 状态常量(简化 proto WorkflowStatus内部用
STATUS_PENDING = "pending"
STATUS_ANALYZING = "analyzing"
STATUS_GENERATING = "generating"
STATUS_PENDING_REVIEW = "pending_review"
STATUS_PERSISTED = "persisted"
STATUS_FAILED = "failed"
# 生成重试上限
MAX_GENERATE_RETRIES = 3
# 预估完成时间(秒)
ESTIMATED_COMPLETION_SECONDS = 60
class LessonPlanWorkflowService:
"""备课工作流服务.
P5 使用 asyncio.create_task 在后台执行工作流。
P6+ 评估迁移到 Temporal。
"""
def __init__(
self,
state_store: WorkflowStateStore,
failover_chain: ProviderFailoverChain,
prompt_service: PromptTemplateService | None = None,
quality_gate: QualityGate | None = None,
content_client: ContentClient | None = None,
data_ana_client: DataAnaClient | None = None,
default_model: str = "gpt-4o-mini",
) -> None:
self._store = state_store
self._chain = failover_chain
self._prompts = prompt_service
self._quality_gate = quality_gate or QualityGate()
self._content_client = content_client
self._data_ana_client = data_ana_client
self._default_model = default_model
self._background_tasks: dict[str, asyncio.Task[Any]] = {}
async def start(
self,
user_id: str,
school_id: str,
class_id: str,
subject_id: str,
topic: str,
target_difficulty: str = "medium",
question_count: int = 5,
request_id: str = "",
) -> WorkflowState:
"""启动备课工作流.
创建工作流状态,立即返回,后台异步执行 4 步编排。
Args:
user_id: 用户 ID
school_id: 学校 ID
class_id: 班级 ID
subject_id: 学科 ID
topic: 备课主题
target_difficulty: 目标难度
question_count: 题目数量
request_id: 请求 ID
Returns:
WorkflowState含 workflow_idstatus=pending
"""
state = WorkflowState(
user_id=user_id,
school_id=school_id,
class_id=class_id,
subject_id=subject_id,
topic=topic,
target_difficulty=target_difficulty,
question_count=question_count,
status=STATUS_PENDING,
request_id=request_id,
)
await self._store.create(state)
# 启动后台任务执行工作流
task = asyncio.create_task(self._run_workflow(state.workflow_id))
self._background_tasks[state.workflow_id] = task
logger.info(
"workflow_started",
workflow_id=state.workflow_id,
user_id=user_id,
topic=topic,
)
return state
async def get_status(self, workflow_id: str) -> WorkflowState:
"""查询工作流状态.
Args:
workflow_id: 工作流 ID
Returns:
WorkflowState
Raises:
AIWorkflowNotFoundError: 工作流不存在
"""
return await self._store.get(workflow_id)
async def confirm(
self,
workflow_id: str,
modifications: dict[str, str] | None = None,
) -> dict[str, Any]:
"""教师确认备课结果入库.
Args:
workflow_id: 工作流 ID
modifications: 教师修改question_index → 修改后内容)
Returns:
{"success": bool, "persisted_question_ids": list[str]}
Raises:
AIWorkflowNotFoundError: 工作流不存在
AIWorkflowStateInvalidError: 工作流状态不允许确认
"""
state = await self._store.get(workflow_id)
if state.status != STATUS_PENDING_REVIEW:
raise AIWorkflowStateInvalidError(
workflow_id,
state.status,
"confirm",
)
# 应用教师修改
questions = state.questions
if modifications:
for idx_str, modified_content in modifications.items():
try:
idx = int(idx_str)
if 0 <= idx < len(questions):
questions[idx].question = modified_content
except (ValueError, IndexError):
logger.warning(
"workflow_confirm_invalid_modification",
workflow_id=workflow_id,
index=idx_str,
)
# 调 content.CreateQuestions 入库
if self._content_client is None:
logger.warning(
"workflow_confirm_no_content_client",
workflow_id=workflow_id,
)
return {
"success": False,
"persisted_question_ids": [],
"error": "content client not configured",
}
question_inputs = [
QuestionInput(
question=q.question,
answer=q.answer,
explanation=q.explanation,
question_type=q.question_type,
difficulty=q.difficulty,
knowledge_point_ids=q.knowledge_point_ids,
)
for q in questions
]
try:
created = await self._content_client.create_questions(
question_inputs,
user_id=state.user_id,
)
persisted_ids = [c.id for c in created]
await self._store.update(
workflow_id,
status=STATUS_PERSISTED,
)
logger.info(
"workflow_confirmed",
workflow_id=workflow_id,
persisted_count=len(persisted_ids),
)
return {
"success": True,
"persisted_question_ids": persisted_ids,
}
except AIError as exc:
await self._store.update(
workflow_id,
status=STATUS_FAILED,
error=str(exc),
)
return {
"success": False,
"persisted_question_ids": [],
"error": str(exc),
}
async def _run_workflow(self, workflow_id: str) -> None:
"""执行 4 步编排(后台任务).
Step 1: 分析学情
Step 2: 推荐知识点
Step 3: 生成题目
Step 4: 设置为待审核
"""
try:
state = await self._store.get(workflow_id)
# Step 1: 分析学情
await self._store.update(
workflow_id,
status=STATUS_ANALYZING,
)
analysis = await self._step1_analyze(state)
state = await self._store.update(
workflow_id,
analysis=analysis,
)
# Step 2: 推荐知识点
knowledge_points = await self._step2_recommend(state)
# Step 3: 生成题目
await self._store.update(
workflow_id,
status=STATUS_GENERATING,
)
questions = await self._step3_generate(state, knowledge_points)
# Step 4: 设置为待审核
await self._store.update(
workflow_id,
status=STATUS_PENDING_REVIEW,
questions=questions,
)
logger.info(
"workflow_completed_pending_review",
workflow_id=workflow_id,
question_count=len(questions),
)
except Exception as exc: # noqa: BLE001
logger.error(
"workflow_failed",
workflow_id=workflow_id,
error=str(exc),
)
await self._store.update(
workflow_id,
status=STATUS_FAILED,
error=str(exc),
)
finally:
self._background_tasks.pop(workflow_id, None)
async def _step1_analyze(self, state: WorkflowState) -> dict[str, Any]:
"""Step 1: 分析学情.
调 data-ana 查询班级学情 + 学生薄弱点。
全并行模式data-ana 不可用时降级(返回空分析)。
"""
analysis: dict[str, Any] = {}
if self._data_ana_client is not None:
try:
performance = await self._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(
"workflow_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"
logger.info(
"workflow_step1_completed",
workflow_id=state.workflow_id,
degraded=analysis.get("degraded", False),
)
return analysis
async def _step2_recommend(
self,
state: WorkflowState,
) -> list[dict[str, str]]:
"""Step 2: 推荐知识点.
调 content 查询知识点前置依赖 + 学习路径。
全并行模式content 不可用时降级(基于 topic 推导)。
"""
knowledge_points: list[dict[str, str]] = []
if self._content_client is not None:
try:
# 查询学习路径
learning_path = await self._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(
"workflow_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(
"workflow_step2_completed",
workflow_id=state.workflow_id,
knowledge_point_count=len(knowledge_points),
)
return knowledge_points
async def _step3_generate(
self,
state: WorkflowState,
knowledge_points: list[dict[str, str]],
) -> list[GeneratedQuestionData]:
"""Step 3: 生成题目.
使用 LLM 生成题目 + 评估三道防线。
评估未通过时重试(最多 MAX_GENERATE_RETRIES 次)。
"""
questions: list[GeneratedQuestionData] = []
kp_ids = [kp["id"] for kp in knowledge_points]
for i in range(state.question_count):
question = await self._generate_single_question(
state,
kp_ids,
question_index=i,
)
questions.append(question)
logger.info(
"workflow_step3_completed",
workflow_id=state.workflow_id,
question_count=len(questions),
)
return questions
async def _generate_single_question(
self,
state: WorkflowState,
kp_ids: list[str],
question_index: int = 0,
) -> GeneratedQuestionData:
"""生成单道题目(含重试)."""
prompt = self._render_generate_prompt(state, kp_ids, question_index)
messages = [
{"role": "system", "content": "你是一个专业的教育题目生成助手。"},
{"role": "user", "content": prompt},
]
for attempt in range(MAX_GENERATE_RETRIES):
try:
response = await self._chain.chat(
messages,
self._default_model,
0.7,
)
# 评估三道防线
evaluation = await self._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(
"workflow_generate_retry",
workflow_id=state.workflow_id,
attempt=attempt + 1,
score=evaluation.score,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"workflow_generate_error_retry",
workflow_id=state.workflow_id,
attempt=attempt + 1,
error=str(exc),
)
# 所有重试失败,返回降级题目
logger.warning(
"workflow_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(
self,
state: WorkflowState,
kp_ids: list[str],
question_index: int = 0,
) -> str:
"""渲染题目生成 prompt."""
if self._prompts is not None:
try:
return self._prompts.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":"..."}'
)

View File

@@ -0,0 +1,250 @@
"""工作流状态存储Redis 持久化24h TTL.
存储备课工作流的状态和中间结果,支持:
- create: 创建工作流
- get: 查询工作流状态
- update: 更新工作流状态
- delete: 删除工作流
Redis key 格式workflow:{workflow_id}
TTL24h86400s可配置
全并行模式Redis 不可用时降级到内存存储(仅单实例有效)。
"""
import json
import uuid
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import Any
import structlog
from redis.asyncio import Redis
from redis.exceptions import RedisError
from ..errors import AIWorkflowNotFoundError
from ..models.question import GeneratedQuestionData
from ..models.workflow import WorkflowStatus
logger = structlog.get_logger()
# Redis key 前缀
WORKFLOW_KEY_PREFIX = "workflow"
# 默认 TTL24h
DEFAULT_TTL_SECONDS = 86400
@dataclass
class WorkflowState:
"""工作流状态."""
workflow_id: str = ""
user_id: str = ""
school_id: str = ""
class_id: str = ""
subject_id: str = ""
topic: str = ""
target_difficulty: str = "medium"
question_count: int = 5
status: WorkflowStatus = "pending"
questions: list[GeneratedQuestionData] = field(default_factory=list)
analysis: dict[str, Any] = field(default_factory=dict)
error: str | None = None
created_at: int = 0
updated_at: int = 0
request_id: str = ""
def __post_init__(self) -> None:
if not self.workflow_id:
self.workflow_id = str(uuid.uuid4())
now = int(datetime.now(UTC).timestamp() * 1000)
if not self.created_at:
self.created_at = now
if not self.updated_at:
self.updated_at = now
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict用于 Redis JSON 存储)."""
data = asdict(self)
# GeneratedQuestionData 转换为 dict
data["questions"] = [
q.model_dump() if hasattr(q, "model_dump") else asdict(q)
for q in self.questions
]
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkflowState":
"""从 dict 反序列化."""
questions_data = data.get("questions", [])
questions = [
GeneratedQuestionData(**q) if isinstance(q, dict) else q
for q in questions_data
]
return cls(
workflow_id=data.get("workflow_id", ""),
user_id=data.get("user_id", ""),
school_id=data.get("school_id", ""),
class_id=data.get("class_id", ""),
subject_id=data.get("subject_id", ""),
topic=data.get("topic", ""),
target_difficulty=data.get("target_difficulty", "medium"),
question_count=data.get("question_count", 5),
status=data.get("status", "pending"),
questions=questions,
analysis=data.get("analysis", {}),
error=data.get("error"),
created_at=data.get("created_at", 0),
updated_at=data.get("updated_at", 0),
request_id=data.get("request_id", ""),
)
def touch(self) -> None:
"""更新 updated_at 时间戳."""
self.updated_at = int(datetime.now(UTC).timestamp() * 1000)
class WorkflowStateStore:
"""工作流状态存储Redis.
全并行模式Redis 不可用时降级到内存存储。
"""
def __init__(
self,
redis: Redis | None = None,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
) -> None:
self._redis = redis
self._ttl = ttl_seconds
# 内存降级存储Redis 不可用时使用)
self._memory_store: dict[str, str] = {}
async def create(self, state: WorkflowState) -> WorkflowState:
"""创建工作流状态.
Args:
state: 工作流状态
Returns:
创建后的 WorkflowState含生成的 workflow_id
"""
state.touch()
key = self._key(state.workflow_id)
value = json.dumps(state.to_dict(), ensure_ascii=False)
if self._redis is not None:
try:
await self._redis.setex(key, self._ttl, value)
logger.info(
"workflow_created",
workflow_id=state.workflow_id,
status=state.status,
)
except RedisError as exc:
logger.warning(
"workflow_create_redis_failed_using_memory",
error=str(exc),
)
self._memory_store[key] = value
else:
self._memory_store[key] = value
return state
async def get(self, workflow_id: str) -> WorkflowState:
"""查询工作流状态.
Args:
workflow_id: 工作流 ID
Returns:
WorkflowState
Raises:
AIWorkflowNotFoundError: 工作流不存在或已过期
"""
key = self._key(workflow_id)
value: str | None = None
if self._redis is not None:
try:
value = await self._redis.get(key)
except RedisError as exc:
logger.warning(
"workflow_get_redis_failed_using_memory",
error=str(exc),
)
value = self._memory_store.get(key)
else:
value = self._memory_store.get(key)
if value is None:
raise AIWorkflowNotFoundError(workflow_id)
data = json.loads(value)
return WorkflowState.from_dict(data)
async def update(self, workflow_id: str, **updates: Any) -> WorkflowState:
"""更新工作流状态.
Args:
workflow_id: 工作流 ID
**updates: 要更新的字段
Returns:
更新后的 WorkflowState
Raises:
AIWorkflowNotFoundError: 工作流不存在
"""
state = await self.get(workflow_id)
for key, value in updates.items():
if hasattr(state, key):
setattr(state, key, value)
state.touch()
# 重新保存
redis_key = self._key(workflow_id)
serialized = json.dumps(state.to_dict(), ensure_ascii=False)
if self._redis is not None:
try:
await self._redis.setex(redis_key, self._ttl, serialized)
logger.info(
"workflow_updated",
workflow_id=workflow_id,
status=state.status,
)
except RedisError as exc:
logger.warning(
"workflow_update_redis_failed_using_memory",
error=str(exc),
)
self._memory_store[redis_key] = serialized
else:
self._memory_store[redis_key] = serialized
return state
async def delete(self, workflow_id: str) -> None:
"""删除工作流.
Args:
workflow_id: 工作流 ID
"""
key = self._key(workflow_id)
if self._redis is not None:
try:
await self._redis.delete(key)
except RedisError as exc:
logger.warning("workflow_delete_redis_failed", error=str(exc))
self._memory_store.pop(key, None)
@staticmethod
def _key(workflow_id: str) -> str:
"""构建 Redis key."""
return f"{WORKFLOW_KEY_PREFIX}:{workflow_id}"