Add customer service API deployment support

This commit is contained in:
2026-06-10 10:48:26 +08:00
parent 0594fc9f8c
commit e589073311
8 changed files with 777 additions and 62 deletions

View File

@@ -1,10 +1,14 @@
# Database # 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_SCHEMA=kg_admin_new2
DB_MIGRATIONS_ENABLED=false DB_MIGRATIONS_ENABLED=false
# FalkorDB # FalkorDB
FALKORDB_HOST=localhost FALKORDB_HOST=falkordb
FALKORDB_INTERNAL_PORT=6379
FALKORDB_PORT=6380 FALKORDB_PORT=6380
FALKORDB_GRAPH=guiyang_new2 FALKORDB_GRAPH=guiyang_new2
FALKORDB_PASSWORD= FALKORDB_PASSWORD=

View File

@@ -5,7 +5,8 @@ FROM node:22-bookworm-slim AS admin-web-build
WORKDIR /build/admin-web WORKDIR /build/admin-web
COPY admin-web/package.json admin-web/package-lock.json ./ 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/ ./ COPY admin-web/ ./
RUN npm run build RUN npm run build
@@ -19,12 +20,22 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app 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 \ && apt-get install -y --no-install-recommends libpq5 curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./ 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 app ./app
COPY schema搭建 ./schema搭建 COPY schema搭建 ./schema搭建

View File

