Add customer service API deployment support
This commit is contained in:
@@ -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=
|
||||
|
||||
17
Dockerfile
17
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搭建
|
||||
|
||||
@@ -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<string, any>;
|
||||
plans?: Array<Record<string, any>>;
|
||||
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<string[]>([]);
|
||||
const [projectContext, setProjectContextState] = useState(() => getProjectContext());
|
||||
|
||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapInstance = useRef<any>(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,10 +396,17 @@ export default function UserExperiencePanel() {
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onSearch={() => runQuery()}
|
||||
loading={loading}
|
||||
placeholder="搜索地点、公交、地铁、美食..."
|
||||
placeholder={isTravelCustomerService ? "输入客户原话,例如:黄小西三日游多少钱?" : "搜索地点、公交、地铁、美食..."}
|
||||
/>
|
||||
|
||||
<div className="kg-user-controls">
|
||||
{isTravelCustomerService ? (
|
||||
<div className="kg-user-travel-mode">
|
||||
<Tag color="green">百姓惠客服问答</Tag>
|
||||
<Text type="secondary">当前图谱:{displayGraphName(projectContext.graphName)},输入客户自然语言后返回图谱召回话术、证据和追问。</Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="kg-user-control-title">
|
||||
<Tag color="blue">游客坐标</Tag>
|
||||
<Button size="small" icon={<AimOutlined />} onClick={() => { setLng(106.702501); setLat(26.55806); }}>
|
||||
@@ -354,11 +427,19 @@ export default function UserExperiencePanel() {
|
||||
<InputNumber min={100} max={10000} step={100} value={radius} onChange={(v) => setRadius(Number(v || 1000))} />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="kg-user-result-head">
|
||||
<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 className="kg-user-results">
|
||||
@@ -370,7 +451,41 @@ export default function UserExperiencePanel() {
|
||||
<>
|
||||
<div className="kg-user-answer">{payload.answer}</div>
|
||||
{results.length === 0 ? (
|
||||
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) => {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 环境变量中配置:
|
||||
|
||||
@@ -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:
|
||||
|
||||
71
scripts/server_rootless_docker.sh
Executable file
71
scripts/server_rootless_docker.sh
Executable 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
|
||||
Reference in New Issue
Block a user