Add Cloud Tour Libo knowledge graph platform

This commit is contained in:
2026-07-30 10:08:04 +08:00
parent 9e05b09a38
commit bae5197d62
103 changed files with 14036 additions and 160 deletions

343
app/api/mcp_server.py Normal file
View File

@@ -0,0 +1,343 @@
"""Minimal MCP Streamable HTTP adapter for Baixinghui customer-service tools."""
from __future__ import annotations
import json
import os
import time
import uuid
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from app.api.travel_assistant import _customer_service_query_response
router = APIRouter()
MCP_PROTOCOL_VERSION = "2025-06-18"
SUPPORTED_MCP_PROTOCOL_VERSIONS = {"2024-11-05", "2025-03-26", MCP_PROTOCOL_VERSION}
DEFAULT_GRAPH_NAME = "baixinghui_travel_agency"
def _tool_definitions() -> list[dict[str, Any]]:
return [
{
"name": "ask_customer_service",
"description": (
"向百姓惠旅行社知识图谱智能客服提问。适用于线路价格、行程天数、景区、酒店、费用、车型、"
"线路推荐和复杂多跳问答。只读工具,不承诺最终价格、余位、房型或景区政策。"
),
"inputSchema": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "用户自然语言问题"},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
"request_id": {"type": "string", "description": "外部请求 ID可选"},
"customer_context": {
"type": "object",
"description": "客户上下文,例如人数、预算、出发日期、偏好,可选",
},
},
"required": ["question"],
},
},
{
"name": "query_route_price",
"description": "查询指定旅行线路的参考价格、成人/儿童价格区间和不可直接承诺事项。只读工具。",
"inputSchema": {
"type": "object",
"properties": {
"route_name": {"type": "string", "description": "线路名称或关键词,例如 黄小西三日游"},
"traveler_type": {
"type": "string",
"description": "游客类型,可选,例如 成人、儿童、老人",
},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
},
"required": ["route_name"],
},
},
{
"name": "list_travel_routes",
"description": "查询百姓惠旅行线路清单,可按天数、景区或关键词筛选。只读工具。",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "线路或目的地关键词,可选"},
"duration_days": {"type": "integer", "description": "期望天数,可选"},
"limit": {"type": "integer", "description": "返回条数上限,可选,默认 20"},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
},
},
},
{
"name": "query_route_detail",
"description": "查询某条线路的天数、价格、景区、附近酒店和费用边界。只读工具。",
"inputSchema": {
"type": "object",
"properties": {
"route_name": {"type": "string", "description": "线路名称或关键词"},
"detail_focus": {
"type": "string",
"description": "关注点,可选,例如 价格、景区、酒店、费用、车型",
},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
},
"required": ["route_name"],
},
},
{
"name": "compare_routes",
"description": "对比两条旅行线路的适配度、景点数量、轻松程度或客服推荐理由。只读工具。",
"inputSchema": {
"type": "object",
"properties": {
"route_a": {"type": "string", "description": "第一条线路名称或关键词"},
"route_b": {"type": "string", "description": "第二条线路名称或关键词"},
"compare_focus": {
"type": "string",
"description": "对比关注点,例如 老人小孩、不太累、景点更多、价格",
},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
},
"required": ["route_a", "route_b"],
},
},
]
def _json_rpc_result(message_id: Any, result: dict[str, Any]) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": message_id, "result": result}
def _json_rpc_error(message_id: Any, code: int, message: str, data: Any = None) -> dict[str, Any]:
error: dict[str, Any] = {"code": code, "message": message}
if data is not None:
error["data"] = data
return {"jsonrpc": "2.0", "id": message_id, "error": error}
def _mcp_auth_enabled() -> bool:
return bool(os.getenv("BXH_MCP_TOKEN", "").strip())
def _is_authorized(request: Request) -> bool:
expected = os.getenv("BXH_MCP_TOKEN", "").strip()
if not expected:
return True
auth = request.headers.get("authorization", "").strip()
api_key = request.headers.get("x-mcp-api-key", "").strip()
bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else auth
return expected in {bearer, api_key}
def _normalize_args(params: dict[str, Any]) -> tuple[str, dict[str, Any]]:
name = str(params.get("name") or "").strip()
args = params.get("arguments")
if args is None:
args = {}
if not isinstance(args, dict):
raise ValueError("arguments must be an object")
return name, args
def _question_for_tool(name: str, args: dict[str, Any]) -> str:
if name == "ask_customer_service":
return str(args.get("question") or "").strip()
if name == "query_route_price":
route = str(args.get("route_name") or "").strip()
traveler = str(args.get("traveler_type") or "").strip()
suffix = f"{traveler}怎么收费" if traveler else ""
return f"{route}多少钱{suffix}".strip()
if name == "list_travel_routes":
keyword = str(args.get("keyword") or "").strip()
days = args.get("duration_days")
pieces = ["旅行车线路有哪些"]
if keyword:
pieces.append(f"{keyword}相关")
if days:
pieces.append(f"{days}")
return "".join(pieces) + ""
if name == "query_route_detail":
route = str(args.get("route_name") or "").strip()
focus = str(args.get("detail_focus") or "").strip()
if focus:
return f"{route}{focus}是什么?"
return f"{route}多少钱,可以玩几天,期间可以去哪些景区,附近可以入住什么酒店?"
if name == "compare_routes":
route_a = str(args.get("route_a") or "").strip()
route_b = str(args.get("route_b") or "").strip()
focus = str(args.get("compare_focus") or "哪个更适合客户").strip()
return f"{route_a}{route_b}{focus},为什么?"
raise KeyError(name)
def _mcp_content_text(payload: dict[str, Any]) -> str:
reply = str(payload.get("customer_reply") or payload.get("answer") or "").strip()
if reply:
return reply
return "当前知识图谱没有返回可直接回复的话术,请补充线路、人数、天数或出行日期后再试。"
def _mcp_structured_data(payload: dict[str, Any]) -> dict[str, Any]:
knowledge = payload.get("knowledge") if isinstance(payload.get("knowledge"), dict) else {}
return {
"status": payload.get("status") or "ok",
"service": payload.get("service") or "baixinghui_customer_service",
"request_id": payload.get("request_id"),
"trace_id": payload.get("trace_id"),
"session_id": payload.get("session_id"),
"graph_name": payload.get("graph_name"),
"question": payload.get("question"),
"customer_reply": payload.get("customer_reply"),
"answer": payload.get("answer"),
"confidence": payload.get("confidence"),
"follow_up_questions": payload.get("follow_up_questions") or [],
"risk_notes": payload.get("risk_notes") or [],
"knowledge": {
"plans": knowledge.get("plans") or [],
"evidence": knowledge.get("evidence") or [],
},
"routing": payload.get("routing") or {},
"trace": payload.get("trace") or {},
}
def _negotiate_protocol_version(params: dict[str, Any]) -> str:
requested = str(params.get("protocolVersion") or "").strip()
if requested in SUPPORTED_MCP_PROTOCOL_VERSIONS:
return requested
return MCP_PROTOCOL_VERSION
async def _call_customer_service_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
question = _question_for_tool(name, args)
if not question:
raise ValueError("question or route fields are required")
body = {
"request_id": args.get("request_id") or f"mcp-{uuid.uuid4().hex[:16]}",
"session_id": args.get("session_id") or "",
"channel": args.get("channel") or "mcp",
"graph_name": args.get("graph_name") or DEFAULT_GRAPH_NAME,
"question": question,
"customer_context": args.get("customer_context") if isinstance(args.get("customer_context"), dict) else {},
"limit": int(args.get("limit") or 80),
}
started = time.perf_counter()
payload = await _customer_service_query_response(body)
structured = _mcp_structured_data(payload)
structured["tool_name"] = name
structured["tool_latency_ms"] = max(1, round((time.perf_counter() - started) * 1000))
return {
"content": [{"type": "text", "text": _mcp_content_text(payload)}],
"structuredContent": structured,
"isError": False,
}
async def _handle_request(message: dict[str, Any]) -> dict[str, Any] | None:
message_id = message.get("id")
method = str(message.get("method") or "")
params = message.get("params") if isinstance(message.get("params"), dict) else {}
if not method:
return _json_rpc_error(message_id, -32600, "Invalid Request")
# JSON-RPC notifications have no id and do not require a response.
if "id" not in message:
return None
if method == "initialize":
return _json_rpc_result(
message_id,
{
"protocolVersion": _negotiate_protocol_version(params),
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {
"name": "baixinghui-customer-service-mcp",
"version": "0.1.0",
},
"instructions": "使用百姓惠旅行社知识图谱工具回答线路、价格、景区、酒店和费用问题。",
},
)
if method == "ping":
return _json_rpc_result(message_id, {})
if method == "tools/list":
return _json_rpc_result(message_id, {"tools": _tool_definitions()})
if method == "tools/call":
try:
name, args = _normalize_args(params)
tool_names = {tool["name"] for tool in _tool_definitions()}
if name not in tool_names:
return _json_rpc_error(message_id, -32602, f"Unknown tool: {name}")
result = await _call_customer_service_tool(name, args)
return _json_rpc_result(message_id, result)
except ValueError as exc:
return _json_rpc_error(message_id, -32602, str(exc))
except Exception as exc: # noqa: BLE001
return _json_rpc_error(message_id, -32603, f"Tool execution failed: {str(exc)[:300]}")
if method in {"resources/list", "prompts/list"}:
key = "resources" if method == "resources/list" else "prompts"
return _json_rpc_result(message_id, {key: []})
return _json_rpc_error(message_id, -32601, f"Method not found: {method}")
@router.get("/mcp")
async def mcp_get(request: Request):
if not _is_authorized(request):
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
accept = request.headers.get("accept", "")
if "text/event-stream" in accept:
async def stream():
yield ": baixinghui customer-service MCP endpoint is ready\n\n"
return StreamingResponse(stream(), media_type="text/event-stream")
return {
"name": "baixinghui-customer-service-mcp",
"protocolVersion": MCP_PROTOCOL_VERSION,
"auth": "bearer" if _mcp_auth_enabled() else "disabled",
"tools": [tool["name"] for tool in _tool_definitions()],
}
@router.post("/mcp")
async def mcp_post(request: Request):
if not _is_authorized(request):
return JSONResponse(
_json_rpc_error(None, -32001, "Unauthorized"),
status_code=401,
media_type="application/json",
)
try:
payload = await request.json()
except Exception:
return JSONResponse(
_json_rpc_error(None, -32700, "Parse error"),
status_code=400,
media_type="application/json",
)
if isinstance(payload, list):
responses = []
for message in payload:
if not isinstance(message, dict):
responses.append(_json_rpc_error(None, -32600, "Invalid Request"))
continue
response = await _handle_request(message)
if response is not None:
responses.append(response)
if not responses:
return Response(status_code=202)
return JSONResponse(responses, media_type="application/json")
if not isinstance(payload, dict):
return JSONResponse(
_json_rpc_error(None, -32600, "Invalid Request"),
status_code=400,
media_type="application/json",
)
response = await _handle_request(payload)
if response is None:
return Response(status_code=202)
return JSONResponse(response, media_type="application/json")