@@ -8,7 +8,7 @@ import {
ExpandOutlined, HeartOutlined, PhoneOutlined, SearchOutlined, SendOutlined, ExpandOutlined, HeartOutlined, PhoneOutlined, SearchOutlined, SendOutlined,
ShareAltOutlined, ShrinkOutlined, ShareAltOutlined, ShrinkOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { plazaAmapConfig, plazaUserQuery } from "../../api"; import { displayGraphName, getProjectContext, plazaAmapConfig, plazaUserQuery, travelAssistantQuery } from "../../api";
const { Text } = Typography; const { Text } = Typography;
@@ -69,6 +69,7 @@ type QueryResult = {
}; };
type QueryPayload = { type QueryPayload = {
mode?: "spatial" | "travel_customer_service";
question: string; question: string;
graph_name: string; graph_name: string;
user_location: { lng: number; lat: number }; user_location: { lng: number; lat: number };
@@ -76,6 +77,9 @@ type QueryPayload = {
answer: string; answer: string;
results: QueryResult[]; results: QueryResult[];
trace: Record<string, any>; trace: Record<string, any>;
plans?: Array<Record<string, any>>;
follow_up_questions?: string[];
risk_notes?: string[];
}; };
const samples = [ const samples = [
@@ -86,6 +90,14 @@ const samples = [
"附近有没有公交地铁站?", "附近有没有公交地铁站?",
]; ];
const travelSamples = [
"黄小西三日游多少钱?",
"2天1晚想去黄果树有哪些线路顺便看附近酒店和可选车辆价格",
"黄果树附近有哪些酒店餐饮,价格和余位能直接承诺吗?",
"客户临时多加2人原来5人用车还能坐吗",
"小七孔有哪些必付和可选费用,老人小孩有没有需要注意的?",
];
const formatDistance = (m: number) => { const formatDistance = (m: number) => {
if (m >= 1000) return `${(m / 1000).toFixed(1)}km`; if (m >= 1000) return `${(m / 1000).toFixed(1)}km`;
return `${Math.round(m)}m`; return `${Math.round(m)}m`;
@@ -121,6 +133,7 @@ export default function UserExperiencePanel() {
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const [photoIndex, setPhotoIndex] = useState(0); const [photoIndex, setPhotoIndex] = useState(0);
const [failedPhotos, setFailedPhotos] = useState<string[]>([]); const [failedPhotos, setFailedPhotos] = useState<string[]>([]);
const [projectContext, setProjectContextState] = useState(() => getProjectContext());
const mapRef = useRef<HTMLDivElement | null>(null); const mapRef = useRef<HTMLDivElement | null>(null);
const mapInstance = useRef<any>(null); const mapInstance = useRef<any>(null);
@@ -129,6 +142,25 @@ export default function UserExperiencePanel() {
const results = payload?.results || []; const results = payload?.results || [];
const intent = payload?.intent; 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(() => { useEffect(() => {
let disposed = false; let disposed = false;
@@ -305,6 +337,40 @@ export default function UserExperiencePanel() {
setQuestion(text); setQuestion(text);
setLoading(true); setLoading(true);
try { 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({ const { data } = await plazaUserQuery({
question: text, question: text,
lng, lng,
@@ -330,35 +396,50 @@ export default function UserExperiencePanel() {
onChange={(e) => setQuestion(e.target.value)} onChange={(e) => setQuestion(e.target.value)}
onSearch={() => runQuery()} onSearch={() => runQuery()}
loading={loading} loading={loading}
placeholder="搜索地点、公交、地铁、美食..." placeholder={isTravelCustomerService ? "输入客户原话,例如:黄小西三日游多少钱?" : "搜索地点、公交、地铁、美食..."}
/> />
<div className="kg-user-controls"> <div className="kg-user-controls">
<div className="kg-user-control-title"> {isTravelCustomerService ? (
<Tag color="blue"></Tag> <div className="kg-user-travel-mode">
<Button size="small" icon={<AimOutlined />} onClick={() => { setLng(106.702501); setLat(26.55806); }}> <Tag color="green"></Tag>
<Text type="secondary">{displayGraphName(projectContext.graphName)}</Text>
</Button> </div>
</div> ) : (
<div className="kg-user-control-grid"> <>
<label> <div className="kg-user-control-title">
<span></span> <Tag color="blue"></Tag>
<InputNumber value={lng} precision={6} onChange={(v) => setLng(Number(v || lng))} /> <Button size="small" icon={<AimOutlined />} onClick={() => { setLng(106.702501); setLat(26.55806); }}>
</label>
<label> </Button>
<span></span> </div>
<InputNumber value={lat} precision={6} onChange={(v) => setLat(Number(v || lat))} /> <div className="kg-user-control-grid">
</label> <label>
<label> <span></span>
<span></span> <InputNumber value={lng} precision={6} onChange={(v) => setLng(Number(v || lng))} />
<InputNumber min={100} max={10000} step={100} value={radius} onChange={(v) => setRadius(Number(v || 1000))} /> </label>
</label> <label>
</div> <span></span>
<InputNumber value={lat} precision={6} onChange={(v) => setLat(Number(v || lat))} />
</label>
<label>
<span></span>
<InputNumber min={100} max={10000} step={100} value={radius} onChange={(v) => setRadius(Number(v || 1000))} />
</label>
</div>
</>
)}
</div> </div>
<div className="kg-user-result-head"> <div className="kg-user-result-head">
<b><CompassOutlined /> </b> <b><CompassOutlined /> </b>
{payload ? <span>{results.length} · {intent?.category || "综合"} · {intent?.radius_m || radius}m</span> : null} {payload ? (
<span>
{isTravelPayload
? `${displayGraphName(payload.graph_name)} · ${intent?.category || "客服问答"} · 方案 ${(payload.plans || []).length}`
: `${results.length} 个结果 · ${intent?.category || "综合"} · ${intent?.radius_m || radius}m`}
</span>
) : null}
</div> </div>
<div className="kg-user-results"> <div className="kg-user-results">
@@ -370,7 +451,41 @@ export default function UserExperiencePanel() {
<> <>
<div className="kg-user-answer">{payload.answer}</div> <div className="kg-user-answer">{payload.answer}</div>
{results.length === 0 ? ( {results.length === 0 ? (
<Empty description="当前半径内没有匹配结果" style={{ marginTop: 70 }} /> isTravelPayload ? (
<div className="kg-user-travel-summary">
{(payload.plans || []).slice(0, 4).map((plan, idx) => (
<div className="kg-user-travel-plan" key={`${plan.plan_name || idx}`}>
<Space size={6} wrap>
<Tag color="blue">{idx + 1}</Tag>
<b>{plan.plan_name || "未命名线路"}</b>
{plan.duration_days && <Tag>{plan.duration_days}</Tag>}
{plan.fit_score && <Tag color="purple"> {plan.fit_score}</Tag>}
</Space>
<p>{plan.route_summary || plan.quote_summary || plan.variant_summary || "图谱已命中该线路,更多细节请继续追问。"}</p>
</div>
))}
{!!payload.follow_up_questions?.length && (
<div className="kg-user-travel-followups">
<Text strong></Text>
<Space wrap>
{payload.follow_up_questions.slice(0, 4).map((item) => (
<Tag color="gold" key={item} onClick={() => runQuery(item)}>{item}</Tag>
))}
</Space>
</div>
)}
{!!payload.risk_notes?.length && (
<Alert
type="warning"
showIcon
message="客服承诺边界"
description={payload.risk_notes.slice(0, 3).join("")}
/>
)}
</div>
) : (
<Empty description="当前半径内没有匹配结果" style={{ marginTop: 70 }} />
)
) : results.map((r, idx) => { ) : results.map((r, idx) => {
const photo = firstPhoto(r); const photo = firstPhoto(r);
return ( return (
@@ -587,6 +702,12 @@ export default function UserExperiencePanel() {
.kg-user-control-grid .ant-input-number { .kg-user-control-grid .ant-input-number {
width: 100%; width: 100%;
} }
.kg-user-travel-mode {
display: flex;
flex-direction: column;
gap: 8px;
line-height: 1.6;
}
.kg-user-result-head { .kg-user-result-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -614,6 +735,32 @@ export default function UserExperiencePanel() {
font-size: 13px; font-size: 13px;
line-height: 1.65; 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 { .kg-user-result-card {
display: grid; display: grid;
grid-template-columns: 1fr 96px; grid-template-columns: 1fr 96px;

View File

@@ -9,7 +9,7 @@ import time
from typing import Any from typing import Any
from falkordb import FalkorDB from falkordb import FalkorDB
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, Header, HTTPException
from app.auth import CurrentUser from app.auth import CurrentUser
from app.config import settings from app.config import settings
@@ -22,6 +22,7 @@ router = APIRouter()
ENTERPRISE_AGENT_TARGET_MS = 1200 ENTERPRISE_AGENT_TARGET_MS = 1200
ENTERPRISE_AGENT_WARN_MS = 2500 ENTERPRISE_AGENT_WARN_MS = 2500
GRAPH_CACHE_TTL_SECONDS = 120 GRAPH_CACHE_TTL_SECONDS = 120
BAIXINGHUI_GRAPH_NAME = "baixinghui_travel_agency"
_GRAPH_DATA_CACHE: dict[tuple[str, str], tuple[float, Any]] = {} _GRAPH_DATA_CACHE: dict[tuple[str, str], tuple[float, Any]] = {}
ATTRACTION_ALIASES: dict[str, list[str]] = { 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) 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]: def _extract_intent(question: str) -> dict[str, Any]:
q = question.strip() q = question.strip()
party_size = None 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) 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() cfg = await get_agent_settings()
global_cfg = cfg.get("global") or {} global_cfg = cfg.get("global") or {}
if global_cfg.get("base_url") and global_cfg.get("api_key"): 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["api_key"],
global_cfg.get("model") or "deepseek-chat", global_cfg.get("model") or "deepseek-chat",
timeout=int(global_cfg.get("timeout") or 45), timeout=int(global_cfg.get("timeout") or 45),
max_tokens=1000, max_tokens=max_tokens,
) )
extract = cfg.get("extract") or {} extract = cfg.get("extract") or {}
@@ -575,11 +624,15 @@ async def _travel_intent_client() -> LlmClient | None:
model_cfg["api_key"], model_cfg["api_key"],
model_cfg.get("model") or "deepseek-chat", model_cfg.get("model") or "deepseek-chat",
timeout=int(extract.get("timeout") or 60), timeout=int(extract.get("timeout") or 60),
max_tokens=1000, max_tokens=max_tokens,
) )
return None return None
async def _travel_intent_client() -> LlmClient | None:
return await _travel_llm_client(max_tokens=1000)
TRAVEL_INTENT_SYS = """你是旅行社客服行程推荐的自然语言需求解析器。只输出 JSON。 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" 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 = { TASK_LABELS = {
"route_price": "线路报价", "route_price": "线路报价",
"route_catalog": "线路清单", "route_catalog": "线路清单",
@@ -2064,10 +2315,11 @@ def _enrich_agent_response(response: dict[str, Any], started_at: float) -> dict[
trace["agent_design"] = { trace["agent_design"] = {
"current_test_goal": ["图查询命中质量", "客服回答可用性", "企业级响应速度", "报价/余位不可承诺边界"], "current_test_goal": ["图查询命中质量", "客服回答可用性", "企业级响应速度", "报价/余位不可承诺边界"],
"future_api_contract": { "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"], "request": ["question", "graph_name", "session_id/customer_context", "use_llm"],
"response": ["copy_text", "plans", "evidence", "follow_up_questions", "trace"], "response": ["answer", "customer_reply", "plans", "evidence", "follow_up_questions", "trace"],
"handoff": "客服系统消费结构化结果trace 用于质检、审计和图查询优化。", "handoff": "外部客服系统消费融合回答和结构化证据trace 用于质检、审计和图查询优化。",
}, },
} }
return response return response
@@ -5023,3 +5275,87 @@ async def travel_assistant_query(
}, },
} }
return _enrich_agent_response(response, started_at) 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

View File

@@ -6,9 +6,9 @@ services:
container_name: travel-kg-postgres container_name: travel-kg-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: admin POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: password POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
POSTGRES_DB: kg_admin POSTGRES_DB: ${POSTGRES_DB:-kg_admin}
ports: ports:
- "${POSTGRES_PORT:-5433}:5432" - "${POSTGRES_PORT:-5433}:5432"
volumes: volumes:
@@ -16,7 +16,7 @@ services:
- ./snapshots/postgres/kg_admin_new2.dump:/snapshots/kg_admin_new2.dump:ro - ./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 - ./docker/postgres-init/01-restore-snapshot.sh:/docker-entrypoint-initdb.d/01-restore-snapshot.sh:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d kg_admin"] test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -59,34 +59,36 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
network: host
image: travel-knowledge-graph-api:local image: travel-knowledge-graph-api:local
container_name: travel-kg-api container_name: travel-kg-api
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql://admin:password@postgres:5432/kg_admin DATABASE_URL: postgresql://${POSTGRES_USER:-admin}:${POSTGRES_PASSWORD:-password}@postgres:5432/${POSTGRES_DB:-kg_admin}
DB_SCHEMA: kg_admin_new2 DB_SCHEMA: ${DB_SCHEMA:-kg_admin_new2}
DB_MIGRATIONS_ENABLED: "false" DB_MIGRATIONS_ENABLED: ${DOCKER_DB_MIGRATIONS_ENABLED:-false}
FALKORDB_HOST: falkordb FALKORDB_HOST: falkordb
FALKORDB_PORT: "6379" FALKORDB_PORT: "6379"
FALKORDB_GRAPH: guiyang_new2 FALKORDB_GRAPH: ${FALKORDB_GRAPH:-guiyang_new2}
FALKORDB_PASSWORD: "" FALKORDB_PASSWORD: ${FALKORDB_PASSWORD:-}
AUTH_SECRET: change-me-32-chars-minimum-secret AUTH_SECRET: ${AUTH_SECRET:-change-me-32-chars-minimum-secret}
AUTH_ALGORITHM: HS256 AUTH_ALGORITHM: ${AUTH_ALGORITHM:-HS256}
AUTH_TOKEN_EXPIRE_MINUTES: "480" AUTH_TOKEN_EXPIRE_MINUTES: ${AUTH_TOKEN_EXPIRE_MINUTES:-480}
AUTH_DEFAULT_USERNAME: admin@example.com AUTH_DEFAULT_USERNAME: ${AUTH_DEFAULT_USERNAME:-admin@example.com}
AUTH_DEFAULT_PASSWORD: change-me AUTH_DEFAULT_PASSWORD: ${AUTH_DEFAULT_PASSWORD:-change-me}
DEFAULT_TENANT: CityGraph-new2 DEFAULT_TENANT: ${DEFAULT_TENANT:-CityGraph-new2}
DEFAULT_PROJECT: CityGraph-new2 DEFAULT_PROJECT: ${DEFAULT_PROJECT:-CityGraph-new2}
INGEST_API_KEYS: dev-key-1 INGEST_API_KEYS: ${INGEST_API_KEYS:-dev-key-1}
LLM_API_BASE: "" LLM_PROVIDER: ${LLM_PROVIDER:-openai-compatible}
LLM_API_KEY: "" LLM_API_BASE: ${LLM_API_BASE:-}
LLM_MODEL: deepseek-chat LLM_API_KEY: ${LLM_API_KEY:-}
LLM_TIMEOUT_SECONDS: "30" LLM_MODEL: ${LLM_MODEL:-deepseek-chat}
LLM_EXTRACTION_ENABLED: "false" LLM_TIMEOUT_SECONDS: ${LLM_TIMEOUT_SECONDS:-30}
AMAP_WEB_KEY: "" LLM_EXTRACTION_ENABLED: ${LLM_EXTRACTION_ENABLED:-false}
AMAP_JS_KEY: "" AMAP_WEB_KEY: ${AMAP_WEB_KEY:-}
AMAP_SECURITY_JSCODE: "" AMAP_JS_KEY: ${AMAP_JS_KEY:-}
GAODE_CRAWLER_PATH: "" AMAP_SECURITY_JSCODE: ${AMAP_SECURITY_JSCODE:-}
GAODE_CRAWLER_PATH: ${GAODE_CRAWLER_PATH:-}
ports: ports:
- "${API_PORT:-8102}:8000" - "${API_PORT:-8102}:8000"
depends_on: depends_on:

View File

@@ -31,6 +31,7 @@ http://localhost:8102/docs
| `/v1/admin/plaza/overview` | `GET` | 图谱广场概览 | | `/v1/admin/plaza/overview` | `GET` | 图谱广场概览 |
| `/v1/admin/manual-ingest/extract` | `POST` | 手动抽取 | | `/v1/admin/manual-ingest/extract` | `POST` | 手动抽取 |
| `/v1/admin/travel/assistant-query` | `POST` | 旅行客服问答 | | `/v1/admin/travel/assistant-query` | `POST` | 旅行客服问答 |
| `/v1/admin/travel/customer-service-query` | `POST` | 百姓惠智能客服外部问答接口 |
| `/v1/admin/super-agent/run` | `POST` | Super Agent 任务 | | `/v1/admin/super-agent/run` | `POST` | Super Agent 任务 |
| `/v1/admin/roles` | `GET/POST` | 角色管理 | | `/v1/admin/roles` | `GET/POST` | 角色管理 |
| `/v1/admin/users` | `GET/POST` | 用户管理 | | `/v1/admin/users` | `GET/POST` | 用户管理 |
@@ -73,10 +74,57 @@ curl http://localhost:8102/v1/admin/travel/assistant-query \
-d '{"question":"黄小西三日游多少钱?"}' -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`,因此无需额外配置跨域代理。 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 环境变量中配置: LLM 和高德地图相关能力默认关闭或留空。启用前需要在 `.env` 或 Docker Compose 环境变量中配置:

View File

@@ -21,6 +21,14 @@ docker compose up -d --build
http://localhost:8102/admin 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 ```text
@@ -62,6 +70,39 @@ docker compose down -v
docker compose up -d --build 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` 文件或部署平台的环境变量。 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 | | `DATABASE_URL` | 后端连接 PostgreSQL 的 URL |
| `DB_SCHEMA` | 默认 `kg_admin_new2` | | `DB_SCHEMA` | 默认 `kg_admin_new2` |
| `DB_MIGRATIONS_ENABLED` | 快照部署默认 `false` | | `DB_MIGRATIONS_ENABLED` | 快照部署默认 `false` |
@@ -106,6 +164,44 @@ Docker Compose 已提供可运行默认值。生产部署时建议改成 `.env`
| `TRAVEL_DELIVERY_ROOT` | POI 交付 CSV 目录,仅运行采集/增强脚本时需要 | | `TRAVEL_DELIVERY_ROOT` | POI 交付 CSV 目录,仅运行采集/增强脚本时需要 |
| `TRAVEL_KG_EXPORT_ROOT` | 采集/构图脚本导出目录 | | `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 API

View File

@@ -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