"""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`). - 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.main import async_session from app.engine.models import ConversationSource, ConversationStatus, Message from app.engine.services.conversation import ConversationManager 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 @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. """ # 服务端 XFF 优先于客户端自报 ip(客户端自报可伪造,可伪造面最小) xf = request.headers.get("x-forwarded-for", "").split(",")[0].strip() client_info = dict(req.client_info or {}) if xf: client_info["ip"] = xf async with async_session() as db: manager = ConversationManager(db) 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): """Public history endpoint for session replay. App-source only, to narrow the read surface; an unknown or non-app session yields 404. """ async with async_session() as db: manager = ConversationManager(db) 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 ip + X-Device-Id, 30/h) guards GLM spend; only this route is throttled. """ ip = request.client.host if request.client else "" dev = request.headers.get("X-Device-Id", "") if not rate_check(ip, dev): raise HTTPException(status_code=429, detail="rate limit exceeded") async with async_session() as db: manager = ConversationManager(db) 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, }