feat: auto committed
This commit is contained in:
24
services/ai/src/ai/clients/__init__.py
Normal file
24
services/ai/src/ai/clients/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""下游 gRPC 客户端模块(02-architecture-design.md §1.2 Client 子图).
|
||||
|
||||
六边形架构端口模式:每个客户端为抽象接口,可注入 mock 实现。
|
||||
|
||||
客户端:
|
||||
- ContentClient: 查询知识点/教材/题库(content 服务 gRPC 50054)
|
||||
- DataAnaClient: 查询学情/薄弱点/趋势(data-ana 服务 gRPC 50055)
|
||||
- IamClient: 查询 DataScope(iam 服务 gRPC 50052,P4 补全后启用)
|
||||
|
||||
全并行模式:下游不可用时返回 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",
|
||||
]
|
||||
153
services/ai/src/ai/clients/base_client.py
Normal file
153
services/ai/src/ai/clients/base_client.py
Normal 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:
|
||||
"""客户端是否可用."""
|
||||
...
|
||||
224
services/ai/src/ai/clients/content_client.py
Normal file
224
services/ai/src/ai/clients/content_client.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Content 服务 gRPC 客户端.
|
||||
|
||||
用于:
|
||||
- 查询知识点前置依赖(GetPrerequisites)
|
||||
- 查询学习路径(GetLearningPath)
|
||||
- 创建题目入库(CreateQuestions - P5 mock,content 服务待补全)
|
||||
|
||||
全并行模式:下游不可用时使用 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="知识点1(mock)"),
|
||||
KnowledgePoint(id="kp_002", title="知识点2(mock)"),
|
||||
KnowledgePoint(id="kp_003", title="知识点3(mock)"),
|
||||
]
|
||||
|
||||
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
|
||||
283
services/ai/src/ai/clients/data_ana_client.py
Normal file
283
services/ai/src/ai/clients/data_ana_client.py
Normal 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,
|
||||
)
|
||||
135
services/ai/src/ai/clients/iam_client.py
Normal file
135
services/ai/src/ai/clients/iam_client.py
Normal 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)
|
||||
@@ -1,23 +1,75 @@
|
||||
"""配置管理."""
|
||||
"""配置管理(pydantic-settings,12-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()
|
||||
|
||||
24
services/ai/src/ai/errors/__init__.py
Normal file
24
services/ai/src/ai/errors/__init__.py
Normal 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",
|
||||
]
|
||||
64
services/ai/src/ai/errors/codes.py
Normal file
64
services/ai/src/ai/errors/codes.py
Normal 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)
|
||||
82
services/ai/src/ai/errors/exceptions.py
Normal file
82
services/ai/src/ai/errors/exceptions.py
Normal 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},
|
||||
)
|
||||
21
services/ai/src/ai/grpc_server/__init__.py
Normal file
21
services/ai/src/ai/grpc_server/__init__.py
Normal 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",
|
||||
]
|
||||
154
services/ai/src/ai/grpc_server/interceptors.py
Normal file
154
services/ai/src/ai/grpc_server/interceptors.py
Normal 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 提取 UserContext(AuthInterceptor 注入)."""
|
||||
return getattr(context, "user_context", UserContext())
|
||||
78
services/ai/src/ai/grpc_server/server.py
Normal file
78
services/ai/src/ai/grpc_server/server.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""gRPC server 启动与管理.
|
||||
|
||||
使用 grpc.aio 异步 server,端口 50058(port-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)
|
||||
347
services/ai/src/ai/grpc_server/servicer.py
Normal file
347
services/ai/src/ai/grpc_server/servicer.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""AiService gRPC Servicer(8 RPC 实现).
|
||||
|
||||
所有 RPC 返回 protobuf message,降级采用方案 B(degraded 字段在 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: ChatService(Chat / StreamChat)
|
||||
- question_service: QuestionService(GenerateQuestion / StreamGenerateQuestion)
|
||||
- expression_service: ExpressionService(OptimizeExpression)
|
||||
- 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,
|
||||
)
|
||||
@@ -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 兼容的响应 dict;api_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"
|
||||
@@ -1,11 +1,26 @@
|
||||
"""AI 网关服务入口."""
|
||||
"""AI 网关服务入口.
|
||||
|
||||
整合组件(02-architecture-design.md §1.2 完整分层):
|
||||
- HTTP 端点(/v1/ai 前缀,ActionState 信封,10 端点)
|
||||
- gRPC server(端口 50058,8 RPC)
|
||||
- LLM Provider FailoverChain(4 适配器 + 熔断 + 故障切换)
|
||||
- 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,
|
||||
)
|
||||
|
||||
# 下游客户端(全并行模式用 Mock,ISSUE-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)
|
||||
|
||||
23
services/ai/src/ai/middleware/__init__.py
Normal file
23
services/ai/src/ai/middleware/__init__.py
Normal 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",
|
||||
]
|
||||
83
services/ai/src/ai/middleware/auth.py
Normal file
83
services/ai/src/ai/middleware/auth.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""认证上下文提取.
|
||||
|
||||
ai 服务不直接校验 JWT(Gateway 负责),从 Gateway/BFF 透传的 header 提取用户上下文。
|
||||
|
||||
透传 header(Gateway 注入):
|
||||
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"),
|
||||
)
|
||||
130
services/ai/src/ai/middleware/error_handler.py
Normal file
130
services/ai/src/ai/middleware/error_handler.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""全局错误处理.
|
||||
|
||||
将 AIError + 未知异常统一转换为 ActionState 错误响应。
|
||||
HTTP 端点返回 JSON,gRPC 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]]
|
||||
138
services/ai/src/ai/middleware/permission.py
Normal file
138
services/ai/src/ai/middleware/permission.py
Normal 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
|
||||
30
services/ai/src/ai/middleware/request_id.py
Normal file
30
services/ai/src/ai/middleware/request_id.py
Normal 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
|
||||
54
services/ai/src/ai/models/__init__.py
Normal file
54
services/ai/src/ai/models/__init__.py
Normal 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",
|
||||
]
|
||||
71
services/ai/src/ai/models/action_state.py
Normal file
71
services/ai/src/ai/models/action_state.py
Normal 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]":
|
||||
"""降级响应(方案 B:success=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,
|
||||
),
|
||||
)
|
||||
50
services/ai/src/ai/models/chat.py
Normal file
50
services/ai/src/ai/models/chat.py
Normal 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]):
|
||||
"""聊天响应信封."""
|
||||
25
services/ai/src/ai/models/expression.py
Normal file
25
services/ai/src/ai/models/expression.py
Normal 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]):
|
||||
"""表达优化响应信封."""
|
||||
46
services/ai/src/ai/models/question.py
Normal file
46
services/ai/src/ai/models/question.py
Normal 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]):
|
||||
"""题目生成响应信封."""
|
||||
73
services/ai/src/ai/models/workflow.py
Normal file
73
services/ai/src/ai/models/workflow.py
Normal 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]):
|
||||
"""确认入库响应信封."""
|
||||
148
services/ai/src/ai/prompt_service.py
Normal file
148
services/ai/src/ai/prompt_service.py
Normal 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()
|
||||
]
|
||||
12
services/ai/src/ai/prompts/chat_system.yaml
Normal file
12
services/ai/src/ai/prompts/chat_system.yaml
Normal 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 %}
|
||||
30
services/ai/src/ai/prompts/generate_question.yaml
Normal file
30
services/ai/src/ai/prompts/generate_question.yaml
Normal 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: 综合应用,需要多知识点结合
|
||||
34
services/ai/src/ai/prompts/lesson_plan_analyze.yaml
Normal file
34
services/ai/src/ai/prompts/lesson_plan_analyze.yaml
Normal 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": "教学策略建议"
|
||||
}
|
||||
37
services/ai/src/ai/prompts/lesson_plan_generate.yaml
Normal file
37
services/ai/src/ai/prompts/lesson_plan_generate.yaml
Normal 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"]
|
||||
}
|
||||
]
|
||||
24
services/ai/src/ai/prompts/optimize_expression.yaml
Normal file
24
services/ai/src/ai/prompts/optimize_expression.yaml
Normal 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"]
|
||||
}
|
||||
14
services/ai/src/ai/proto_gen/__init__.py
Normal file
14
services/ai/src/ai/proto_gen/__init__.py
Normal 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"]
|
||||
72
services/ai/src/ai/proto_gen/ai_pb2.py
Normal file
72
services/ai/src/ai/proto_gen/ai_pb2.py
Normal file
File diff suppressed because one or more lines are too long
418
services/ai/src/ai/proto_gen/ai_pb2_grpc.py
Normal file
418
services/ai/src/ai/proto_gen/ai_pb2_grpc.py
Normal 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 50058(port-allocation.md §3/§5/§7 权威源)
|
||||
HTTP 保留作 Gateway 直连降级 + 前端 SSE 流式;gRPC 为 BFF 主入口
|
||||
响应信封遵循 ActionState(004 §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 50058(port-allocation.md §3/§5/§7 权威源)
|
||||
HTTP 保留作 Gateway 直连降级 + 前端 SSE 流式;gRPC 为 BFF 主入口
|
||||
响应信封遵循 ActionState(004 §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 50058(port-allocation.md §3/§5/§7 权威源)
|
||||
HTTP 保留作 Gateway 直连降级 + 前端 SSE 流式;gRPC 为 BFF 主入口
|
||||
响应信封遵循 ActionState(004 §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)
|
||||
91
services/ai/src/ai/providers/__init__.py
Normal file
91
services/ai/src/ai/providers/__init__.py
Normal 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,
|
||||
仅注册已配置的 Provider(is_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)
|
||||
239
services/ai/src/ai/providers/anthropic_provider.py
Normal file
239
services/ai/src/ai/providers/anthropic_provider.py
Normal 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
|
||||
172
services/ai/src/ai/providers/baichuan_provider.py
Normal file
172
services/ai/src/ai/providers/baichuan_provider.py
Normal 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")
|
||||
100
services/ai/src/ai/providers/base.py
Normal file
100
services/ai/src/ai/providers/base.py
Normal 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")
|
||||
135
services/ai/src/ai/providers/circuit_breaker.py
Normal file
135
services/ai/src/ai/providers/circuit_breaker.py
Normal 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
|
||||
221
services/ai/src/ai/providers/failover.py
Normal file
221
services/ai/src/ai/providers/failover.py
Normal 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")
|
||||
191
services/ai/src/ai/providers/ollama_provider.py
Normal file
191
services/ai/src/ai/providers/ollama_provider.py
Normal 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", [])
|
||||
216
services/ai/src/ai/providers/openai_provider.py
Normal file
216
services/ai/src/ai/providers/openai_provider.py
Normal 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", [])
|
||||
194
services/ai/src/ai/rate_limiter.py
Normal file
194
services/ai/src/ai/rate_limiter.py
Normal 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,
|
||||
)
|
||||
22
services/ai/src/ai/security/__init__.py
Normal file
22
services/ai/src/ai/security/__init__.py
Normal 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",
|
||||
]
|
||||
126
services/ai/src/ai/security/input_sanitizer.py
Normal file
126
services/ai/src/ai/security/input_sanitizer.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""输入清洗器(prompt injection 检测 + 危险字符过滤).
|
||||
|
||||
检测以下攻击模式:
|
||||
- Prompt injection:jailbreak 指令、角色覆盖、指令注入
|
||||
- 危险字符: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)
|
||||
130
services/ai/src/ai/security/output_moderator.py
Normal file
130
services/ai/src/ai/security/output_moderator.py
Normal 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
|
||||
)
|
||||
112
services/ai/src/ai/security/pii_redactor.py
Normal file
112
services/ai/src/ai/security/pii_redactor.py
Normal 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:]
|
||||
18
services/ai/src/ai/services/__init__.py
Normal file
18
services/ai/src/ai/services/__init__.py
Normal 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",
|
||||
]
|
||||
112
services/ai/src/ai/services/chat_service.py
Normal file
112
services/ai/src/ai/services/chat_service.py
Normal 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."
|
||||
18
services/ai/src/ai/services/evaluation/__init__.py
Normal file
18
services/ai/src/ai/services/evaluation/__init__.py
Normal 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",
|
||||
]
|
||||
165
services/ai/src/ai/services/evaluation/llm_judge.py
Normal file
165
services/ai/src/ai/services/evaluation/llm_judge.py
Normal 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:
|
||||
JudgeResult(LLM 不可用时返回 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"),
|
||||
)
|
||||
169
services/ai/src/ai/services/evaluation/quality_gate.py
Normal file
169
services/ai/src/ai/services/evaluation/quality_gate.py
Normal 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
|
||||
167
services/ai/src/ai/services/evaluation/rule_validator.py
Normal file
167
services/ai/src/ai/services/evaluation/rule_validator.py
Normal 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}",
|
||||
)
|
||||
126
services/ai/src/ai/services/expression_service.py
Normal file
126
services/ai/src/ai/services/expression_service.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""表达优化服务.
|
||||
|
||||
编排 LLM FailoverChain + Prompt 模板,优化文字表达的清晰度、简洁度和语气。
|
||||
降级采用方案 B(总裁裁决 §2.6):LLM 不可用时返回 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
|
||||
304
services/ai/src/ai/services/question_service.py
Normal file
304
services/ai/src/ai/services/question_service.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""题目生成服务(非流式 + 流式 + 评估三道防线集成).
|
||||
|
||||
设计依据 02-architecture-design.md §10 评估三道防线:
|
||||
1. RuleValidator:JSON 格式 / 必填字段 / 难度匹配 / 题型校验
|
||||
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,
|
||||
)
|
||||
22
services/ai/src/ai/usage/__init__.py
Normal file
22
services/ai/src/ai/usage/__init__.py
Normal 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",
|
||||
]
|
||||
195
services/ai/src/ai/usage/kafka_producer.py
Normal file
195
services/ai/src/ai/usage/kafka_producer.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Kafka 用量事件生产者.
|
||||
|
||||
发布 AIUsageEvent 到 edu.ai.usage topic(coord-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
|
||||
137
services/ai/src/ai/usage/quota_enforcer.py
Normal file
137
services/ai/src/ai/usage/quota_enforcer.py
Normal 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,
|
||||
)
|
||||
140
services/ai/src/ai/usage/usage_recorder.py
Normal file
140
services/ai/src/ai/usage/usage_recorder.py
Normal 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")
|
||||
18
services/ai/src/ai/workflow/__init__.py
Normal file
18
services/ai/src/ai/workflow/__init__.py
Normal 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",
|
||||
]
|
||||
536
services/ai/src/ai/workflow/lesson_plan_workflow.py
Normal file
536
services/ai/src/ai/workflow/lesson_plan_workflow.py
Normal 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_id,status=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":"..."}'
|
||||
)
|
||||
250
services/ai/src/ai/workflow/state_store.py
Normal file
250
services/ai/src/ai/workflow/state_store.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""工作流状态存储(Redis 持久化,24h TTL).
|
||||
|
||||
存储备课工作流的状态和中间结果,支持:
|
||||
- create: 创建工作流
|
||||
- get: 查询工作流状态
|
||||
- update: 更新工作流状态
|
||||
- delete: 删除工作流
|
||||
|
||||
Redis key 格式:workflow:{workflow_id}
|
||||
TTL:24h(86400s,可配置)
|
||||
|
||||
全并行模式: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"
|
||||
|
||||
# 默认 TTL(24h)
|
||||
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}"
|
||||
Reference in New Issue
Block a user