feat: jc 引擎主线搬入 app/engine(315 测试基线全绿:320 去掉 admin 5 用例)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 16:25:32 +08:00
parent 0ae5952002
commit 603c03eb4a
55 changed files with 10912 additions and 0 deletions
View File
+106
View File
@@ -0,0 +1,106 @@
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.main import async_session
from app.engine.models import ConversationSource, ConversationStatus
from app.engine.services.conversation import ConversationManager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.websocket("/ws/chat")
async def ws_chat(websocket: WebSocket):
await websocket.accept()
session_id = None
try:
async with async_session() as db:
manager = ConversationManager(db)
# Collect client info
client_ip = websocket.client.host if websocket.client else ""
forwarded = websocket.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded:
client_ip = forwarded
user_agent = websocket.headers.get("user-agent", "")
client_info = {"ip": client_ip, "user_agent": user_agent}
# First message: init or resume
init_data = await websocket.receive_text()
init_msg = json.loads(init_data)
if "session_id" in init_msg:
conv = await manager.get_by_session(init_msg["session_id"])
if conv and conv.status == ConversationStatus.active:
session_id = conv.session_id
await websocket.send_text(json.dumps({
"type": "resumed",
"session_id": session_id,
}))
else:
session_id = None
# page_token: reuse active conversation from same page session
if not session_id and "page_token" in init_msg and init_msg["page_token"]:
conv = await manager.get_by_page_token(init_msg["page_token"])
if conv:
session_id = conv.session_id
await websocket.send_text(json.dumps({
"type": "resumed",
"session_id": session_id,
}))
if not session_id:
source = ConversationSource(
init_msg.get("source", "standalone")
)
merged_info = {**client_info, **(init_msg.get("client_info") or {})}
# 服务端 XFF 优先于客户端自报 ip(客户端自报可伪造,可伪造面最小);
# 无 XFF 时保留客户端自报,user_agent 等其他字段不受影响
if forwarded:
merged_info["ip"] = forwarded
conv = await manager.create_conversation(
source=source,
client_info=merged_info,
page_token=init_msg.get("page_token"),
)
session_id = conv.session_id
await websocket.send_text(json.dumps({
"type": "init",
"session_id": session_id,
}))
# Chat loop
while True:
data = await websocket.receive_text()
msg = json.loads(data)
content = msg.get("content", "").strip()
if not content:
continue
ended = False
async for event in manager.stream_process_message(session_id, content):
if event["type"] == "error":
await websocket.send_text(json.dumps({
"type": "error",
"message": event["message"],
}))
break
await websocket.send_text(json.dumps(event, ensure_ascii=False))
if event["type"] == "evaluated" and event.get("ended"):
ended = True
break
if ended:
break
except WebSocketDisconnect:
logger.info("Client disconnected: %s", session_id)
except Exception:
logger.exception("WebSocket error for session %s", session_id)
+148
View File
@@ -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,
}