Files
ai-kefu-admin/app/engine/routes/chat_rest.py
T
浅念 71e11c317f refactor: config_views 单入口 + 0语义统一 + FakeAI 收敛(清理单#1#2#3)
- #3 六个消费视图 resolver 收拢 app/platform/config_views(单一定义点):
  resolve_email/push/ai_settings ← 各服务文件, resolve_contact_flags/
  context_seed_map ← conversation, name_to_region_extras ← manager
  __init__ 内联取值(or None 语义原样)——函数体逐字搬入,服务层 import 之,
  旧定义删;Phase 3 编辑器/试金石与该模块对接
- #2 限流 0 语义统一: conn_limiter.try_acquire 的 limit 与 chat_rest 的
  rate_limit_per_hour 取值同款——0 或负 = 回退出厂默认(与姊妹键一致),
  原 conn_limiter 0=全拒改为回退;chat_rest 取值抽 _resolve_rate_limit
  纯函数供锚定
- #1 FakeAI 收敛进 conftest(超集: extract/evaluate/eval_calls),四份拷贝
  删(test_lead_email_trigger/test_cc_integration/test_channel_hygiene/
  test_lead_push_wiring——计划记三份,grep 实有四份;push_wiring 经
  evaluate 参数注入原本地固定返回值,断言零改动)

全量 558 passed(554 + 新增 4: 单入口/内联等价/两处 0 语义)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:45:42 +08:00

188 lines
7.6 KiB
Python

"""REST shell for the HESUPIPE app (non-streaming, JSON-in/JSON-out).
These routes wrap `ConversationManager`, shared with the existing `/ws/chat`
widget path. The app uses HTTP because it lacks a WebSocket client and is fine
with a 3-8s "AI is typing" affordance covering the round trip.
Red lines (see spec):
- WS path (`app/api/routes/chat.py`) shares `ConversationManager` with these
routes; both paths stay drift-free.
- `context` seeding is conditional + whitelist (handled in
`ConversationManager.create_conversation`; Task 9 起白名单 =
租户 config.engine.context_seed_keys——jiancai product_*/auto vehicle_*).
- The message route consumes `stream_process_message`; lead email is
triggered inside `ConversationManager` when contact info is detected
(contact-driven-lead-email plan).
"""
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from app.engine.models import ConversationSource, ConversationStatus, Message
from app.engine.routes.runtime import get_runtime
from app.engine.services.conversation import ConversationManager
from app.engine.services.rate_limit import DEFAULT_MAX
from app.engine.services.rate_limit import check as rate_check
router = APIRouter(prefix="/api/chat", tags=["chat-rest"])
class SessionRequest(BaseModel):
session_id: str | None = None
context: dict | None = None
client_info: dict | None = None
def _server_ip(request: Request) -> str:
"""服务端可信 IP:XFF 第一段优先,否则直连 host;全空返回空串。
同步自上游 ai-kefu(app/api/routes/chat_rest.py):REST 路径原先只在有
XFF 时覆盖自报 ip,直连(无代理)场景自报假 IP 原样落库。"""
xff = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
return xff or (request.client.host if request.client else "")
def _merge_client_info(client_info: dict | None, server_ip: str) -> dict:
"""服务端 IP 覆盖自报 ip 键;服务端为空时保留自报(空串不得冲掉客户端值)。"""
merged = dict(client_info or {})
if server_ip:
merged["ip"] = server_ip
return merged
@router.post("/session")
async def chat_session(req: SessionRequest, request: Request):
"""Resume an active conversation by session_id, or create a new app one.
`context` flows into `create_conversation` where it is whitelisted into
`raw_info` (only when non-None). `client_info` is stored for analytics only
(ip/ua/device-id/lang) and never fed to the AI prompt.
"""
# 服务端可信 IP 同步自上游 ai-kefu:XFF 第一段优先,无 XFF 用直连 host
# 覆盖客户端自报(可伪造面最小);全空保留自报。?site= 处理不变。
client_info = _merge_client_info(req.client_info, _server_ip(request))
# Task 10: ?site=<slug> 记入 client_info 作会话站点标(与 WS init 的
# site 字段同语义)——只加记录,不参与业务逻辑
site_slug = request.query_params.get("site", "").strip()
if site_slug:
client_info["site"] = site_slug
# Task 5b: 租户运行时经 state/默认租户兜底解析(过渡期无中间件);
# manager 由装配线从 runtime 造(db 会话 + runtime 引用)
runtime = get_runtime(request)
async with runtime.session_factory() as db:
manager = ConversationManager(db, runtime=runtime)
if req.session_id:
conv = await manager.get_by_session(req.session_id)
if conv and conv.status == ConversationStatus.active:
return {
"session_id": conv.session_id,
"is_new": False,
"ended": False,
}
conv = await manager.create_conversation(
source=ConversationSource.app,
client_info=client_info,
context=req.context,
)
return {
"session_id": conv.session_id,
"is_new": True,
"ended": False,
}
@router.get("/{session_id}/messages")
async def chat_history(session_id: str, request: Request):
"""Public history endpoint for session replay. App-source only, to narrow
the read surface; an unknown or non-app session yields 404.
"""
runtime = get_runtime(request)
async with runtime.session_factory() as db:
manager = ConversationManager(db, runtime=runtime)
conv = await manager.get_by_session(session_id)
if not conv or conv.source != ConversationSource.app:
raise HTTPException(status_code=404, detail="session not found")
result = await db.execute(
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
msgs = result.scalars().all()
return [
{
"role": m.role.value,
"content": m.content,
"created_at": m.created_at.isoformat(),
}
for m in msgs
]
class MessageRequest(BaseModel):
session_id: str
content: str
def _resolve_rate_limit(config_data: dict | None) -> int:
"""engine.rate_limit_per_hour 取值(Task 10): 0 或负 = 回退出厂默认
(与姊妹键 ws_conn_limit 一致)——config_validation 已拒 0/负,此处兜
直写快照/旧库数据。"""
v = ((config_data or {}).get("engine") or {}).get("rate_limit_per_hour")
return v if v and v > 0 else DEFAULT_MAX
@router.post("/message")
async def chat_message(req: MessageRequest, request: Request):
"""Process one app turn: stream-collect the AI reply, then evaluate.
🔴 Consumes `stream_process_message` so the REST path shares one code
path with the WS widget and never drifts. Lead email is triggered inside
`ConversationManager` when contact info is detected
(contact-driven-lead-email plan) — this route carries no email logic.
A light in-memory rate limit (per tenant + ip + X-Device-Id, rolling 1h)
guards GLM spend; only this route is throttled. Task 9: 桶键加租户维度
(两租户同出口 IP 不互挤配额),上限读租户 config.engine.rate_limit_per_hour
(默认 30,与出厂一致)。
"""
runtime = get_runtime(request)
ip = request.client.host if request.client else ""
dev = request.headers.get("X-Device-Id", "")
limit = _resolve_rate_limit(runtime.config.data)
if not rate_check(runtime.config.slug, ip, dev, max_requests=limit):
raise HTTPException(status_code=429, detail="rate limit exceeded")
async with runtime.session_factory() as db:
manager = ConversationManager(db, runtime=runtime)
conv = await manager.get_by_session(req.session_id)
if not conv:
raise HTTPException(status_code=404, detail="session not found")
if conv.status != ConversationStatus.active:
raise HTTPException(status_code=409, detail="conversation ended")
reply_parts: list[str] = []
ended = False
intent_level = "pending"
async for event in manager.stream_process_message(
req.session_id, req.content
):
t = event.get("type")
if t == "stream_chunk":
reply_parts.append(event.get("content", ""))
elif t == "evaluated":
ended = bool(event.get("ended"))
intent_level = event.get("intent_level", "pending")
elif t == "error":
msg = event.get("message", "error")
raise HTTPException(
status_code=409 if "ended" in msg else 404, detail=msg
)
return {
"reply": "".join(reply_parts),
"ended": ended,
"intent_level": intent_level,
}