"""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= 记入 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, }