From e5890733118dc329f0e7f3e7251051a3c26648e6 Mon Sep 17 00:00:00 2001 From: 3452078359-xuexue <3452078359@qq.com> Date: Wed, 10 Jun 2026 10:48:26 +0800 Subject: [PATCH] Add customer service API deployment support --- .env.example | 8 +- Dockerfile | 17 +- .../src/panels/plaza/UserExperiencePanel.tsx | 195 ++++++++-- app/api/travel_assistant.py | 350 +++++++++++++++++- docker-compose.yml | 54 +-- docs/API_REFERENCE.md | 48 +++ docs/DEPLOYMENT.md | 96 +++++ scripts/server_rootless_docker.sh | 71 ++++ 8 files changed, 777 insertions(+), 62 deletions(-) create mode 100755 scripts/server_rootless_docker.sh diff --git a/.env.example b/.env.example index 9b2fd75..4b2c63a 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,14 @@ # Database -DATABASE_URL=postgresql://admin:password@localhost:5433/kg_admin +POSTGRES_USER=admin +POSTGRES_PASSWORD=password +POSTGRES_DB=kg_admin +DATABASE_URL=postgresql://admin:password@postgres:5432/kg_admin DB_SCHEMA=kg_admin_new2 DB_MIGRATIONS_ENABLED=false # FalkorDB -FALKORDB_HOST=localhost +FALKORDB_HOST=falkordb +FALKORDB_INTERNAL_PORT=6379 FALKORDB_PORT=6380 FALKORDB_GRAPH=guiyang_new2 FALKORDB_PASSWORD= diff --git a/Dockerfile b/Dockerfile index 9852be9..e5ec480 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,8 @@ FROM node:22-bookworm-slim AS admin-web-build WORKDIR /build/admin-web COPY admin-web/package.json admin-web/package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm npm ci +RUN --mount=type=cache,target=/root/.npm \ + npm ci COPY admin-web/ ./ RUN npm run build @@ -19,12 +20,22 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -RUN apt-get update \ +ARG APT_MIRROR=https://mirrors.aliyun.com/debian +ARG APT_SECURITY_MIRROR=https://mirrors.aliyun.com/debian-security +ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple + +RUN set -eux; \ + if [ -f /etc/apt/sources.list.d/debian.sources ]; then \ + sed -i "s|http://deb.debian.org/debian-security|${APT_SECURITY_MIRROR}|g; s|http://deb.debian.org/debian|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources; \ + elif [ -f /etc/apt/sources.list ]; then \ + sed -i "s|http://deb.debian.org/debian-security|${APT_SECURITY_MIRROR}|g; s|http://deb.debian.org/debian|${APT_MIRROR}|g" /etc/apt/sources.list; \ + fi; \ + apt-get update \ && apt-get install -y --no-install-recommends libpq5 curl \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -i "${PIP_INDEX_URL}" -r requirements.txt COPY app ./app COPY schema搭建 ./schema搭建 diff --git a/admin-web/src/panels/plaza/UserExperiencePanel.tsx b/admin-web/src/panels/plaza/UserExperiencePanel.tsx index d6717bd..7e9ff40 100644 --- a/admin-web/src/panels/plaza/UserExperiencePanel.tsx +++ b/admin-web/src/panels/plaza/UserExperiencePanel.tsx @@ -8,7 +8,7 @@ import { ExpandOutlined, HeartOutlined, PhoneOutlined, SearchOutlined, SendOutlined, ShareAltOutlined, ShrinkOutlined, } from "@ant-design/icons"; -import { plazaAmapConfig, plazaUserQuery } from "../../api"; +import { displayGraphName, getProjectContext, plazaAmapConfig, plazaUserQuery, travelAssistantQuery } from "../../api"; const { Text } = Typography; @@ -69,6 +69,7 @@ type QueryResult = { }; type QueryPayload = { + mode?: "spatial" | "travel_customer_service"; question: string; graph_name: string; user_location: { lng: number; lat: number }; @@ -76,6 +77,9 @@ type QueryPayload = { answer: string; results: QueryResult[]; trace: Record; + plans?: Array>; + follow_up_questions?: string[]; + risk_notes?: string[]; }; const samples = [ @@ -86,6 +90,14 @@ const samples = [ "附近有没有公交地铁站?", ]; +const travelSamples = [ + "黄小西三日游多少钱?", + "2天1晚想去黄果树,有哪些线路,顺便看附近酒店和可选车辆价格", + "黄果树附近有哪些酒店餐饮,价格和余位能直接承诺吗?", + "客户临时多加2人,原来5人用车还能坐吗?", + "小七孔有哪些必付和可选费用,老人小孩有没有需要注意的?", +]; + const formatDistance = (m: number) => { if (m >= 1000) return `${(m / 1000).toFixed(1)}km`; return `${Math.round(m)}m`; @@ -121,6 +133,7 @@ export default function UserExperiencePanel() { const [fullscreen, setFullscreen] = useState(false); const [photoIndex, setPhotoIndex] = useState(0); const [failedPhotos, setFailedPhotos] = useState([]); + const [projectContext, setProjectContextState] = useState(() => getProjectContext()); const mapRef = useRef(null); const mapInstance = useRef(null); @@ -129,6 +142,25 @@ export default function UserExperiencePanel() { const results = payload?.results || []; const intent = payload?.intent; + const graphKey = String(projectContext.graphName || "").toLowerCase(); + const isTravelCustomerService = graphKey.includes("baixinghui") || String(projectContext.graphName || "").includes("百姓惠"); + const isTravelPayload = payload?.mode === "travel_customer_service"; + + useEffect(() => { + const updateContext = (event: Event) => { + const detail = (event as CustomEvent).detail; + setProjectContextState(detail || getProjectContext()); + }; + window.addEventListener("znkg-project-context", updateContext); + return () => window.removeEventListener("znkg-project-context", updateContext); + }, []); + + useEffect(() => { + const nextSamples = isTravelCustomerService ? travelSamples : samples; + setQuestion((current) => ([...samples, ...travelSamples].includes(current) ? nextSamples[0] : current)); + setPayload(null); + setActiveId(""); + }, [isTravelCustomerService]); useEffect(() => { let disposed = false; @@ -305,6 +337,40 @@ export default function UserExperiencePanel() { setQuestion(text); setLoading(true); try { + if (isTravelCustomerService) { + const { data } = await travelAssistantQuery({ + question: text, + graph_name: projectContext.graphName, + use_llm: true, + }); + const trace = data?.trace || {}; + const warnings = Array.isArray(trace.quality_checks) + ? trace.quality_checks + .filter((item: any) => item?.status === "warn" || item?.status === "fail") + .map((item: any) => item?.detail || item?.label) + .filter(Boolean) + : []; + setPayload({ + mode: "travel_customer_service", + question: text, + graph_name: data?.graph_name || projectContext.graphName, + user_location: { lng, lat }, + intent: { + radius_m: radius, + category: trace.response_mode_label || "百姓惠客服", + keywords: data?.intent?.destinations || [], + user_need: text, + }, + answer: data?.copy_text || "当前知识图谱没有返回可直接回复的话术,请补充客户出行人数、天数、团期和必去景点后再试。", + results: [], + trace, + plans: data?.plans || [], + follow_up_questions: data?.follow_up_questions || [], + risk_notes: warnings, + }); + setActiveId(""); + return; + } const { data } = await plazaUserQuery({ question: text, lng, @@ -330,35 +396,50 @@ export default function UserExperiencePanel() { onChange={(e) => setQuestion(e.target.value)} onSearch={() => runQuery()} loading={loading} - placeholder="搜索地点、公交、地铁、美食..." + placeholder={isTravelCustomerService ? "输入客户原话,例如:黄小西三日游多少钱?" : "搜索地点、公交、地铁、美食..."} />
-
- 游客坐标 - -
-
- - - -
+ {isTravelCustomerService ? ( +
+ 百姓惠客服问答 + 当前图谱:{displayGraphName(projectContext.graphName)},输入客户自然语言后返回图谱召回话术、证据和追问。 +
+ ) : ( + <> +
+ 游客坐标 + +
+
+ + + +
+ + )}
查询结果 - {payload ? {results.length} 个结果 · {intent?.category || "综合"} · {intent?.radius_m || radius}m : null} + {payload ? ( + + {isTravelPayload + ? `${displayGraphName(payload.graph_name)} · ${intent?.category || "客服问答"} · 方案 ${(payload.plans || []).length}` + : `${results.length} 个结果 · ${intent?.category || "综合"} · ${intent?.radius_m || radius}m`} + + ) : null}
@@ -370,7 +451,41 @@ export default function UserExperiencePanel() { <>
{payload.answer}
{results.length === 0 ? ( - + isTravelPayload ? ( +
+ {(payload.plans || []).slice(0, 4).map((plan, idx) => ( +
+ + {idx + 1} + {plan.plan_name || "未命名线路"} + {plan.duration_days && {plan.duration_days}天} + {plan.fit_score && 匹配 {plan.fit_score}} + +

{plan.route_summary || plan.quote_summary || plan.variant_summary || "图谱已命中该线路,更多细节请继续追问。"}

+
+ ))} + {!!payload.follow_up_questions?.length && ( +
+ 建议追问 + + {payload.follow_up_questions.slice(0, 4).map((item) => ( + runQuery(item)}>{item} + ))} + +
+ )} + {!!payload.risk_notes?.length && ( + + )} +
+ ) : ( + + ) ) : results.map((r, idx) => { const photo = firstPhoto(r); return ( @@ -587,6 +702,12 @@ export default function UserExperiencePanel() { .kg-user-control-grid .ant-input-number { width: 100%; } + .kg-user-travel-mode { + display: flex; + flex-direction: column; + gap: 8px; + line-height: 1.6; + } .kg-user-result-head { display: flex; align-items: center; @@ -614,6 +735,32 @@ export default function UserExperiencePanel() { font-size: 13px; line-height: 1.65; } + .kg-user-travel-summary { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + background: #fff; + } + .kg-user-travel-plan { + padding: 12px; + border: 1px solid #edf1f7; + border-radius: 8px; + background: #fbfdff; + } + .kg-user-travel-plan p { + margin: 8px 0 0; + color: #4e5969; + line-height: 1.65; + } + .kg-user-travel-followups { + display: flex; + flex-direction: column; + gap: 8px; + } + .kg-user-travel-followups .ant-tag { + cursor: pointer; + } .kg-user-result-card { display: grid; grid-template-columns: 1fr 96px; diff --git a/app/api/travel_assistant.py b/app/api/travel_assistant.py index 3c6e05e..977a161 100644 --- a/app/api/travel_assistant.py +++ b/app/api/travel_assistant.py @@ -9,7 +9,7 @@ import time from typing import Any from falkordb import FalkorDB -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from app.auth import CurrentUser from app.config import settings @@ -22,6 +22,7 @@ router = APIRouter() ENTERPRISE_AGENT_TARGET_MS = 1200 ENTERPRISE_AGENT_WARN_MS = 2500 GRAPH_CACHE_TTL_SECONDS = 120 +BAIXINGHUI_GRAPH_NAME = "baixinghui_travel_agency" _GRAPH_DATA_CACHE: dict[tuple[str, str], tuple[float, Any]] = {} ATTRACTION_ALIASES: dict[str, list[str]] = { @@ -230,6 +231,54 @@ def _cached_fee_bindings(graph_name: str) -> list[dict[str, Any]]: return _cached_graph_data(graph_name, "fee_bindings", _load_fee_bindings) +def _configured_api_keys() -> set[str]: + raw = str(getattr(settings, "ingest_api_keys", "") or "") + return {item.strip() for item in re.split(r"[,;\s]+", raw) if item.strip()} + + +def _bearer_token(value: str | None) -> str: + text = (value or "").strip() + if text.lower().startswith("bearer "): + return text[7:].strip() + return "" + + +async def _require_customer_service_api_key( + x_kg_api_key: str | None = Header(default=None, alias="X-KG-API-Key"), + x_api_key: str | None = Header(default=None, alias="X-API-Key"), + authorization: str | None = Header(default=None, alias="Authorization"), +) -> str: + keys = _configured_api_keys() + if not keys: + return "api-key-disabled" + candidate = (x_kg_api_key or x_api_key or _bearer_token(authorization)).strip() + if candidate and candidate in keys: + return candidate + raise HTTPException(status_code=401, detail="缺少或无效的客服接口 API Key") + + +def _normalize_customer_service_graph(value: Any) -> str: + text = str(value or "").strip() + if not text: + return BAIXINGHUI_GRAPH_NAME + lower = text.lower() + if lower in {"bxh", "baixinghui", "baixinghui_travel", "baixinghui_travel_agency"}: + return BAIXINGHUI_GRAPH_NAME + if "百姓惠" in text or "baixinghui" in lower: + return BAIXINGHUI_GRAPH_NAME + return text + + +def _external_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + return str(value).strip().lower() in {"1", "true", "yes", "y", "on", "启用", "开启"} + + def _extract_intent(question: str) -> dict[str, Any]: q = question.strip() party_size = None @@ -552,7 +601,7 @@ def _normalize_intent(question: str, fallback: dict[str, Any], data: dict[str, A return _complete_intent_defaults(question, merged) -async def _travel_intent_client() -> LlmClient | None: +async def _travel_llm_client(max_tokens: int = 1000) -> LlmClient | None: cfg = await get_agent_settings() global_cfg = cfg.get("global") or {} if global_cfg.get("base_url") and global_cfg.get("api_key"): @@ -561,7 +610,7 @@ async def _travel_intent_client() -> LlmClient | None: global_cfg["api_key"], global_cfg.get("model") or "deepseek-chat", timeout=int(global_cfg.get("timeout") or 45), - max_tokens=1000, + max_tokens=max_tokens, ) extract = cfg.get("extract") or {} @@ -575,11 +624,15 @@ async def _travel_intent_client() -> LlmClient | None: model_cfg["api_key"], model_cfg.get("model") or "deepseek-chat", timeout=int(extract.get("timeout") or 60), - max_tokens=1000, + max_tokens=max_tokens, ) return None +async def _travel_intent_client() -> LlmClient | None: + return await _travel_llm_client(max_tokens=1000) + + TRAVEL_INTENT_SYS = """你是旅行社客服行程推荐的自然语言需求解析器。只输出 JSON。 从客户原话中提取用于知识图谱检索的旅行需求,不要生成行程。 字段: @@ -615,6 +668,204 @@ async def _llm_intent(question: str, fallback: dict[str, Any], enabled: bool) -> return fallback, "llm_failed_rule_fallback" +CUSTOMER_SERVICE_FUSION_SYS = """你是百姓惠旅行社智能客服问答融合器。只输出 JSON。 +你的任务是把知识图谱召回结果整理成可以直接回复客户的话术。 +规则: +1. 只能使用 knowledge_base 中的线路、费用、酒店、餐饮、车辆、证据和 trace,不得编造未出现的价格、余位、房型、政策。 +2. 如价格、余位、车辆、酒店房型、餐标、门票、小交通或天气政策不确定,必须说明需要按团期/供应商/景区政策二次核实。 +3. 客户问“多少钱/报价/费用”时,优先说明图谱里的报价口径和计算边界;没有明确数字时不要补数字。 +4. 客户问线路推荐时,优先给 1-3 个命中的线路或方案,并说明适合点。 +5. 客户问资源替换或附近酒店餐饮时,说明是候选资源,不代表已锁定。 +6. 语言要像真实客服,简洁、自然、可复制。 +输出字段: +- answer: 给调用系统展示的完整回答 +- customer_reply: 可直接发给客户的回复,和 answer 可相同但更口语 +- confidence: 0 到 1 的数字 +- next_questions: 2 到 4 个建议追问 +- risk_notes: 0 到 4 条不能承诺或需核实的风险提示 +""" + + +def _list_texts(value: Any, limit: int = 4, text_limit: int = 120) -> list[str]: + if not isinstance(value, list): + return [] + return [_value(item, text_limit) for item in value[:limit] if _value(item, text_limit)] + + +def _summarize_named_items(value: Any, limit: int = 4) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + items: list[dict[str, str]] = [] + for item in value[:limit]: + if not isinstance(item, dict): + text = _value(item, 140) + if text: + items.append({"name": text, "summary": ""}) + continue + name = _value( + item.get("name") + or item.get("hotel_name") + or item.get("restaurant_name") + or item.get("vehicle_name") + or item.get("title"), + 80, + ) + summary = _value(item.get("summary") or item.get("detail") or item.get("description") or item.get("price_text"), 180) + if name or summary: + items.append({"name": name, "summary": summary}) + return items + + +def _summarize_plan_for_llm(plan: dict[str, Any]) -> dict[str, Any]: + days: list[dict[str, str]] = [] + for day in (plan.get("daily_itinerary") or [])[:6]: + if not isinstance(day, dict): + continue + days.append({ + "day": _value(day.get("day_index") or day.get("day") or "", 20), + "route": _value(day.get("from_to") or day.get("title") or "", 120), + "activity": _value(day.get("activity") or day.get("route_summary") or "", 180), + "hotel": _value(day.get("accommodation") or "", 100), + "meals": _value(day.get("meals") or day.get("meal_plan") or "", 140), + }) + costs: list[dict[str, str]] = [] + for item in (plan.get("cost_breakdown") or [])[:6]: + if isinstance(item, dict): + costs.append({ + "category": _value(item.get("category"), 60), + "detail": _value(item.get("detail") or item.get("summary"), 180), + }) + return { + "plan_name": _value(plan.get("plan_name") or plan.get("name"), 140), + "rank": plan.get("rank"), + "fit_score": plan.get("fit_score"), + "duration_days": plan.get("duration_days"), + "duration_nights": plan.get("duration_nights"), + "route_summary": _value(plan.get("route_summary"), 600), + "quote_summary": _value(plan.get("quote_summary") or plan.get("variant_summary"), 500), + "match_reasons": _list_texts(plan.get("match_reasons"), limit=5, text_limit=80), + "daily_itinerary": days, + "cost_breakdown": costs, + "hotels": _summarize_named_items(plan.get("hotels"), limit=4), + "restaurants": _summarize_named_items(plan.get("restaurants"), limit=4), + "vehicles": _summarize_named_items(plan.get("vehicles"), limit=4), + "policies": _summarize_named_items(plan.get("policies"), limit=4), + } + + +def _summarize_evidence_for_llm(item: dict[str, Any]) -> dict[str, str]: + return { + "type": _value(item.get("type"), 50), + "name": _value(item.get("name") or item.get("title"), 120), + "summary": _value(item.get("summary") or item.get("detail") or item.get("description"), 260), + "source": _value(item.get("source"), 120), + } + + +def _knowledge_pack_for_llm(agent_response: dict[str, Any]) -> dict[str, Any]: + trace = agent_response.get("trace") or {} + return { + "graph_name": agent_response.get("graph_name"), + "intent": agent_response.get("intent") or {}, + "kg_answer_draft": _value(agent_response.get("copy_text"), 2400), + "plans": [ + _summarize_plan_for_llm(plan) + for plan in (agent_response.get("plans") or [])[:5] + if isinstance(plan, dict) + ], + "evidence": [ + _summarize_evidence_for_llm(item) + for item in (agent_response.get("evidence") or [])[:12] + if isinstance(item, dict) + ], + "sales_scripts": _summarize_named_items(agent_response.get("sales_scripts"), limit=4), + "follow_up_questions": _list_texts(agent_response.get("follow_up_questions"), limit=5, text_limit=120), + "trace": { + "response_mode": trace.get("response_mode"), + "response_mode_label": trace.get("response_mode_label"), + "method": trace.get("method"), + "quality_summary": trace.get("quality_summary") or {}, + "retrieval_summary": trace.get("retrieval_summary") or {}, + "data_gap": trace.get("data_gap"), + }, + } + + +def _fallback_customer_service_answer( + agent_response: dict[str, Any], + source: str, + error: str | None = None, +) -> dict[str, Any]: + text = _value(agent_response.get("copy_text"), 3000) + if not text: + text = "当前知识图谱没有命中足够的线路或资源信息,建议先补充客户出行时间、人数、天数和必去景点后再核价。" + risk_notes = ["价格、余位、房型、车辆、门票和景区政策以团期及供应商二次核实为准。"] + trace = agent_response.get("trace") or {} + for check in (trace.get("quality_checks") or [])[:4]: + if isinstance(check, dict) and check.get("status") in {"warn", "fail"}: + note = _value(check.get("detail") or check.get("label"), 140) + if note and note not in risk_notes: + risk_notes.append(note) + return { + "answer": text, + "customer_reply": text, + "confidence": 0.68 if agent_response.get("plans") else 0.42, + "next_questions": _list_texts(agent_response.get("follow_up_questions"), limit=4, text_limit=120), + "risk_notes": risk_notes[:4], + "llm_used": False, + "fusion_source": source, + "llm_error": error, + } + + +def _confidence(value: Any, default: float = 0.72) -> float: + try: + return max(0.0, min(1.0, float(value))) + except Exception: + return default + + +async def _fuse_customer_service_answer( + question: str, + agent_response: dict[str, Any], + enabled: bool, +) -> dict[str, Any]: + if not enabled: + return _fallback_customer_service_answer(agent_response, "kg_template") + + client = await _travel_llm_client(max_tokens=1600) + if not client: + return _fallback_customer_service_answer(agent_response, "kg_template_no_llm_configured") + + payload = { + "question": question, + "knowledge_base": _knowledge_pack_for_llm(agent_response), + } + try: + data = await asyncio.to_thread( + client.chat_json, + CUSTOMER_SERVICE_FUSION_SYS, + json.dumps(payload, ensure_ascii=False), + ) + except Exception as exc: # noqa: BLE001 + return _fallback_customer_service_answer(agent_response, "kg_template_llm_failed", str(exc)[:220]) + + answer = _value(data.get("answer") or data.get("customer_reply"), 3000) + reply = _value(data.get("customer_reply") or answer, 2200) + if not answer: + return _fallback_customer_service_answer(agent_response, "kg_template_empty_llm_answer") + return { + "answer": answer, + "customer_reply": reply or answer, + "confidence": _confidence(data.get("confidence")), + "next_questions": _list_texts(data.get("next_questions"), limit=4, text_limit=120), + "risk_notes": _list_texts(data.get("risk_notes"), limit=4, text_limit=160), + "llm_used": True, + "fusion_source": "kg_llm_fusion", + "llm_error": None, + } + + TASK_LABELS = { "route_price": "线路报价", "route_catalog": "线路清单", @@ -2064,10 +2315,11 @@ def _enrich_agent_response(response: dict[str, Any], started_at: float) -> dict[ trace["agent_design"] = { "current_test_goal": ["图查询命中质量", "客服回答可用性", "企业级响应速度", "报价/余位不可承诺边界"], "future_api_contract": { - "endpoint": "POST /v1/admin/travel/assistant-query", + "endpoint": "POST /v1/admin/travel/customer-service-query", + "internal_test_endpoint": "POST /v1/admin/travel/assistant-query", "request": ["question", "graph_name", "session_id/customer_context", "use_llm"], - "response": ["copy_text", "plans", "evidence", "follow_up_questions", "trace"], - "handoff": "客服系统消费结构化结果;trace 用于质检、审计和图查询优化。", + "response": ["answer", "customer_reply", "plans", "evidence", "follow_up_questions", "trace"], + "handoff": "外部客服系统消费融合回答和结构化证据;trace 用于质检、审计和图查询优化。", }, } return response @@ -5023,3 +5275,87 @@ async def travel_assistant_query( }, } return _enrich_agent_response(response, started_at) + + +@router.post("/travel/customer-service-query") +async def travel_customer_service_query( + body: dict, + _api_key: str = Depends(_require_customer_service_api_key), +): + question = str(body.get("question") or body.get("text") or body.get("query") or "").strip() + if not question: + raise HTTPException(status_code=400, detail="请输入客户咨询内容") + + graph_name = _normalize_customer_service_graph( + body.get("graph_name") + or body.get("knowledge_graph") + or body.get("kg") + or body.get("graph") + or BAIXINGHUI_GRAPH_NAME + ) + context = ProjectContext( + tenant_id=str(body.get("tenant_id") or "travel_agency").strip(), + project_id=str(body.get("project_id") or "baixinghui_travel_agency").strip(), + graph_name=graph_name, + ) + agent_body = dict(body) + agent_body["question"] = question + agent_body["graph_name"] = graph_name + agent_body["use_llm"] = _external_bool(body.get("use_llm"), True) + + agent_response = await travel_assistant_query( + agent_body, + context=context, + _user={"username": "customer_service_api", "roles": ["api"]}, + ) + fusion = await _fuse_customer_service_answer( + question, + agent_response, + _external_bool(body.get("llm_fusion"), True), + ) + trace = agent_response.get("trace") or {} + response: dict[str, Any] = { + "status": "ok", + "service": "baixinghui_customer_service", + "api_version": "2026-06-10", + "session_id": _value(body.get("session_id"), 120), + "question": question, + "graph_name": graph_name, + "answer": fusion["answer"], + "customer_reply": fusion["customer_reply"], + "confidence": fusion["confidence"], + "follow_up_questions": fusion.get("next_questions") or [], + "risk_notes": fusion.get("risk_notes") or [], + "knowledge": { + "plans": agent_response.get("plans") or [], + "evidence": agent_response.get("evidence") or [], + "sales_scripts": agent_response.get("sales_scripts") or [], + "quality_summary": trace.get("quality_summary") or {}, + "retrieval_summary": trace.get("retrieval_summary") or {}, + }, + "routing": { + "intent": agent_response.get("intent") or {}, + "response_mode": trace.get("response_mode"), + "response_mode_label": trace.get("response_mode_label"), + "planned_tasks": trace.get("planned_tasks") or [], + "method": trace.get("method"), + "intent_method": trace.get("intent_method"), + }, + "llm": { + "intent_requested": bool((agent_response.get("intent") or {}).get("llm_requested")), + "intent_used": bool((agent_response.get("intent") or {}).get("llm_used")), + "fusion_used": bool(fusion.get("llm_used")), + "fusion_source": fusion.get("fusion_source"), + "error": fusion.get("llm_error"), + }, + "trace": { + "latency_ms": trace.get("latency_ms"), + "speed": trace.get("speed") or {}, + "quality_summary": trace.get("quality_summary") or {}, + "retrieval_summary": trace.get("retrieval_summary") or {}, + "graph_capabilities_used": trace.get("graph_capabilities_used") or [], + }, + } + if _external_bool(body.get("return_raw_agent") or body.get("debug"), False): + response["raw_agent_response"] = agent_response + return response diff --git a/docker-compose.yml b/docker-compose.yml index 829f137..ce8bada 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,9 @@ services: container_name: travel-kg-postgres restart: unless-stopped environment: - POSTGRES_USER: admin - POSTGRES_PASSWORD: password - POSTGRES_DB: kg_admin + POSTGRES_USER: ${POSTGRES_USER:-admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + POSTGRES_DB: ${POSTGRES_DB:-kg_admin} ports: - "${POSTGRES_PORT:-5433}:5432" volumes: @@ -16,7 +16,7 @@ services: - ./snapshots/postgres/kg_admin_new2.dump:/snapshots/kg_admin_new2.dump:ro - ./docker/postgres-init/01-restore-snapshot.sh:/docker-entrypoint-initdb.d/01-restore-snapshot.sh:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U admin -d kg_admin"] + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] interval: 10s timeout: 5s retries: 10 @@ -59,34 +59,36 @@ services: build: context: . dockerfile: Dockerfile + network: host image: travel-knowledge-graph-api:local container_name: travel-kg-api restart: unless-stopped environment: - DATABASE_URL: postgresql://admin:password@postgres:5432/kg_admin - DB_SCHEMA: kg_admin_new2 - DB_MIGRATIONS_ENABLED: "false" + DATABASE_URL: postgresql://${POSTGRES_USER:-admin}:${POSTGRES_PASSWORD:-password}@postgres:5432/${POSTGRES_DB:-kg_admin} + DB_SCHEMA: ${DB_SCHEMA:-kg_admin_new2} + DB_MIGRATIONS_ENABLED: ${DOCKER_DB_MIGRATIONS_ENABLED:-false} FALKORDB_HOST: falkordb FALKORDB_PORT: "6379" - FALKORDB_GRAPH: guiyang_new2 - FALKORDB_PASSWORD: "" - AUTH_SECRET: change-me-32-chars-minimum-secret - AUTH_ALGORITHM: HS256 - AUTH_TOKEN_EXPIRE_MINUTES: "480" - AUTH_DEFAULT_USERNAME: admin@example.com - AUTH_DEFAULT_PASSWORD: change-me - DEFAULT_TENANT: CityGraph-new2 - DEFAULT_PROJECT: CityGraph-new2 - INGEST_API_KEYS: dev-key-1 - LLM_API_BASE: "" - LLM_API_KEY: "" - LLM_MODEL: deepseek-chat - LLM_TIMEOUT_SECONDS: "30" - LLM_EXTRACTION_ENABLED: "false" - AMAP_WEB_KEY: "" - AMAP_JS_KEY: "" - AMAP_SECURITY_JSCODE: "" - GAODE_CRAWLER_PATH: "" + FALKORDB_GRAPH: ${FALKORDB_GRAPH:-guiyang_new2} + FALKORDB_PASSWORD: ${FALKORDB_PASSWORD:-} + AUTH_SECRET: ${AUTH_SECRET:-change-me-32-chars-minimum-secret} + AUTH_ALGORITHM: ${AUTH_ALGORITHM:-HS256} + AUTH_TOKEN_EXPIRE_MINUTES: ${AUTH_TOKEN_EXPIRE_MINUTES:-480} + AUTH_DEFAULT_USERNAME: ${AUTH_DEFAULT_USERNAME:-admin@example.com} + AUTH_DEFAULT_PASSWORD: ${AUTH_DEFAULT_PASSWORD:-change-me} + DEFAULT_TENANT: ${DEFAULT_TENANT:-CityGraph-new2} + DEFAULT_PROJECT: ${DEFAULT_PROJECT:-CityGraph-new2} + INGEST_API_KEYS: ${INGEST_API_KEYS:-dev-key-1} + LLM_PROVIDER: ${LLM_PROVIDER:-openai-compatible} + LLM_API_BASE: ${LLM_API_BASE:-} + LLM_API_KEY: ${LLM_API_KEY:-} + LLM_MODEL: ${LLM_MODEL:-deepseek-chat} + LLM_TIMEOUT_SECONDS: ${LLM_TIMEOUT_SECONDS:-30} + LLM_EXTRACTION_ENABLED: ${LLM_EXTRACTION_ENABLED:-false} + AMAP_WEB_KEY: ${AMAP_WEB_KEY:-} + AMAP_JS_KEY: ${AMAP_JS_KEY:-} + AMAP_SECURITY_JSCODE: ${AMAP_SECURITY_JSCODE:-} + GAODE_CRAWLER_PATH: ${GAODE_CRAWLER_PATH:-} ports: - "${API_PORT:-8102}:8000" depends_on: diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index aaf038c..a66564e 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -31,6 +31,7 @@ http://localhost:8102/docs | `/v1/admin/plaza/overview` | `GET` | 图谱广场概览 | | `/v1/admin/manual-ingest/extract` | `POST` | 手动抽取 | | `/v1/admin/travel/assistant-query` | `POST` | 旅行客服问答 | +| `/v1/admin/travel/customer-service-query` | `POST` | 百姓惠智能客服外部问答接口 | | `/v1/admin/super-agent/run` | `POST` | Super Agent 任务 | | `/v1/admin/roles` | `GET/POST` | 角色管理 | | `/v1/admin/users` | `GET/POST` | 用户管理 | @@ -73,10 +74,57 @@ curl http://localhost:8102/v1/admin/travel/assistant-query \ -d '{"question":"黄小西三日游多少钱?"}' ``` +## 百姓惠智能客服外部接口 + +该接口用于对接外部客服系统。调用方传入用户自然语言问题,服务端默认选择百姓惠旅行社知识图谱,先召回图谱线路、报价口径、费用、酒店、餐饮、车辆和证据,再用 LLM 融合成客服可直接使用的话术。没有配置 LLM 时会自动退回图谱模板回答。 + +认证方式使用接口 Key,默认本地开发值来自 `.env` / `docker-compose.yml` 的 `INGEST_API_KEYS`: + +```bash +curl http://localhost:8102/v1/admin/travel/customer-service-query \ + -H 'Content-Type: application/json' \ + -H 'X-KG-API-Key: dev-key-1' \ + -d '{ + "question": "黄小西三日游多少钱?", + "knowledge_graph": "百姓惠", + "session_id": "demo-session-001", + "use_llm": true, + "llm_fusion": true + }' +``` + +常用入参: + +| 字段 | 说明 | +| --- | --- | +| `question` / `text` | 用户原始咨询内容,必填 | +| `knowledge_graph` / `graph_name` | 知识图谱名称,可传 `百姓惠`、`bxh` 或 `baixinghui_travel_agency` | +| `session_id` | 外部客服会话 ID,可选 | +| `customer_context` | 外部系统附带的客户上下文,可选 | +| `use_llm` | 是否启用 LLM 意图解析,默认 `true` | +| `llm_fusion` | 是否启用 LLM 话术融合,默认 `true` | +| `return_raw_agent` | 是否返回内部图谱问答原始结果,默认 `false` | + +核心返回字段: + +| 字段 | 说明 | +| --- | --- | +| `answer` | 给外部系统展示的完整回答 | +| `customer_reply` | 可直接发给客户的话术 | +| `confidence` | 本次回答置信度 | +| `follow_up_questions` | 建议追问 | +| `risk_notes` | 不可直接承诺或需二次核实的事项 | +| `knowledge.plans` | 图谱命中的线路/方案 | +| `knowledge.evidence` | 图谱证据 | +| `routing.intent` | 解析出的客户需求 | +| `trace` | 响应耗时、召回规模、质量检查摘要 | + ## 前端调用 React 管理后台通过 `admin-web/src/api.ts` 访问同源 API。Docker 部署时前端和 API 同在 `http://localhost:8102`,因此无需额外配置跨域代理。 +本地问答效果可在 `http://localhost:8102/admin/plaza/user` 验证。当前项目图谱选择 `baixinghui_travel_agency` 时,该页面会走百姓惠旅行社客服问答;选择城市空间图谱时仍为附近地点问答。 + ## 外部服务 LLM 和高德地图相关能力默认关闭或留空。启用前需要在 `.env` 或 Docker Compose 环境变量中配置: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7099fc0..9d3796e 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -21,6 +21,14 @@ docker compose up -d --build http://localhost:8102/admin ``` +服务器部署后把 `localhost` 换成服务器 IP 或域名。例如本项目迁移到 `8.163.40.99` 后: + +```text +http://8.163.40.99:8102/admin +http://8.163.40.99:8102/docs +http://8.163.40.99:8102/openapi.json +``` + 默认账号: ```text @@ -62,6 +70,39 @@ docker compose down -v docker compose up -d --build ``` +## 无 sudo 服务器的 rootless Docker 启动 + +如果服务器账号不能使用 `sudo`,并且 `loginctl show-user <用户> -p Linger` 显示 `Linger=no`,用户级 `docker.service` 可能会在 SSH 退出后停止。此时可以使用项目脚本把 rootless Docker 的 socket 固定到用户目录: + +```bash +cd /home/dockerop/travel-knowledge-graph +./scripts/server_rootless_docker.sh start +./scripts/server_rootless_docker.sh up +./scripts/server_rootless_docker.sh ps +./scripts/server_rootless_docker.sh health +``` + +本项目在 `8.163.40.99` 的当前部署使用: + +```bash +DOCKER_HOST=unix:///home/dockerop/.docker/run/docker.sock +``` + +如果服务器重启,重新执行: + +```bash +cd /home/dockerop/travel-knowledge-graph +./scripts/server_rootless_docker.sh up +``` + +更推荐的长期方式是让服务器管理员执行: + +```bash +sudo loginctl enable-linger dockerop +``` + +这样 rootless Docker 可以由 systemd 用户服务长期托管。 + ## 端口配置 可以在启动时覆盖端口: @@ -87,8 +128,25 @@ docker compose up -d --build Docker Compose 已提供可运行默认值。生产部署时建议改成 `.env` 文件或部署平台的环境变量。 +服务器首次部署建议复制模板后修改: + +```bash +cp .env.example .env +``` + +至少修改: + +```env +AUTH_SECRET=换成32位以上随机字符串 +AUTH_DEFAULT_PASSWORD=换成后台管理员密码 +INGEST_API_KEYS=换成给外部系统的接口Key +LLM_API_BASE=你的OpenAI兼容模型地址 +LLM_API_KEY=你的模型Key +``` + | 变量 | 说明 | | --- | --- | +| `POSTGRES_USER`、`POSTGRES_PASSWORD`、`POSTGRES_DB` | PostgreSQL 容器账号、密码、库名 | | `DATABASE_URL` | 后端连接 PostgreSQL 的 URL | | `DB_SCHEMA` | 默认 `kg_admin_new2` | | `DB_MIGRATIONS_ENABLED` | 快照部署默认 `false` | @@ -106,6 +164,44 @@ Docker Compose 已提供可运行默认值。生产部署时建议改成 `.env` | `TRAVEL_DELIVERY_ROOT` | POI 交付 CSV 目录,仅运行采集/增强脚本时需要 | | `TRAVEL_KG_EXPORT_ROOT` | 采集/构图脚本导出目录 | +## 百姓惠智能客服接口 + +给外部系统对接时,只开放这个接口即可: + +```text +POST http://8.163.40.99:8102/v1/admin/travel/customer-service-query +``` + +服务器安全组需要放行 TCP `8102`。如果服务器本机 `curl http://127.0.0.1:8102/v1/admin/health` 正常,但外部访问 `http://8.163.40.99:8102` 超时,优先检查云控制台安全组/防火墙入方向规则。 + +请求示例: + +```bash +curl http://8.163.40.99:8102/v1/admin/travel/customer-service-query \ + -H 'Content-Type: application/json' \ + -H 'X-KG-API-Key: 你的INGEST_API_KEYS之一' \ + -d '{ + "question": "黄小西三日游多少钱?", + "knowledge_graph": "百姓惠", + "session_id": "demo-001", + "use_llm": true, + "llm_fusion": true + }' +``` + +`llm_fusion=true` 需要配置 `LLM_API_BASE` 和 `LLM_API_KEY`。未配置 key 时,接口仍会返回图谱模板答案,但不会调用 LLM 融合。 + +主要返回字段: + +| 字段 | 说明 | +| --- | --- | +| `answer` | 给外部系统展示的完整回答 | +| `customer_reply` | 可以直接发给客户的话术 | +| `follow_up_questions` | 建议追问 | +| `risk_notes` | 需要二次核实或不可直接承诺的事项 | +| `knowledge.plans` | 图谱命中的线路/方案 | +| `knowledge.evidence` | 图谱证据 | + ## 健康检查 API: diff --git a/scripts/server_rootless_docker.sh b/scripts/server_rootless_docker.sh new file mode 100755 index 0000000..6710490 --- /dev/null +++ b/scripts/server_rootless_docker.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR="${PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +ROOTLESS_RUNTIME_DIR="${ROOTLESS_RUNTIME_DIR:-$HOME/.docker/run}" +DOCKER_SOCK="${DOCKER_SOCK:-$ROOTLESS_RUNTIME_DIR/docker.sock}" +DOCKER_LOG="${DOCKER_LOG:-$HOME/.docker/dockerd-rootless.log}" + +ensure_docker() { + mkdir -p "$ROOTLESS_RUNTIME_DIR" + chmod 700 "$ROOTLESS_RUNTIME_DIR" + + if DOCKER_HOST="unix://$DOCKER_SOCK" docker info >/dev/null 2>&1; then + return 0 + fi + + if ! pgrep -u "$(id -u)" -f "dockerd-rootless.sh.*$DOCKER_SOCK" >/dev/null 2>&1; then + : > "$DOCKER_LOG" + setsid sh -c \ + "export XDG_RUNTIME_DIR='$ROOTLESS_RUNTIME_DIR'; exec dockerd-rootless.sh --iptables=false --host=unix://$DOCKER_SOCK >> '$DOCKER_LOG' 2>&1" \ + < /dev/null >/dev/null 2>&1 & + fi + + for _ in $(seq 1 45); do + if DOCKER_HOST="unix://$DOCKER_SOCK" docker info >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + echo "Docker rootless daemon did not become ready. Last log lines:" >&2 + tail -n 80 "$DOCKER_LOG" >&2 || true + return 1 +} + +compose() { + ensure_docker + export DOCKER_HOST="unix://$DOCKER_SOCK" + cd "$PROJECT_DIR" + docker compose "$@" +} + +case "${1:-up}" in + start) + ensure_docker + ;; + up) + compose up -d + ;; + build-up) + compose up -d --build + ;; + ps) + compose ps + ;; + logs) + shift + compose logs "${@:-api}" + ;; + down) + compose down + ;; + health) + curl -fsS http://127.0.0.1:${API_PORT:-8102}/v1/admin/health + echo + ;; + *) + echo "Usage: $0 {start|up|build-up|ps|logs [service]|down|health}" >&2 + exit 2 + ;; +esac