feat: jc 引擎主线搬入 app/engine(315 测试基线全绿:320 去掉 admin 5 用例)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user