feat(ai): 完善AI网关服务并添加LLM降级模式

config.py 加openai_api_key/base_url/dev_mode

新建llm_client.py httpx异步调OpenAI REST API

main.py 业务路由加/ai前缀+降级模式+readyz端点

Gateway添加/notifications和/ai路由

docs: known-issues记录P5三服务经验
This commit is contained in:
SpecialX
2026-07-09 09:09:27 +08:00
parent dfb6d2bfc1
commit a70a74207e
6 changed files with 377 additions and 71 deletions

View File

@@ -255,30 +255,35 @@
### 2.7 messagingTS/NestJSP5 ### 2.7 messagingTS/NestJSP5
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| -------------- | -------------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------ |
| 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 | | 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 |
| 通知批量化 | `createNotifications(items)` 单次 INSERT沿用旧项目 dispatcher 模式 | | 通知批量化 | `createBatch(items)` 单次 INSERT沿用旧项目 dispatcher 模式 |
| 多渠道 | 站内/SMS/邮件/微信in_app 批量 + 其他渠道并行 | | 多渠道 | 站内/SMS/邮件/微信in_app 批量 + 其他渠道并行 |
| fan-out 分页 | `getAllUserIds(limit=1000, offset)` 分页遍历 | | fan-out 分页 | `listByUserWithPagination(userId, page, pageSize)` 分页查询 |
| 撤回不乐观更新 | 需服务端返回判断 2 分钟窗口 | | ES 降级 | ES_URL 未设置时 esClient=nullsafeIndex/safeSearch 跳过返回空结果 |
| Push 推送降级 | PUSH_GATEWAY_URL 未设置或连接失败时 try/catch 跳过,不影响 DB 写入 |
| db 常量导出 | database.ts 导出 `db` 常量替代 `getDb()` 函数 |
### 2.8 push-gatewayGoP5 ### 2.8 push-gatewayGoP5
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| ---------------- | ----------------------------------------------- | | ---------------- | ---------------------------------------------------------------- |
| WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需发 Kafka 消息 | | WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需调 /internal/push |
| 跨实例同步 | Redis PubSub | | 跨实例同步 | Redis PubSubRedisURL 配置,预留 P6 实现) |
| 离线消息 | 仅推在线用户,离线消息存 MySQL上线时拉取 | | 离线消息 | 仅推在线用户,离线消息存 MySQL上线时拉取 |
| 并发写修复 | send chan + 单写协程模式,避免 gorilla/websocket 并发写竞争 |
| DEV_MODE 鉴权 | DEV_MODE=true 时接受 dev-token生产环境必须 JWT 校验 |
| 广播端点 | POST /internal/broadcastbody {event, data},调用 hub.Broadcast |
### 2.9 ai-gatewayPython/FastAPIP5 ### 2.9 ai-gatewayPython/FastAPIP5
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| ----------------- | ------------------------------------------- | | ----------------- | --------------------------------------------------------------- |
| LLM Provider 适配 | OpenAI/Anthropiclangchain/litellm 生态 | | LLM Provider 适配 | OpenAI 兼容 REST APIhttpx 异步),不引入 openai SDK |
| Prompt 模板管理 | 版本管理友好 | | 降级模式 | API key 为空或调用失败时返回骨架响应,标记 degraded: true |
| 流式 SSE | AI 网关 → BFF → 前端三层透传BFF 不缓冲 | | 流式 SSE | AI 网关 → BFF → 前端三层透传BFF 不缓冲 |
| 用量计费 | 按 token 计费 | | 路由前缀 | 业务路由加 /ai 前缀APIRouter prefix="/ai"Gateway 代理 /ai |
| AI 模块纯服务端 | Zod 验证 + 失败降级返回空(沿用旧项目模式) | | dev_mode tracer | dev_mode=true 时跳过 OTel exporter 初始化 |
### 2.10 shared-proto契约包 ### 2.10 shared-proto契约包
@@ -322,7 +327,8 @@
> 按时间倒序50 条上限。AI 发现更好方案时可更新本节。 > 按时间倒序50 条上限。AI 发现更好方案时可更新本节。
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 | | 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
| ---------- | ---- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------- | ---- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-07-09 | 中午 | msg/push-gateway/ai/api-gateway | **P5 沟通与 AI 阶段三服务完善**(1) msg 服务修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL 改 optional 加 DEV_MODE/PUSH_GATEWAY_URLelasticsearch.ts ES 降级esClient=null 时 safeIndex/safeSearch 跳过notifications.service.ts 加 createBatch + listByUserWithPagination + Push Gateway 推送调用try/catch 降级);新建 msg-init.sql 2 张表。(2) push-gateway 完善hub.go 重写用 send chan + 单写协程模式修复 gorilla/websocket 并发写竞争handler.go 加 DEV_MODE dev-token 支持 + broadcast 端点config.go 加 DevMode/RedisURL。(3) ai 服务完善config.py 加 openai_api_key/base_url/dev_mode新建 llm_client.pyhttpx 异步调 OpenAI REST APImain.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg/ai → ai 服务。**学到**gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入FastAPI APIRouter prefix 与 Gateway 代理路径要协调ai 服务加 /ai 前缀Gateway 代理 /ai/*pathLLM 降级策略统一返回 degraded 标记,调用方据此判断是否路由流量。 |
| 2026-07-09 | 上午 | content/api-gateway | **P4 内容分析服务端到端打通**(1) content 服务系统性修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODEneo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000health/lifecycle 改用 Drizzleglobal-error.filter 移除 @types/express 依赖textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块chaptersCRUD + 按 textbook 查询、knowledge-pointsCRUD + Neo4j 前置依赖图非阻塞查询、questionsCRUD + 4 种题型校验)。(3) Gateway 路由扩展textbooks/chapters/knowledge-points/questions 四组路由。(4) 数据库content-init.sql 4 张表。(5) E2E 验证POST /textbooks 201 → POST /chapters 201字段用 order 非 orderNum→ POST /knowledge-points 201Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**Drizzle schema TS 字段名与 DB 列名解耦order→order_numAPI 请求体用 TS 字段名Neo4j 不可用时必须 driver=null不设 NEO4J_URL否则每次请求尝试连接拖慢响应neo4j-driver safeCreateNode 用 try/catch 非阻塞MySQL 数据始终先落库。 | | 2026-07-09 | 上午 | content/api-gateway | **P4 内容分析服务端到端打通**(1) content 服务系统性修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODEneo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000health/lifecycle 改用 Drizzleglobal-error.filter 移除 @types/express 依赖textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块chaptersCRUD + 按 textbook 查询、knowledge-pointsCRUD + Neo4j 前置依赖图非阻塞查询、questionsCRUD + 4 种题型校验)。(3) Gateway 路由扩展textbooks/chapters/knowledge-points/questions 四组路由。(4) 数据库content-init.sql 4 张表。(5) E2E 验证POST /textbooks 201 → POST /chapters 201字段用 order 非 orderNum→ POST /knowledge-points 201Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**Drizzle schema TS 字段名与 DB 列名解耦order→order_numAPI 请求体用 TS 字段名Neo4j 不可用时必须 driver=null不设 NEO4J_URL否则每次请求尝试连接拖慢响应neo4j-driver safeCreateNode 用 try/catch 非阻塞MySQL 数据始终先落库。 |
| 2026-07-09 | 凌晨 | core-edu/api-gateway | **P3 核心教学服务端到端打通**(1) core-edu 服务系统性修复 13 项database.ts 导出 db 常量替代 getDb()env.ts JWT_SECRET 改 optional 加 DEV_MODEkafka.ts connectKafka 加 try/catch 不阻塞启动main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule3 个 controller 路由去前缀去 UseGuards 从 x-user-id 读身份exams/homework service datetime 列 ISO 字符串转 Date 修复 drizzle toISOString 错误;修正 10 处相对 import 路径health/lifecycle 改用 Drizzle 原生查询;新增 core-edu-init.sql 4 张表。(2) Gateway 路由扩展:发现 internal/routing/routing.go 是死代码(未被 main 引用),真正路由在 main.go在 main.go 添加 exams/homework/grades 三组路由(无尾斜杠+通配符);删除 routing.goconfig.go 加 CoreEduServiceURL。(3) DEV_MODE 环境变量问题Go 不自动加载 .env必须在启动前 export DEV_MODE=true 否则 dev-token 被拒 401。(4) E2E 验证POST /exams 201 → GET /exams/:id 200 → GET /exams/class/:id 200 → POST /homework 201 → POST /grades 201 → Outbox 3 条事件正确写入exam.failed 因 Kafka 未启动homework/grade pending。**学到**drizzle datetime 列需 Date 对象不是 ISO 字符串mapToDriverValue 调 toISOStringGo 项目 .env 不会自动加载需显式 export 或 godotenv 库NestJS controller 路由前缀与 Gateway 代理路径要协调Gateway 去掉 /api/v1 后转发controller 用裸路径如 'exams'Outbox 模式业务事务同写验证通过Kafka 未启动时事件 status=failed 但业务数据已落库。 | | 2026-07-09 | 凌晨 | core-edu/api-gateway | **P3 核心教学服务端到端打通**(1) core-edu 服务系统性修复 13 项database.ts 导出 db 常量替代 getDb()env.ts JWT_SECRET 改 optional 加 DEV_MODEkafka.ts connectKafka 加 try/catch 不阻塞启动main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule3 个 controller 路由去前缀去 UseGuards 从 x-user-id 读身份exams/homework service datetime 列 ISO 字符串转 Date 修复 drizzle toISOString 错误;修正 10 处相对 import 路径health/lifecycle 改用 Drizzle 原生查询;新增 core-edu-init.sql 4 张表。(2) Gateway 路由扩展:发现 internal/routing/routing.go 是死代码(未被 main 引用),真正路由在 main.go在 main.go 添加 exams/homework/grades 三组路由(无尾斜杠+通配符);删除 routing.goconfig.go 加 CoreEduServiceURL。(3) DEV_MODE 环境变量问题Go 不自动加载 .env必须在启动前 export DEV_MODE=true 否则 dev-token 被拒 401。(4) E2E 验证POST /exams 201 → GET /exams/:id 200 → GET /exams/class/:id 200 → POST /homework 201 → POST /grades 201 → Outbox 3 条事件正确写入exam.failed 因 Kafka 未启动homework/grade pending。**学到**drizzle datetime 列需 Date 对象不是 ISO 字符串mapToDriverValue 调 toISOStringGo 项目 .env 不会自动加载需显式 export 或 godotenv 库NestJS controller 路由前缀与 Gateway 代理路径要协调Gateway 去掉 /api/v1 后转发controller 用裸路径如 'exams'Outbox 模式业务事务同写验证通过Kafka 未启动时事件 status=failed 但业务数据已落库。 |
| 2026-07-09 | 上午 | iam/teacher-bff/teacher-portal | **P2 身份阶段完整实现**(1) Gateway 公开路径白名单register/login/refresh解决无 token 死锁。(2) IAM schema 扩展users 加 dataScope新增 role_viewports 表。(3) RBAC 端点 4 个 GET。(4) 视口按 requiredPermission 过滤 + sortOrder 排序getEffectivePermissions 用 Set 去重。(5) JWT payload 含 dataScoperegister 自动分配 teacher 角色。(6) 种子数据 7 权限+12 映射+7 视口。(7) Teacher BFF 视口聚合。(8) 前端lib/auth.ts + login + AppShell + (app) 路由组 + dashboard + classes真实 JWT+ 根重定向。(9) E2E 全链路通过。**学到**Next.js 路由组 (app) 不影响 URL/login 与 /dashboard 共存只后者套壳fetch headers 函数返回 Record<string,string> 避免 TS2769ESLint 9 需 flat config 留 P6AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 | | 2026-07-09 | 上午 | iam/teacher-bff/teacher-portal | **P2 身份阶段完整实现**(1) Gateway 公开路径白名单register/login/refresh解决无 token 死锁。(2) IAM schema 扩展users 加 dataScope新增 role_viewports 表。(3) RBAC 端点 4 个 GET。(4) 视口按 requiredPermission 过滤 + sortOrder 排序getEffectivePermissions 用 Set 去重。(5) JWT payload 含 dataScoperegister 自动分配 teacher 角色。(6) 种子数据 7 权限+12 映射+7 视口。(7) Teacher BFF 视口聚合。(8) 前端lib/auth.ts + login + AppShell + (app) 路由组 + dashboard + classes真实 JWT+ 根重定向。(9) E2E 全链路通过。**学到**Next.js 路由组 (app) 不影响 URL/login 与 /dashboard 共存只后者套壳fetch headers 函数返回 Record<string,string> 避免 TS2769ESLint 9 需 flat config 留 P6AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 |

View File

@@ -7,12 +7,27 @@ class Settings(BaseSettings):
"""应用配置.""" """应用配置."""
port: int = 3008 port: int = 3008
# LLM 配置(可选,为空时降级返回骨架响应)
openai_api_key: str = "" openai_api_key: str = ""
openai_base_url: str = "https://api.openai.com/v1"
anthropic_api_key: str = "" anthropic_api_key: str = ""
# 开发模式true 时跳过 OTel exporter 初始化,避免本地无 collector 时报错
dev_mode: str = "false"
# 可观测性
otel_endpoint: str = "http://localhost:4318" otel_endpoint: str = "http://localhost:4318"
log_level: str = "info" log_level: str = "info"
model_config = {"env_file": ".env", "env_prefix": ""} model_config = {"env_file": ".env", "env_prefix": ""}
@property
def is_dev(self) -> bool:
"""是否处于开发模式."""
return self.dev_mode.lower() == "true"
@property
def llm_available(self) -> bool:
"""LLM 是否可用(至少一个 provider 配置了 API key."""
return bool(self.openai_api_key or self.anthropic_api_key)
settings = Settings() settings = Settings()

View File

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

View File

@@ -1,9 +1,11 @@
"""AI 网关服务入口.""" """AI 网关服务入口."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any
import structlog import structlog
from fastapi import FastAPI from fastapi import APIRouter, FastAPI
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from opentelemetry import trace from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
@@ -12,25 +14,45 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor
from prometheus_client import make_asgi_app from prometheus_client import make_asgi_app
from pydantic import BaseModel from pydantic import BaseModel
from .config import settings
from .llm_client import chat_completion, chat_completion_stream
logger = structlog.get_logger() logger = structlog.get_logger()
tracer = trace.get_tracer(__name__) tracer = trace.get_tracer(__name__)
def init_tracer() -> None: def init_tracer() -> None:
"""初始化 OpenTelemetry.""" """初始化 OpenTelemetry.
endpoint 从 settings.otel_endpoint 读取dev_mode=true 时跳过 exporter
初始化,避免本地无 collector 时报错。
"""
if settings.is_dev:
logger.info("dev_mode_tracer_skipped", dev_mode=settings.dev_mode)
return
provider = TracerProvider() provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces") endpoint = f"{settings.otel_endpoint.rstrip('/')}/v1/traces"
exporter = OTLPSpanExporter(endpoint=endpoint)
provider.add_span_processor(BatchSpanProcessor(exporter)) provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider) trace.set_tracer_provider(provider)
logger.info("tracer_initialized", otel_endpoint=endpoint)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""应用生命周期.""" """应用生命周期."""
init_tracer() init_tracer()
logger.info("ai service starting") logger.info(
"ai_service_starting",
llm_available=settings.llm_available,
dev_mode=settings.is_dev,
openai_base_url=settings.openai_base_url,
)
if not settings.llm_available:
logger.warning("ai_service_llm_degraded_no_api_key")
yield yield
logger.info("ai service stopping") logger.info("ai_service_stopping")
app = FastAPI( app = FastAPI(
@@ -41,11 +63,14 @@ app = FastAPI(
app.mount("/metrics", make_asgi_app()) app.mount("/metrics", make_asgi_app())
# 业务路由加 /ai 前缀Gateway 代理 /api/v1/ai/* → /ai/*
router = APIRouter(prefix="/ai")
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
"""聊天请求.""" """聊天请求."""
messages: list[dict] messages: list[dict[str, Any]]
model: str = "gpt-4o-mini" model: str = "gpt-4o-mini"
temperature: float = 0.7 temperature: float = 0.7
stream: bool = False stream: bool = False
@@ -56,56 +81,157 @@ class ChatResponse(BaseModel):
content: str content: str
model: str model: str
usage: dict usage: dict[str, Any]
degraded: bool = False
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
@app.get("/healthz") @app.get("/healthz")
async def healthz(): async def healthz() -> dict[str, Any]:
"""健康检查.""" """健康检查liveness."""
return {"status": "ok", "service": "ai"} return {"status": "ok", "service": "ai"}
@app.post("/chat", response_model=ChatResponse) @app.get("/readyz")
async def chat(req: ChatRequest): async def readyz() -> dict[str, Any]:
"""LLM 聊天接口.""" """就绪检查readiness.
with tracer.start_as_current_span("ai_chat"):
# P5 骨架:实际调用 OpenAI/Anthropic API LLM 未配置时仍返回 200但标记 degraded=true调用方可据此判断是否路由流量。
# 需要从环境变量获取 API key """
llm_configured = settings.llm_available
return { return {
"content": "P5 skeleton - LLM integration pending", "status": "ok",
"model": req.model, "service": "ai",
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, "llm_configured": llm_configured,
"degraded": not llm_configured,
"openai_base_url": settings.openai_base_url,
} }
@app.post("/chat/stream") @router.post("/chat", response_model=ChatResponse)
async def chat_stream(req: ChatRequest): async def chat(req: ChatRequest) -> ChatResponse:
"""流式聊天SSE.""" """LLM 聊天接口(无 API key 时降级返回骨架响应."""
with tracer.start_as_current_span("ai_chat"):
result = await chat_completion(
messages=req.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,
)
async def generate():
@router.post("/chat/stream")
async def chat_stream(req: ChatRequest) -> StreamingResponse:
"""流式聊天SSE无 API key 时降级返回骨架 SSE."""
async def generate() -> AsyncGenerator[str, None]:
with tracer.start_as_current_span("ai_chat_stream"): with tracer.start_as_current_span("ai_chat_stream"):
# P5 骨架:流式调用 LLM async for chunk in chat_completion_stream(
yield "data: P5 skeleton\n\n" messages=req.messages,
yield "data: [DONE]\n\n" model=req.model,
temperature=req.temperature,
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
):
yield chunk
return StreamingResponse(generate(), media_type="text/event-stream") return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/generate/question") @router.post("/generate/question")
async def generate_question(prompt: str): async def generate_question(prompt: str) -> dict[str, Any]:
"""生成题目.""" """生成题目(无 API key 时降级返回骨架)."""
with tracer.start_as_current_span("generate_question"): 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": 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=prompt[:100])
return { return {
"success": True, "success": True,
"data": {"question": "P5 skeleton - question generation pending"}, "data": {"question": "[degraded] question generation skeleton"},
"degraded": True,
}
content, _, _ = _extract_content(result)
return {
"success": True,
"data": {"question": content},
"degraded": False,
} }
@app.post("/optimize/expression") @router.post("/optimize/expression")
async def optimize_expression(text: str): async def optimize_expression(text: str) -> dict[str, Any]:
"""优化表达.""" """优化表达(无 API key 时降级返回骨架)."""
with tracer.start_as_current_span("optimize_expression"): 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,
)
if result is None:
logger.warning("optimize_expression_degraded", text=text[:100])
return { return {
"success": True, "success": True,
"data": {"optimized": "P5 skeleton - expression optimization pending"}, "data": {"optimized": "[degraded] expression optimization skeleton"},
"degraded": True,
} }
content, _, _ = _extract_content(result)
return {
"success": True,
"data": {"optimized": content},
"degraded": False,
}
app.include_router(router)

View File

@@ -16,6 +16,8 @@ type Config struct {
CoreEduServiceURL string CoreEduServiceURL string
ContentServiceURL string ContentServiceURL string
DataAnaServiceURL string DataAnaServiceURL string
MsgServiceURL string
AiServiceURL string
OTLPEndpoint string OTLPEndpoint string
LogLevel string LogLevel string
DevMode bool DevMode bool
@@ -33,6 +35,8 @@ func Load() *Config {
CoreEduServiceURL: getEnv("CORE_EDU_SERVICE_URL", "http://localhost:3004"), CoreEduServiceURL: getEnv("CORE_EDU_SERVICE_URL", "http://localhost:3004"),
ContentServiceURL: getEnv("CONTENT_SERVICE_URL", "http://localhost:3005"), ContentServiceURL: getEnv("CONTENT_SERVICE_URL", "http://localhost:3005"),
DataAnaServiceURL: getEnv("DATA_ANA_SERVICE_URL", "http://localhost:3006"), DataAnaServiceURL: getEnv("DATA_ANA_SERVICE_URL", "http://localhost:3006"),
MsgServiceURL: getEnv("MSG_SERVICE_URL", "http://localhost:3007"),
AiServiceURL: getEnv("AI_SERVICE_URL", "http://localhost:3008"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"), LogLevel: getEnv("LOG_LEVEL", "info"),
DevMode: getEnvBool("DEV_MODE", false), DevMode: getEnvBool("DEV_MODE", false),

View File

@@ -109,6 +109,24 @@ func main() {
api.Any("/questions", contentHandler) api.Any("/questions", contentHandler)
api.Any("/questions/*path", contentHandler) api.Any("/questions/*path", contentHandler)
// msg 服务路由(通知/消息)
msgProxy, err := proxy.NewProxy(cfg.MsgServiceURL)
if err != nil {
log.Fatalf("failed to create msg proxy: %v", err)
}
msgHandler := proxy.ProxyHandler(msgProxy)
api.Any("/notifications", msgHandler)
api.Any("/notifications/*path", msgHandler)
// ai 服务路由AI 聊天/生成/优化)
aiProxy, err := proxy.NewProxy(cfg.AiServiceURL)
if err != nil {
log.Fatalf("failed to create ai proxy: %v", err)
}
aiHandler := proxy.ProxyHandler(aiProxy)
api.Any("/ai", aiHandler)
api.Any("/ai/*path", aiHandler)
// data-ana 服务路由(学情诊断/错题本) // data-ana 服务路由(学情诊断/错题本)
dataAnaProxy, err := proxy.NewProxy(cfg.DataAnaServiceURL) dataAnaProxy, err := proxy.NewProxy(cfg.DataAnaServiceURL)
if err != nil { if err != nil {