Files
ai-kefu-admin/app/engine/routes/chat_rest.py
T
浅念 d1aed8e722 feat: 吸收上游 7 个测试文件断言与 placeholder 配置化对齐
上游 ai-kefu → phase1 吸收(Task 11 unit 12):
- 拷入 7 个测试文件,import/夹具按目标库重锚(app.engine.* / conftest
  async_db+build_jiancai_runtime / seed_profiles 提示词锚点)
- 补实现(同步自上游): chat_rest 服务端可信 IP(_server_ip/
  _merge_client_info,直连场景自报假 IP 不再原样落库);widget.js 与
  chat.js 的 data-placeholder 读取(叠加 Task 10 管道:data-* > brand >
  中性兜底,tests/test_placeholder_static.py 守卫)
- 断言重锚以 jc 主线为准: ev.contact_info 存裸号+等待尾注(e164 走
  _cc 派生)、source 值 fallback/customer_stated 口径、推送标注键
  号码补全说明、prompt 静态锚按租户(seed_profiles)双测
- 全量回归 459 passed(基线 421 + 吸收 38)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 23:19:02 +08:00

182 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
@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", "")
engine_cfg = runtime.config.data.get("engine") or {}
# 类型与 0 语义由 Phase 2 配置校验统一(当前: 空/0 回退 DEFAULT_MAX)
limit = engine_cfg.get("rate_limit_per_hour") or DEFAULT_MAX
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,
}