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:
@@ -255,30 +255,35 @@
|
||||
### 2.7 messaging(TS/NestJS,P5)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| -------------- | -------------------------------------------------------------------- |
|
||||
| ------------- | ------------------------------------------------------------------ |
|
||||
| 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 |
|
||||
| 通知批量化 | `createNotifications(items)` 单次 INSERT,沿用旧项目 dispatcher 模式 |
|
||||
| 通知批量化 | `createBatch(items)` 单次 INSERT,沿用旧项目 dispatcher 模式 |
|
||||
| 多渠道 | 站内/SMS/邮件/微信,in_app 批量 + 其他渠道并行 |
|
||||
| fan-out 分页 | `getAllUserIds(limit=1000, offset)` 分页遍历 |
|
||||
| 撤回不乐观更新 | 需服务端返回判断 2 分钟窗口 |
|
||||
| fan-out 分页 | `listByUserWithPagination(userId, page, pageSize)` 分页查询 |
|
||||
| ES 降级 | ES_URL 未设置时 esClient=null,safeIndex/safeSearch 跳过返回空结果 |
|
||||
| Push 推送降级 | PUSH_GATEWAY_URL 未设置或连接失败时 try/catch 跳过,不影响 DB 写入 |
|
||||
| db 常量导出 | database.ts 导出 `db` 常量替代 `getDb()` 函数 |
|
||||
|
||||
### 2.8 push-gateway(Go,P5)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| ---------------- | ----------------------------------------------- |
|
||||
| WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需发 Kafka 消息 |
|
||||
| 跨实例同步 | Redis PubSub |
|
||||
| ---------------- | ---------------------------------------------------------------- |
|
||||
| WebSocket 长连接 | 单节点支撑 10w+ 连接,业务服务只需调 /internal/push |
|
||||
| 跨实例同步 | Redis PubSub(RedisURL 配置,预留 P6 实现) |
|
||||
| 离线消息 | 仅推在线用户,离线消息存 MySQL,上线时拉取 |
|
||||
| 并发写修复 | send chan + 单写协程模式,避免 gorilla/websocket 并发写竞争 |
|
||||
| DEV_MODE 鉴权 | DEV_MODE=true 时接受 dev-token,生产环境必须 JWT 校验 |
|
||||
| 广播端点 | POST /internal/broadcast,body {event, data},调用 hub.Broadcast |
|
||||
|
||||
### 2.9 ai-gateway(Python/FastAPI,P5)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| ----------------- | ------------------------------------------- |
|
||||
| LLM Provider 适配 | OpenAI/Anthropic,langchain/litellm 生态 |
|
||||
| Prompt 模板管理 | 版本管理友好 |
|
||||
| ----------------- | --------------------------------------------------------------- |
|
||||
| LLM Provider 适配 | OpenAI 兼容 REST API(httpx 异步),不引入 openai SDK |
|
||||
| 降级模式 | API key 为空或调用失败时返回骨架响应,标记 degraded: true |
|
||||
| 流式 SSE | AI 网关 → BFF → 前端三层透传,BFF 不缓冲 |
|
||||
| 用量计费 | 按 token 计费 |
|
||||
| AI 模块纯服务端 | Zod 验证 + 失败降级返回空(沿用旧项目模式) |
|
||||
| 路由前缀 | 业务路由加 /ai 前缀(APIRouter prefix="/ai"),Gateway 代理 /ai |
|
||||
| dev_mode tracer | dev_mode=true 时跳过 OTel exporter 初始化 |
|
||||
|
||||
### 2.10 shared-proto(契约包)
|
||||
|
||||
@@ -322,7 +327,8 @@
|
||||
> 按时间倒序,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_URL;elasticsearch.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.py(httpx 异步调 OpenAI REST API);main.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true)+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg,/ai → ai 服务。**学到**:gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入;FastAPI APIRouter prefix 与 Gateway 代理路径要协调(ai 服务加 /ai 前缀,Gateway 代理 /ai/*path);LLM 降级策略统一返回 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_MODE;neo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000;health/lifecycle 改用 Drizzle;global-error.filter 移除 @types/express 依赖;textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型;textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块:chapters(CRUD + 按 textbook 查询)、knowledge-points(CRUD + Neo4j 前置依赖图非阻塞查询)、questions(CRUD + 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 201(Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**:Drizzle schema TS 字段名与 DB 列名解耦(order→order_num),API 请求体用 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_MODE;kafka.ts connectKafka 加 try/catch 不阻塞启动;main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞;app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule;3 个 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.go;config.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 调 toISOString);Go 项目 .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 含 dataScope,register 自动分配 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> 避免 TS2769;ESLint 9 需 flat config 留 P6;AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 |
|
||||
|
||||
@@ -7,12 +7,27 @@ 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 时报错
|
||||
dev_mode: str = "false"
|
||||
# 可观测性
|
||||
otel_endpoint: str = "http://localhost:4318"
|
||||
log_level: str = "info"
|
||||
|
||||
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()
|
||||
|
||||
137
services/ai/src/ai/llm_client.py
Normal file
137
services/ai/src/ai/llm_client.py
Normal 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 兼容的响应 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,9 +1,11 @@
|
||||
"""AI 网关服务入口."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from opentelemetry import trace
|
||||
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 pydantic import BaseModel
|
||||
|
||||
from .config import settings
|
||||
from .llm_client import chat_completion, chat_completion_stream
|
||||
|
||||
logger = structlog.get_logger()
|
||||
tracer = trace.get_tracer(__name__)
|
||||
|
||||
|
||||
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()
|
||||
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))
|
||||
trace.set_tracer_provider(provider)
|
||||
logger.info("tracer_initialized", otel_endpoint=endpoint)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期."""
|
||||
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
|
||||
logger.info("ai service stopping")
|
||||
logger.info("ai_service_stopping")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -41,11 +63,14 @@ app = FastAPI(
|
||||
|
||||
app.mount("/metrics", make_asgi_app())
|
||||
|
||||
# 业务路由加 /ai 前缀,Gateway 代理 /api/v1/ai/* → /ai/*
|
||||
router = APIRouter(prefix="/ai")
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
"""聊天请求."""
|
||||
|
||||
messages: list[dict]
|
||||
messages: list[dict[str, Any]]
|
||||
model: str = "gpt-4o-mini"
|
||||
temperature: float = 0.7
|
||||
stream: bool = False
|
||||
@@ -56,56 +81,157 @@ class ChatResponse(BaseModel):
|
||||
|
||||
content: 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")
|
||||
async def healthz():
|
||||
"""健康检查."""
|
||||
async def healthz() -> dict[str, Any]:
|
||||
"""健康检查(liveness)."""
|
||||
return {"status": "ok", "service": "ai"}
|
||||
|
||||
|
||||
@app.post("/chat", response_model=ChatResponse)
|
||||
async def chat(req: ChatRequest):
|
||||
"""LLM 聊天接口."""
|
||||
with tracer.start_as_current_span("ai_chat"):
|
||||
# P5 骨架:实际调用 OpenAI/Anthropic API
|
||||
# 需要从环境变量获取 API key
|
||||
@app.get("/readyz")
|
||||
async def readyz() -> dict[str, Any]:
|
||||
"""就绪检查(readiness).
|
||||
|
||||
LLM 未配置时仍返回 200,但标记 degraded=true,调用方可据此判断是否路由流量。
|
||||
"""
|
||||
llm_configured = settings.llm_available
|
||||
return {
|
||||
"content": "P5 skeleton - LLM integration pending",
|
||||
"model": req.model,
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
"status": "ok",
|
||||
"service": "ai",
|
||||
"llm_configured": llm_configured,
|
||||
"degraded": not llm_configured,
|
||||
"openai_base_url": settings.openai_base_url,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/chat/stream")
|
||||
async def chat_stream(req: ChatRequest):
|
||||
"""流式聊天(SSE)."""
|
||||
@router.post("/chat", response_model=ChatResponse)
|
||||
async def chat(req: ChatRequest) -> ChatResponse:
|
||||
"""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"):
|
||||
# P5 骨架:流式调用 LLM
|
||||
yield "data: P5 skeleton\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
async for chunk in chat_completion_stream(
|
||||
messages=req.messages,
|
||||
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")
|
||||
|
||||
|
||||
@app.post("/generate/question")
|
||||
async def generate_question(prompt: str):
|
||||
"""生成题目."""
|
||||
@router.post("/generate/question")
|
||||
async def generate_question(prompt: str) -> dict[str, Any]:
|
||||
"""生成题目(无 API key 时降级返回骨架)."""
|
||||
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 {
|
||||
"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")
|
||||
async def optimize_expression(text: str):
|
||||
"""优化表达."""
|
||||
@router.post("/optimize/expression")
|
||||
async def optimize_expression(text: str) -> dict[str, Any]:
|
||||
"""优化表达(无 API key 时降级返回骨架)."""
|
||||
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 {
|
||||
"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)
|
||||
|
||||
@@ -16,6 +16,8 @@ type Config struct {
|
||||
CoreEduServiceURL string
|
||||
ContentServiceURL string
|
||||
DataAnaServiceURL string
|
||||
MsgServiceURL string
|
||||
AiServiceURL string
|
||||
OTLPEndpoint string
|
||||
LogLevel string
|
||||
DevMode bool
|
||||
@@ -33,6 +35,8 @@ func Load() *Config {
|
||||
CoreEduServiceURL: getEnv("CORE_EDU_SERVICE_URL", "http://localhost:3004"),
|
||||
ContentServiceURL: getEnv("CONTENT_SERVICE_URL", "http://localhost:3005"),
|
||||
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"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
DevMode: getEnvBool("DEV_MODE", false),
|
||||
|
||||
@@ -109,6 +109,24 @@ func main() {
|
||||
api.Any("/questions", 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 服务路由(学情诊断/错题本)
|
||||
dataAnaProxy, err := proxy.NewProxy(cfg.DataAnaServiceURL)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user