"""LLM-to-Cypher graph QA engine for enterprise knowledge-base questions.""" from __future__ import annotations import asyncio import copy import json import re import time from typing import Any from falkordb import FalkorDB from app.config import settings from app.db import get_agent_settings from app.llm_client import LlmClient GRAPH_QA_MAX_LIMIT = 300 SCHEMA_CACHE_TTL_SECONDS = 300 QA_RESPONSE_CACHE_TTL_SECONDS = 600 CYPHER_CACHE_TTL_SECONDS = 1800 GRAPH_QA_PLANNER_TIMEOUT_SECONDS = 2.5 GRAPH_QA_CYPHER_TIMEOUT_SECONDS = 4.5 GRAPH_QA_REPAIR_TIMEOUT_SECONDS = 5.0 GRAPH_QA_ANSWER_TIMEOUT_SECONDS = 6.0 _SCHEMA_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} _QA_RESPONSE_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} _CYPHER_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} WRITE_KEYWORDS = re.compile( r"\b(CREATE|MERGE|SET|DELETE|DETACH|DROP|REMOVE|CALL\s+DB\.IDX|CALL\s+DB\.CONSTRAINT)\b", re.IGNORECASE, ) READ_START = re.compile(r"^(MATCH|RETURN|CALL)\b", re.IGNORECASE) GRAPH_QA_PLANNER_SYS = """你是百姓惠知识图谱客服问答的路由规划器。只输出 JSON: {"intent":"...","route":"...","confidence":0.0,"reason":"...","need_llm_graph":false} 任务:判断用户问题应该先走哪条链路,不生成答案、不生成 Cypher。 可选 intent: route_catalog、price_quote、fee_detail、vehicle_catalog、route_multihop_detail、 condition_route_advice、route_compare_suitability、route_compare_scenic_count、 route_context_advice、general_graph_qa。 可选 route: template_graph_query、deterministic_context_llm_answer、llm_graph_qa。 原则:高频客服问题优先 template_graph_query;需要推荐/解释但图谱可固定取证时选 deterministic_context_llm_answer; 无法用模板覆盖的长尾问题才选 llm_graph_qa。不要臆造线路名。""" GRAPH_QA_CYPHER_SYS = """你是 FalkorDB/RedisGraph 只读 Cypher 规划器。只输出 JSON: {"cypher":"...","reason":"...","answer_focus":"..."} 规则:只允许 MATCH/RETURN/CALL;禁止 CREATE/MERGE/SET/DELETE/DETACH/DROP/REMOVE;必须 LIMIT;中文用 CONTAINS。 只给一条最短可执行查询,cypher 控制在 800 字以内,不要输出示例、解释或多条备选。 百姓惠常用标签:TourProduct、ProductDay、RouteStop、ScenicAttraction、TravelItem、ProductPricePlan、PolicyRule、SalesScript。 价格:TourProduct-[:PRODUCT_HAS_PRICE_PLAN]->ProductPricePlan,字段 adult_price_min/max、child_price_min/max、single_room_diff_min/max。 费用/小交通:TourProduct-[:PRODUCT_USES_TRAVEL_ITEM]->TravelItem;TravelItem 字段 name、type、subtype、category、price、adult_price、unit、raw_evidence、requires_supplier_confirm。 黄小西/小西:按黄果树、小七孔、西江相关线路匹配。""" GRAPH_QA_REPAIR_SYS = """你是 FalkorDB/RedisGraph Cypher 修复器。只输出 JSON。 根据执行错误修复上一条只读 Cypher。仍然必须只读、以 MATCH/RETURN/CALL 开头、带 LIMIT。 字段:cypher、reason、answer_focus。""" GRAPH_QA_ANSWER_SYS = """你是百姓惠客服图谱问答助手。只输出 JSON。 基于 graph_result 回答,不编造价格、余位、房型、车辆或承诺;证据不足就提示二次核实。 customer_reply 控制在 100 字内,answer 控制在 360 字内。 字段:answer、customer_reply、confidence、follow_up_questions、risk_notes、evidence_cards。""" def _get_graph(graph_name: str): db = FalkorDB(host=settings.falkordb_host, port=settings.falkordb_port) return db.select_graph(graph_name) def _safe_int(value: Any, default: int) -> int: try: return int(value) except Exception: return default async def _chat_json_with_retry( client: LlmClient, system_prompt: str, user_prompt: str, *, attempts: int = 2, ) -> dict[str, Any]: last_exc: Exception | None = None for attempt in range(max(1, attempts)): try: return await asyncio.to_thread(client.chat_json, system_prompt, user_prompt) except Exception as exc: # noqa: BLE001 last_exc = exc if attempt >= attempts - 1: break await asyncio.sleep(0.8 * (attempt + 1)) raise last_exc or RuntimeError("LLM JSON 调用失败") async def _chat_json_timed( client: LlmClient, system_prompt: str, user_prompt: str, *, timeout_seconds: float, attempts: int = 1, ) -> dict[str, Any]: return await asyncio.wait_for( _chat_json_with_retry(client, system_prompt, user_prompt, attempts=attempts), timeout=timeout_seconds, ) async def _graph_qa_client(max_tokens: int = 1600, *, use_config_max_tokens: bool = True) -> LlmClient | None: try: cfg = await get_agent_settings() except Exception: cfg = {} api_access = cfg.get("api_access") if isinstance(cfg, dict) else {} qa_llm = (api_access or {}).get("qa_llm") if isinstance(api_access, dict) else {} if isinstance(qa_llm, dict) and qa_llm.get("base_url") and qa_llm.get("api_key"): configured_max_tokens = _safe_int(qa_llm.get("max_tokens"), max_tokens) return LlmClient( qa_llm["base_url"], qa_llm["api_key"], qa_llm.get("model") or settings.llm_model or "deepseek-chat", timeout=_safe_int(qa_llm.get("timeout"), settings.llm_timeout_seconds or 45), max_tokens=min(configured_max_tokens, max_tokens) if use_config_max_tokens else max_tokens, ) global_cfg = cfg.get("global") if isinstance(cfg, dict) else {} if isinstance(global_cfg, dict) and global_cfg.get("base_url") and global_cfg.get("api_key"): return LlmClient( global_cfg["base_url"], global_cfg["api_key"], global_cfg.get("model") or settings.llm_model or "deepseek-chat", timeout=_safe_int(global_cfg.get("timeout"), settings.llm_timeout_seconds or 45), max_tokens=max_tokens, ) if settings.llm_api_base and settings.llm_api_key: return LlmClient( settings.llm_api_base, settings.llm_api_key, settings.llm_model or "deepseek-chat", timeout=settings.llm_timeout_seconds, max_tokens=max_tokens, ) extract = cfg.get("extract") if isinstance(cfg, dict) else {} extract = extract if isinstance(extract, dict) else {} models = extract.get("models") or {} models = models if isinstance(models, dict) else {} keys = [extract.get("aggregator")] + [ key for key, value in models.items() if isinstance(value, dict) and value.get("enabled") ] + list(models.keys()) for key in keys: model_cfg = models.get(key or "") if isinstance(model_cfg, dict) and model_cfg.get("base_url") and model_cfg.get("api_key"): return LlmClient( model_cfg["base_url"], model_cfg["api_key"], model_cfg.get("model") or settings.llm_model or "deepseek-chat", timeout=int(extract.get("timeout") or settings.llm_timeout_seconds or 60), max_tokens=max_tokens, ) return None def _assert_read_only(cypher: str) -> None: if not cypher: raise ValueError("Cypher query required") if WRITE_KEYWORDS.search(cypher): raise ValueError("Read-only queries only") if not READ_START.match(cypher): raise ValueError("Only MATCH, RETURN, or CALL queries allowed") def _clean_cypher(cypher: Any, limit: int) -> str: text = str(cypher or "").strip() text = re.sub(r"^```(?:cypher)?\s*", "", text, flags=re.I).strip() text = text.removesuffix("```").strip().rstrip(";") if not re.search(r"\bLIMIT\s+\d+\b", text, flags=re.IGNORECASE): text = f"{text} LIMIT {limit}" _assert_read_only(text) return text def _compact_scalar(value: Any, limit: int = 500) -> Any: if value is None or isinstance(value, (int, float, bool)): return value if isinstance(value, (list, tuple)): return [_compact_scalar(item, 180) for item in value[:20]] text = re.sub(r"\s+", " ", str(value)).strip() return text[:limit] def _node_id(node: Any) -> str: value = getattr(node, "id", None) return str(value if value is not None else node) def _node_summary(node: Any) -> dict[str, Any]: props = dict(getattr(node, "properties", None) or {}) labels = list(getattr(node, "labels", None) or []) title = "" for key in ( "display_name", "name", "title", "label", "product_name", "route_line_name", "natural_key", "product_id", "item_id", ): if props.get(key): title = str(props[key]) break if not title: title = f"{labels[0] if labels else 'Node'} #{_node_id(node)}" return { "id": _node_id(node), "labels": labels, "title": title[:160], "properties": {str(k): _compact_scalar(v, 700) for k, v in props.items()}, } def _edge_summary(edge: Any) -> dict[str, Any]: props = dict(getattr(edge, "properties", None) or {}) return { "id": str(getattr(edge, "id", "")), "type": str(getattr(edge, "relation", "") or ""), "from": _node_id(getattr(edge, "src_node", "")), "to": _node_id(getattr(edge, "dest_node", "")), "properties": {str(k): _compact_scalar(v, 300) for k, v in props.items()}, } def _compact_value(value: Any) -> Any: if hasattr(value, "labels") and hasattr(value, "properties"): return {"node": _node_summary(value)} if hasattr(value, "src_node") and hasattr(value, "dest_node"): return {"relationship": _edge_summary(value)} if ( hasattr(value, "nodes") and callable(getattr(value, "nodes")) and hasattr(value, "edges") and callable(getattr(value, "edges")) ): return { "path": { "nodes": [_node_summary(node) for node in value.nodes()], "relationships": [_edge_summary(edge) for edge in value.edges()], } } if isinstance(value, (list, tuple)): return [_compact_value(item) for item in value[:20]] if isinstance(value, dict): return {str(k): _compact_value(v) for k, v in list(value.items())[:30]} return _compact_scalar(value) def _extract_graph_items(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: nodes: dict[str, dict[str, Any]] = {} edges: dict[str, dict[str, Any]] = {} def walk(value: Any) -> None: if hasattr(value, "labels") and hasattr(value, "properties"): nodes.setdefault(_node_id(value), _node_summary(value)) return if hasattr(value, "src_node") and hasattr(value, "dest_node"): edge = _edge_summary(value) key = edge["id"] or f"{edge['from']}-{edge['type']}-{edge['to']}" edges.setdefault(key, edge) return if ( hasattr(value, "nodes") and callable(getattr(value, "nodes")) and hasattr(value, "edges") and callable(getattr(value, "edges")) ): for node in value.nodes(): walk(node) for edge in value.edges(): walk(edge) return if isinstance(value, (list, tuple)): for item in value: walk(item) for row in rows: walk(row) return list(nodes.values()), list(edges.values()) def _header_names(header: Any) -> list[str]: out: list[str] = [] for item in header or []: if isinstance(item, (list, tuple)) and len(item) > 1: out.append(str(item[1])) else: out.append(str(item)) return out def _run_cypher(graph_name: str, cypher: str, limit: int) -> dict[str, Any]: graph = _get_graph(graph_name) result = graph.query(cypher) rows = list(result.result_set)[:limit] nodes, relationships = _extract_graph_items(rows) compact_rows = [[_compact_value(value) for value in row] for row in rows[:40]] return { "cypher": cypher, "columns": _header_names(result.header), "rows": compact_rows, "row_count": len(rows), "nodes": nodes, "relationships": relationships, } FEE_QUESTION_TERMS = ( "费用", "自费", "自理", "另付", "不含", "包含", "小交通", "观光车", "环保车", "电瓶车", "索道", "扶梯", "游船", "门票", "二消", "必付", "可选", ) PRICE_QUESTION_TERMS = ( "多少钱", "价格", "报价", "成人价", "儿童价", "小孩价", "房差", "单房差", "结算价", ) VEHICLE_QUESTION_TERMS = ( "车型", "车辆", "用车", "车有哪些", "什么车", "商务车", "大巴", "中巴", "小包团用车", ) COMPLEX_QUESTION_TERMS = ( "推荐", "对比", "比较", "为什么", "怎么安排", "如何安排", "适合", "帮我", "方案", "预算", "老人", "亲子", "团队", "定制", "如果", "同时", "综合", "详细行程", ) FAMILY_CONDITION_TERMS = ("老人", "老年", "长辈", "小孩", "孩子", "儿童", "亲子", "家庭", "一家") COMFORT_CONDITION_TERMS = ("不累", "不要太累", "太累", "轻松", "舒适", "少走路", "慢一点", "不赶", "休闲", "轻奢") BUDGET_CONDITION_TERMS = ("预算", "便宜", "划算", "性价比", "不贵", "低价", "经济") ROUTE_COMPARE_TERMS = ("哪个", "哪条", "对比", "比较", "更多", "更少", "包含") SCENIC_QUESTION_TERMS = ( "景区", "景点", "去哪", "去哪里", "去哪些", "游玩", "打卡", "途经", "经过", ) HOTEL_QUESTION_TERMS = ( "酒店", "住宿", "入住", "住哪里", "住哪", "附近酒店", "附近入住", "附近住宿", ) SCENIC_HINT_ALIASES: dict[str, tuple[str, ...]] = { "荔波小七孔景区": ("小七孔", "荔波"), "西江千户苗寨景区": ("西江", "千户苗寨", "苗寨"), "黄果树旅游景区": ("黄果树", "瀑布"), "梵净山景区": ("梵净山", "镇梵"), "青岩古镇": ("青岩", "青岩古镇"), "镇远古城": ("镇远", "镇远古城"), "贵阳": ("贵阳",), "安顺": ("安顺",), } FEE_EVIDENCE_TERMS = ( "raw_evidence", "ScenicTransport", "ScenicOptional", "景区费用项目", "观光车", "环保车", "电瓶车", "小交通", "索道", "扶梯", "游船", "门票", "自费", "不含", "另付", ) def _is_fee_question(question: str) -> bool: return any(term in question for term in FEE_QUESTION_TERMS) def _is_price_question(question: str) -> bool: return any(term in question for term in PRICE_QUESTION_TERMS) def _is_vehicle_question(question: str) -> bool: return any(term in question for term in VEHICLE_QUESTION_TERMS) def _has_duration_question(question: str) -> bool: return bool(_route_duration_clauses(question)) or any(term in question for term in ("几天", "多少天", "天数", "玩几天", "行程几天")) def _is_scenic_question(question: str) -> bool: return any(term in question for term in SCENIC_QUESTION_TERMS) def _is_hotel_question(question: str) -> bool: return any(term in question for term in HOTEL_QUESTION_TERMS) def _is_route_multihop_question(question: str) -> bool: if not _route_name_terms(question) or not _is_hotel_question(question): return False facets = [ _is_price_question(question), _has_duration_question(question), _is_scenic_question(question), _is_hotel_question(question), ] return sum(1 for item in facets if item) >= 3 def _is_route_compare_scenic_count_question(question: str) -> bool: terms = _route_name_terms(question) return ( len(terms) >= 2 and any(term in question for term in ROUTE_COMPARE_TERMS) and any(term in question for term in ("景点", "景区", "包含", "去的地方", "玩的地方")) and any(term in question for term in ("更多", "多", "少", "哪个", "哪条")) ) def _is_route_compare_suitability_question(question: str) -> bool: terms = _route_name_terms(question) slots = _condition_slots(question) return ( len(terms) >= 2 and (slots["family"] or slots["comfort"] or slots["budget"]) and any(term in question for term in ("哪个", "哪条", "对比", "比较", "适合", "推荐", "为什么")) ) def _condition_slots(question: str) -> dict[str, bool]: return { "family": any(term in question for term in FAMILY_CONDITION_TERMS), "comfort": any(term in question for term in COMFORT_CONDITION_TERMS), "budget": any(term in question for term in BUDGET_CONDITION_TERMS), "recommend": any(term in question for term in ("推荐", "适合", "有没有", "哪条", "帮我", "怎么选")), } def _is_condition_route_advice_question(question: str) -> bool: slots = _condition_slots(question) return ( not _route_name_terms(question) and slots["recommend"] and (slots["family"] or slots["comfort"] or slots["budget"]) ) def _normalized_question(question: str) -> str: text = re.sub(r"\s+", "", question.strip().lower()) return ( text.replace("路线", "线路") .replace("产品", "线路") .replace("报价", "价格") .replace("成人价", "价格") .replace("小孩价", "儿童价") ) PLANNER_ROUTE_BY_INTENT = { "route_catalog": "template_graph_query", "price_quote": "template_graph_query", "fee_detail": "template_graph_query", "vehicle_catalog": "template_graph_query", "route_multihop_detail": "template_graph_query", "condition_route_advice": "template_graph_query", "route_compare_suitability": "template_graph_query", "route_compare_scenic_count": "template_graph_query", "route_context_advice": "deterministic_context_llm_answer", "general_graph_qa": "llm_graph_qa", } def _planner_enabled_from_context(customer_context: dict[str, Any] | None) -> bool: if not isinstance(customer_context, dict): return False value = customer_context.get("llm_planner") if value is None: value = customer_context.get("force_llm_planner") return str(value).strip().lower() in {"1", "true", "yes", "on", "启用", "强制"} def _should_use_llm_planner(intent: dict[str, Any], customer_context: dict[str, Any] | None) -> bool: if _planner_enabled_from_context(customer_context): return True route = str(intent.get("route") or "") confidence = _safe_confidence(intent.get("confidence"), 0.0) return route == "llm_graph_qa" or confidence < 0.7 def _planner_trace( *, used: bool, skipped_reason: str = "", latency_ms: int = 0, error: str = "", decision: dict[str, Any] | None = None, ) -> dict[str, Any]: return { "used": used, "latency_ms": latency_ms, "timeout_ms": round(GRAPH_QA_PLANNER_TIMEOUT_SECONDS * 1000), "skipped_reason": skipped_reason, "error": error, "decision": decision or {}, } def _normalize_planner_decision(base_intent: dict[str, Any], decision: dict[str, Any]) -> dict[str, Any]: planned_intent = str(decision.get("intent") or "").strip() if planned_intent not in PLANNER_ROUTE_BY_INTENT: return base_intent confidence = _safe_confidence(decision.get("confidence"), _safe_confidence(base_intent.get("confidence"), 0.55)) route = PLANNER_ROUTE_BY_INTENT[planned_intent] return { **base_intent, "intent": planned_intent, "route": route, "confidence": confidence, "complexity": str(decision.get("complexity") or base_intent.get("complexity") or "planned"), "reason": str(decision.get("reason") or base_intent.get("reason") or "LLM 规划器选择链路。")[:260], "planner_need_llm_graph": bool(decision.get("need_llm_graph")) or route == "llm_graph_qa", } async def _llm_plan_intent( question: str, graph_name: str, base_intent: dict[str, Any], customer_context: dict[str, Any] | None, ) -> tuple[dict[str, Any], dict[str, Any]]: if not _should_use_llm_planner(base_intent, customer_context): return base_intent, _planner_trace(used=False, skipped_reason="high_confidence_template_or_context_route") client = await _graph_qa_client(max_tokens=320, use_config_max_tokens=False) if client is None: return base_intent, _planner_trace(used=False, skipped_reason="llm_not_configured") client.timeout = min(float(getattr(client, "timeout", GRAPH_QA_PLANNER_TIMEOUT_SECONDS) or GRAPH_QA_PLANNER_TIMEOUT_SECONDS), GRAPH_QA_PLANNER_TIMEOUT_SECONDS) payload = { "question": question, "graph_name": graph_name, "rule_intent": base_intent, "route_terms": _route_name_terms(question), "condition_slots": _condition_slots(question), "allowed_intents": list(PLANNER_ROUTE_BY_INTENT.keys()), "customer_context": customer_context or {}, } started = time.perf_counter() try: decision = await _chat_json_timed( client, GRAPH_QA_PLANNER_SYS, json.dumps(payload, ensure_ascii=False), timeout_seconds=GRAPH_QA_PLANNER_TIMEOUT_SECONDS, attempts=1, ) except asyncio.TimeoutError: latency_ms = max(1, round((time.perf_counter() - started) * 1000)) return base_intent, _planner_trace(used=True, latency_ms=latency_ms, error="planner_timeout") except Exception as exc: # noqa: BLE001 latency_ms = max(1, round((time.perf_counter() - started) * 1000)) return base_intent, _planner_trace(used=True, latency_ms=latency_ms, error=str(exc)[:220]) latency_ms = max(1, round((time.perf_counter() - started) * 1000)) planned = _normalize_planner_decision(base_intent, decision) return planned, _planner_trace(used=True, latency_ms=latency_ms, decision=decision) def _classify_graph_qa_intent(question: str) -> dict[str, Any]: normalized = _normalized_question(question) terms = _route_name_terms(question) duration_clauses = _route_duration_clauses(question) complex_hint = len(question) > 72 or any(term in question for term in COMPLEX_QUESTION_TERMS) intent = { "intent": "general_graph_qa", "route": "llm_graph_qa", "confidence": 0.48 if complex_hint else 0.58, "complexity": "complex" if complex_hint else "open", "slots": { "route_terms": terms, "has_duration": bool(duration_clauses), }, "reason": "未命中标准模板,使用 LLM 生成 Cypher 做通用图查询。", } if _is_route_list_question(question): return { **intent, "intent": "route_catalog", "route": "template_graph_query", "confidence": 0.95, "complexity": "standard", "reason": "线路清单类问题可由 TourProduct 固定模板回答。", } if _is_condition_route_advice_question(question): return { **intent, "intent": "condition_route_advice", "route": "template_graph_query", "confidence": 0.82, "complexity": "standard_condition_filter", "slots": { **intent["slots"], "condition_slots": _condition_slots(question), }, "reason": "未指定线路但给出家庭/舒适/预算条件,使用固定图查询筛选候选线路并模板回答。", } if _is_route_compare_suitability_question(question): return { **intent, "intent": "route_compare_suitability", "route": "template_graph_query", "confidence": 0.84, "complexity": "standard_suitability_compare", "slots": { **intent["slots"], "condition_slots": _condition_slots(question), }, "reason": "多线路适配度对比已识别条件词,可固定取证后用评分模板快速回答。", } if _is_route_compare_scenic_count_question(question): return { **intent, "intent": "route_compare_scenic_count", "route": "template_graph_query", "confidence": 0.86, "complexity": "standard_compare", "reason": "线路景点数量对比可由固定取证统计 ScenicAttraction 数量回答。", } if _is_vehicle_question(question) and any(term in normalized for term in ("车型", "车辆", "用车", "车")): return { **intent, "intent": "vehicle_catalog", "route": "template_graph_query", "confidence": 0.88, "complexity": "standard", "reason": "车辆/车型类问题可由 TravelItem 用车项目固定模板回答。", } if _is_route_multihop_question(question): return { **intent, "intent": "route_multihop_detail", "route": "template_graph_query", "confidence": 0.91, "complexity": "standard_multihop", "reason": "线路价格、天数、途经景区和附近酒店属于标准多跳图谱查询,可用固定模板快速回答。", } if _is_price_question(question) and terms: return { **intent, "intent": "price_quote", "route": "template_graph_query", "confidence": 0.9, "complexity": "standard", "reason": "线路报价类问题已识别线路实体和天数,可直接查 ProductPricePlan。", } if _is_fee_question(question) and terms: return { **intent, "intent": "fee_detail", "route": "template_graph_query", "confidence": 0.86, "complexity": "standard", "reason": "费用/小交通类问题已识别线路实体,可直接查 TravelItem。", } if complex_hint and terms: return { **intent, "intent": "route_context_advice", "route": "deterministic_context_llm_answer", "confidence": 0.76, "complexity": "complex_with_entities", "reason": "复杂推荐/对比问题已识别线路实体,先用固定 Cypher 取证据,再让 LLM 基于证据组织答案。", } return intent def _has_fee_evidence(graph_result: dict[str, Any]) -> bool: if not graph_result.get("row_count"): return False text = json.dumps( { "rows": graph_result.get("rows") or [], "nodes": graph_result.get("nodes") or [], "relationships": graph_result.get("relationships") or [], }, ensure_ascii=False, default=str, ) return any(term in text for term in FEE_EVIDENCE_TERMS) def _cypher_quote(value: str) -> str: return value.replace("\\", "\\\\").replace("'", "\\'") def _is_valid_route_term(term: str) -> bool: text = term.strip() if len(text) < 2: return False noisy_fragments = ( "适合", "推荐", "有没有", "不要", "不累", "太累", "轻松", "舒适", "老人", "小孩", "孩子", "儿童", "家庭", "预算", "便宜", "划算", "哪些", "什么", "哪个", "多少", "可以", "附近", "入住", "酒店", ) if any(fragment in text for fragment in noisy_fragments): return False return True def _route_name_terms(question: str) -> list[str]: terms: list[str] = [] if "小西镇梵" in question: terms.append("小西镇梵") if "黄小西镇梵" in question: terms.append("黄小西镇梵") if "黄小西" in question: terms.append("黄小西") elif "小西" in question: terms.append("小西") for term in ("黄果树", "小七孔", "荔波", "西江", "千户苗寨", "青岩", "梵净山", "镇远"): if term in question: terms.append(term) if not terms: for match in re.findall(r"[\u4e00-\u9fa5A-Za-z0-9]{2,12}(?:游|线路|产品|团)", question): terms.append(match.replace("线路", "").replace("产品", "").replace("团", "")) out: list[str] = [] for term in terms: if _is_valid_route_term(term) and term not in out: out.append(term) return out[:6] def _route_duration_clauses(question: str) -> list[str]: match = re.search(r"(\d+)\s*(?:日|天)", question) days = int(match.group(1)) if match else None chinese_days = {"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7} for text, value in chinese_days.items(): if f"{text}日" in question or f"{text}天" in question: days = value break if not days: return [] return [ f"p.duration_days = {days}", f"p.name CONTAINS '{days}日'", f"p.name CONTAINS '{days}天'", f"p.name CONTAINS '{list(chinese_days.keys())[list(chinese_days.values()).index(days)]}日'" if days in chinese_days.values() else "", ] def _fee_fallback_cypher(question: str, limit: int) -> str | None: terms = _route_name_terms(question) if not terms: return None name_clauses: list[str] = [] for term in terms: quoted = _cypher_quote(term) name_clauses.extend([ f"p.name CONTAINS '{quoted}'", f"p.display_name CONTAINS '{quoted}'", f"p.product_name CONTAINS '{quoted}'", ]) duration_clauses = [item for item in _route_duration_clauses(question) if item] where_parts = [f"({' OR '.join(name_clauses)})"] if duration_clauses: where_parts.append(f"({' OR '.join(duration_clauses)})") where_clause = " AND ".join(where_parts) return f""" MATCH (p:TourProduct) WHERE {where_clause} OPTIONAL MATCH (p)-[:PRODUCT_USES_TRAVEL_ITEM]->(direct_item:TravelItem) OPTIONAL MATCH (p)-[:PRODUCT_HAS_DAY]->(d:ProductDay)-[:DAY_HAS_STOP]->(s:RouteStop) OPTIONAL MATCH (s)-[:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction) OPTIONAL MATCH (s)-[:STOP_USES_TRAVEL_ITEM]->(stop_item:TravelItem) OPTIONAL MATCH (a)-[:ATTRACTION_HAS_ITEM]->(attraction_item:TravelItem) RETURN p.name AS product_name, p.product_id AS product_id, p.duration_days AS duration_days, collect(DISTINCT {{ name: direct_item.name, type: direct_item.type, subtype: direct_item.subtype, category: direct_item.category, status: direct_item.default_status, price: direct_item.price, adult_price: direct_item.adult_price, unit: direct_item.unit, raw_evidence: direct_item.raw_evidence, requires_supplier_confirm: direct_item.requires_supplier_confirm }}) AS product_items, collect(DISTINCT {{ stop_name: s.name, attraction_name: a.name, name: stop_item.name, type: stop_item.type, subtype: stop_item.subtype, category: stop_item.category, status: stop_item.default_status, price: stop_item.price, adult_price: stop_item.adult_price, unit: stop_item.unit, raw_evidence: stop_item.raw_evidence, requires_supplier_confirm: stop_item.requires_supplier_confirm }}) AS stop_items, collect(DISTINCT {{ stop_name: s.name, attraction_name: a.name, name: attraction_item.name, type: attraction_item.type, subtype: attraction_item.subtype, category: attraction_item.category, status: attraction_item.default_status, price: attraction_item.price, adult_price: attraction_item.adult_price, unit: attraction_item.unit, raw_evidence: attraction_item.raw_evidence, requires_supplier_confirm: attraction_item.requires_supplier_confirm }}) AS attraction_items LIMIT {limit} """.strip() def _price_fallback_cypher(question: str, limit: int) -> str | None: terms = _route_name_terms(question) if not terms: return None name_clauses: list[str] = [] for term in terms: quoted = _cypher_quote(term) name_clauses.extend([ f"p.name CONTAINS '{quoted}'", f"p.display_name CONTAINS '{quoted}'", f"p.product_name CONTAINS '{quoted}'", ]) duration_clauses = [item for item in _route_duration_clauses(question) if item] where_parts = [f"({' OR '.join(name_clauses)})"] if duration_clauses: where_parts.append(f"({' OR '.join(duration_clauses)})") where_clause = " AND ".join(where_parts) return f""" MATCH (p:TourProduct) WHERE {where_clause} OPTIONAL MATCH (p)-[:PRODUCT_HAS_PRICE_PLAN]->(plan:ProductPricePlan) RETURN p.name AS product_name, p.product_id AS product_id, p.duration_days AS duration_days, collect(DISTINCT {{ plan_name: plan.plan_name, display_name: plan.display_name, adult_price_min: plan.adult_price_min, adult_price_max: plan.adult_price_max, child_price_min: plan.child_price_min, child_price_max: plan.child_price_max, single_room_diff_min: plan.single_room_diff_min, single_room_diff_max: plan.single_room_diff_max, adult_settlement_source: plan.adult_settlement_source, child_settlement_source: plan.child_settlement_source }}) AS price_plans LIMIT {limit} """.strip() def _route_catalog_cypher(limit: int) -> str: return f""" MATCH (p:TourProduct) RETURN p.name AS product_name, p.display_name AS display_name, p.product_id AS product_id, p.duration_days AS duration_days ORDER BY p.duration_days, p.name LIMIT {limit} """.strip() def _vehicle_catalog_cypher(limit: int) -> str: return f""" MATCH (item:TravelItem) WHERE item.name CONTAINS '车' OR item.subtype CONTAINS '用车' OR item.category CONTAINS '用车' RETURN item.name AS name, item.type AS type, item.subtype AS subtype, item.price AS price, item.unit AS unit, item.raw_evidence AS raw_evidence ORDER BY item.name LIMIT {limit} """.strip() def _route_context_cypher(question: str, limit: int) -> str | None: terms = _route_name_terms(question) if not terms: return None name_clauses: list[str] = [] for term in terms: quoted = _cypher_quote(term) name_clauses.extend([ f"p.name CONTAINS '{quoted}'", f"p.display_name CONTAINS '{quoted}'", f"p.product_name CONTAINS '{quoted}'", ]) where_clause = f"({' OR '.join(name_clauses)})" return f""" MATCH (p:TourProduct) WHERE {where_clause} OPTIONAL MATCH (p)-[:PRODUCT_HAS_PRICE_PLAN]->(plan:ProductPricePlan) OPTIONAL MATCH (p)-[:PRODUCT_HAS_DAY]->(d:ProductDay)-[:DAY_HAS_STOP]->(s:RouteStop) OPTIONAL MATCH (s)-[:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction) RETURN p.name AS product_name, p.product_id AS product_id, p.duration_days AS duration_days, collect(DISTINCT {{ plan_name: plan.plan_name, adult_price_min: plan.adult_price_min, adult_price_max: plan.adult_price_max, child_price_min: plan.child_price_min, child_price_max: plan.child_price_max, single_room_diff_min: plan.single_room_diff_min, single_room_diff_max: plan.single_room_diff_max }}) AS price_plans, collect(DISTINCT {{ day: d.day_index, stop_name: s.name, attraction_name: a.name, sequence: s.sequence }}) AS route_points, count(DISTINCT a) AS attraction_count LIMIT {min(limit, 40)} """.strip() def _condition_context_cypher(limit: int) -> str: return f""" MATCH (p:TourProduct) OPTIONAL MATCH (p)-[:PRODUCT_HAS_PRICE_PLAN]->(plan:ProductPricePlan) OPTIONAL MATCH (p)-[:PRODUCT_HAS_DAY]->(d:ProductDay)-[:DAY_HAS_STOP]->(s:RouteStop) OPTIONAL MATCH (s)-[:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction) RETURN p.name AS product_name, p.product_id AS product_id, p.duration_days AS duration_days, collect(DISTINCT {{ plan_name: plan.plan_name, adult_price_min: plan.adult_price_min, adult_price_max: plan.adult_price_max, child_price_min: plan.child_price_min, child_price_max: plan.child_price_max, single_room_diff_min: plan.single_room_diff_min, single_room_diff_max: plan.single_room_diff_max }}) AS price_plans, collect(DISTINCT {{ day: d.day_index, stop_name: s.name, attraction_name: a.name, sequence: s.sequence }}) AS route_points, count(DISTINCT a) AS attraction_count ORDER BY p.duration_days, p.name LIMIT {min(limit, 80)} """.strip() def _scenic_hint_terms(question: str) -> list[str]: hints: list[str] = [] for scenic_name, aliases in SCENIC_HINT_ALIASES.items(): if scenic_name in question or any(alias in question for alias in aliases): hints.append(scenic_name) hints.extend(aliases) out: list[str] = [] for hint in hints: if hint and hint not in out: out.append(hint) return out def _route_feature_terms(question: str) -> list[str]: candidates = ("轻奢", "纯玩", "2+1", "头等舱", "精品", "小团", "私家团", "跟团", "亲子") return [term for term in candidates if term in question] def _route_multihop_cypher(question: str, limit: int) -> str | None: terms = _route_name_terms(question) if not terms: return None name_clauses: list[str] = [] for term in terms + _route_feature_terms(question): quoted = _cypher_quote(term) name_clauses.extend([ f"p.name CONTAINS '{quoted}'", f"p.display_name CONTAINS '{quoted}'", f"p.product_name CONTAINS '{quoted}'", ]) duration_clauses = [item for item in _route_duration_clauses(question) if item] where_parts = [f"({' OR '.join(name_clauses)})"] if duration_clauses: where_parts.append(f"({' OR '.join(duration_clauses)})") where_clause = " AND ".join(where_parts) return f""" MATCH (p:TourProduct) WHERE {where_clause} OPTIONAL MATCH (p)-[:PRODUCT_HAS_PRICE_PLAN]->(plan:ProductPricePlan) OPTIONAL MATCH (p)-[:PRODUCT_HAS_DAY]->(d:ProductDay)-[:DAY_HAS_STOP]->(s:RouteStop) OPTIONAL MATCH (s)-[:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction) OPTIONAL MATCH (a)-[near:ATTRACTION_NEARBY_HOTEL]-(h:Hotel) RETURN p.name AS product_name, p.product_id AS product_id, p.duration_days AS duration_days, collect(DISTINCT {{ plan_name: plan.plan_name, display_name: plan.display_name, adult_price_min: plan.adult_price_min, adult_price_max: plan.adult_price_max, child_price_min: plan.child_price_min, child_price_max: plan.child_price_max, single_room_diff_min: plan.single_room_diff_min, single_room_diff_max: plan.single_room_diff_max }}) AS price_plans, collect(DISTINCT {{ day: d.day_index, sequence: s.sequence, stop_name: s.name, attraction_name: a.name }}) AS route_points, collect(DISTINCT {{ scenic_name: a.name, stop_name: s.name, hotel_name: h.name, star_rating: h.star_rating, city: h.city, county: h.county, address: h.address, features: h.features, driving_distance_km: near.driving_distance_km, driving_time_minutes: near.driving_time_minutes, recommend_rank: near.recommend_rank, usage_note: near.usage_note, remark: near.remark }}) AS nearby_hotels LIMIT {min(limit, 60)} """.strip() def _schema_snapshot(graph_name: str) -> dict[str, Any]: now = time.monotonic() cached = _SCHEMA_CACHE.get(graph_name) if cached and now - cached[0] <= SCHEMA_CACHE_TTL_SECONDS: return cached[1] graph = _get_graph(graph_name) labels: list[dict[str, Any]] = [] relations: list[dict[str, Any]] = [] samples: list[dict[str, Any]] = [] try: for labels_value, count in graph.query( "MATCH (n) RETURN labels(n) AS labels, count(n) AS count LIMIT 120" ).result_set: label = labels_value[0] if labels_value else "" labels.append({"label": label, "count": count}) except Exception: labels = [] try: for rel_type, count in graph.query( "MATCH ()-[r]->() RETURN type(r) AS type, count(r) AS count LIMIT 160" ).result_set: relations.append({"type": rel_type, "count": count}) except Exception: relations = [] for item in labels[:12]: label = item.get("label") if not label or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", str(label)): continue try: rows = graph.query(f"MATCH (n:{label}) RETURN n LIMIT 1").result_set except Exception: continue for row in rows: node = row[0] props = dict(getattr(node, "properties", None) or {}) samples.append({ "label": label, "properties": { str(k): _compact_scalar(v, 120) for k, v in list(props.items())[:12] }, }) snapshot = {"labels": labels[:80], "relations": relations[:100], "node_samples": samples[:16]} _SCHEMA_CACHE[graph_name] = (now, snapshot) return snapshot def _list_texts(value: Any, limit: int, text_limit: int) -> list[str]: if not isinstance(value, list): return [] out: list[str] = [] for item in value[:limit]: text = re.sub(r"\s+", " ", str(item or "")).strip() if text: out.append(text[:text_limit]) return out def _is_route_list_question(question: str) -> bool: return any(term in question for term in ("线路有哪些", "路线有哪些", "产品有哪些", "有哪些线路", "有哪些路线", "有哪些产品", "旅行车线路")) def _route_items_from_rows(graph_result: dict[str, Any]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] seen: set[str] = set() for row in graph_result.get("rows") or []: if not isinstance(row, list) or not row: continue name = "" duration = None for value in row: if isinstance(value, str) and value.strip(): name = value.strip() break for value in row: if isinstance(value, (int, float)) and 0 < value <= 30: duration = int(value) break if name and name not in seen: seen.add(name) items.append({"name": name, "duration_days": duration}) return items def _money(value: Any) -> str: if value in (None, ""): return "" try: number = float(value) except Exception: return str(value) if number.is_integer(): return str(int(number)) return f"{number:.2f}".rstrip("0").rstrip(".") def _money_range(min_value: Any, max_value: Any, unit: str = "元/人") -> str: low = _money(min_value) high = _money(max_value) if low and high and low != high: return f"{low}-{high}{unit}" if low or high: return f"{low or high}{unit}" return "" def _price_plans_from_rows(graph_result: dict[str, Any]) -> list[dict[str, Any]]: products: list[dict[str, Any]] = [] for row in graph_result.get("rows") or []: if not isinstance(row, list) or len(row) < 4: continue product_name = str(row[0] or "").strip() if not product_name: continue plans: list[dict[str, Any]] = [] for raw_plan in row[3] if isinstance(row[3], list) else []: if not isinstance(raw_plan, dict): continue if not any(raw_plan.get(k) not in (None, "") for k in ( "plan_name", "display_name", "adult_price_min", "adult_price_max", "child_price_min", "child_price_max", "single_room_diff_min", "single_room_diff_max", )): continue plans.append(raw_plan) products.append({ "product_name": product_name, "product_id": row[1] if len(row) > 1 else "", "duration_days": row[2] if len(row) > 2 else None, "plans": plans, }) return products def _route_list_answer(question: str, graph_result: dict[str, Any]) -> tuple[str, str] | None: if not _is_route_list_question(question): return None items = _route_items_from_rows(graph_result) if not items: return None lines = [ f"当前图谱共查到 {len(items)} 条旅行线路:", *[ f"{idx}. {item['name']}" + (f"({item['duration_days']}日)" if item.get("duration_days") else "") for idx, item in enumerate(items, start=1) ], "如需进一步报价,请继续指定线路名称、出发日期、人数和住宿偏好。", ] answer = "\n".join(lines) preview_items = items[:10] customer = "目前可查到 " + str(len(items)) + " 条旅行线路,主要包括:" + "、".join( item["name"] + (f"({item['duration_days']}日)" if item.get("duration_days") else "") for item in preview_items ) if len(items) > len(preview_items): customer += f"等,剩余 {len(items) - len(preview_items)} 条可继续筛选。" else: customer += "。" customer += "您想看哪条线路的价格、行程或余位?" return answer, customer def _price_template_answer(question: str, graph_result: dict[str, Any]) -> tuple[str, str, list[dict[str, str]], list[dict[str, Any]]] | None: products = _price_plans_from_rows(graph_result) if not products: return None lines = [f"已在图谱中匹配到 {len(products)} 条相关线路报价:"] evidence: list[dict[str, str]] = [] plans: list[dict[str, Any]] = [] for idx, product in enumerate(products[:8], start=1): title = str(product["product_name"]) day_suffix = f"({product['duration_days']}日)" if product.get("duration_days") else "" plan_lines: list[str] = [] for plan in product["plans"][:8]: plan_name = str(plan.get("display_name") or plan.get("plan_name") or "默认报价档位") adult = _money_range(plan.get("adult_price_min"), plan.get("adult_price_max")) child = _money_range(plan.get("child_price_min"), plan.get("child_price_max")) room = _money_range(plan.get("single_room_diff_min"), plan.get("single_room_diff_max"), "元") parts = [plan_name] if adult: parts.append(f"成人 {adult}") if child: parts.append(f"儿童 {child}") if room: parts.append(f"单房差 {room}") plan_lines.append(",".join(parts)) if not plan_lines: plan_lines.append("图谱暂未录入明确报价档位,需要按团期核价") lines.append(f"{idx}. {title}{day_suffix}:" + ";".join(plan_lines)) summary = ";".join(plan_lines)[:360] evidence.append({ "type": "报价证据", "name": title[:120], "summary": summary, "source": "FalkorDB 图查询 ProductPricePlan", }) plans.append({ "rank": idx, "label": "线路报价", "plan_name": title, "product_name": title, "fit_score": max(60, 96 - idx * 3), "match_reasons": ["命中线路名称/天数", "读取 ProductPricePlan 报价档位"], "route_summary": f"{title}{day_suffix}", "quote_summary": summary, "variant_summary": summary, "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "fast_price_template", }) lines.append("以上为图谱报价档位,最终价格、余位、房型和儿童口径需要按具体出发日期二次核实。") answer = "\n".join(lines) first = products[0] first_plans = first.get("plans") or [] if first_plans: plan = first_plans[0] adult = _money_range(plan.get("adult_price_min"), plan.get("adult_price_max")) child = _money_range(plan.get("child_price_min"), plan.get("child_price_max")) bits = [str(first["product_name"])] if adult: bits.append(f"成人参考 {adult}") if child: bits.append(f"儿童参考 {child}") customer = ",".join(bits) + "。具体价格和余位需要按出发日期、人数、住宿档位再核实。" else: customer = f"已匹配到 {first['product_name']},但图谱暂未给出明确报价档位,需要按出发日期和人数核价。" return answer, customer, evidence, plans def _non_empty_maps(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] out: list[dict[str, Any]] = [] for item in value: if isinstance(item, dict) and any(v not in (None, "") for v in item.values()): out.append(item) return out def _route_match_score(question: str, product: dict[str, Any]) -> int: name = str(product.get("product_name") or "") score = 0 for term in _route_name_terms(question): if term and term in name: score += 12 for term in _route_feature_terms(question): if term and term in name: score += 18 match = re.search(r"(\d+)\s*(?:日|天)", question) if match and _safe_int(product.get("duration_days"), 0) == int(match.group(1)): score += 15 for chinese, value in {"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5}.items(): if (f"{chinese}日" in question or f"{chinese}天" in question) and _safe_int(product.get("duration_days"), 0) == value: score += 15 return score def _route_multihop_products_from_rows(question: str, graph_result: dict[str, Any]) -> list[dict[str, Any]]: products: list[dict[str, Any]] = [] for row in graph_result.get("rows") or []: if not isinstance(row, list) or len(row) < 6: continue product = { "product_name": str(row[0] or "").strip(), "product_id": row[1] if len(row) > 1 else "", "duration_days": row[2] if len(row) > 2 else None, "price_plans": _non_empty_maps(row[3]), "route_points": _non_empty_maps(row[4]), "nearby_hotels": _non_empty_maps(row[5]), } if product["product_name"]: product["match_score"] = _route_match_score(question, product) products.append(product) products.sort(key=lambda item: (item.get("match_score") or 0, -_safe_int(item.get("duration_days"), 99)), reverse=True) return products def _aggregate_price_text(plans: list[dict[str, Any]]) -> tuple[str, str, str]: adult_lows: list[float] = [] adult_highs: list[float] = [] child_lows: list[float] = [] child_highs: list[float] = [] for plan in plans: for bucket, key in ((adult_lows, "adult_price_min"), (adult_highs, "adult_price_max"), (child_lows, "child_price_min"), (child_highs, "child_price_max")): try: value = float(plan.get(key)) except Exception: continue if value > 0: bucket.append(value) adult = _money_range(min(adult_lows) if adult_lows else None, max(adult_highs) if adult_highs else None) child = _money_range(min(child_lows) if child_lows else None, max(child_highs) if child_highs else None) samples: list[str] = [] for plan in plans[:5]: name = str(plan.get("display_name") or plan.get("plan_name") or "报价档位").strip() adult_item = _money_range(plan.get("adult_price_min"), plan.get("adult_price_max")) child_item = _money_range(plan.get("child_price_min"), plan.get("child_price_max")) parts = [name] if adult_item: parts.append(f"成人 {adult_item}") if child_item: parts.append(f"儿童 {child_item}") samples.append(",".join(parts)) return adult, child, ";".join(samples) def _route_point_key(point: dict[str, Any]) -> tuple[int, int, str]: return ( _safe_int(point.get("day"), 99), _safe_int(point.get("sequence"), 99), str(point.get("attraction_name") or point.get("stop_name") or ""), ) def _is_non_scenic_route_node(name: str) -> bool: compact = re.sub(r"\s+", "", name) if any(term in compact for term in ("送机", "送站", "接机", "接站", "机场", "高铁站", "火车站", "散团", "集合")): return True if compact in {"贵阳", "安顺", "贵阳/安顺", "贵阳安顺"}: return True return bool(re.fullmatch(r"(贵阳|安顺|遵义|凯里|铜仁|荔波|西江)(/|、)(贵阳|安顺|遵义|凯里|铜仁|荔波|西江)", compact)) def _route_scenic_names(points: list[dict[str, Any]], limit: int = 12) -> list[str]: out: list[str] = [] for point in sorted(points, key=_route_point_key): name = str(point.get("attraction_name") or point.get("stop_name") or "").strip() if _is_non_scenic_route_node(name): continue if name and name not in out: out.append(name) if len(out) >= limit: break return out def _hotel_sort_key(item: dict[str, Any]) -> tuple[int, float, str]: rank = _safe_int(item.get("recommend_rank"), 999) try: distance = float(item.get("driving_distance_km")) except Exception: distance = 9999.0 return rank, distance, str(item.get("hotel_name") or "") def _filter_hotels(question: str, hotels: list[dict[str, Any]], limit: int = 8) -> list[dict[str, Any]]: hints = _scenic_hint_terms(question) out: list[dict[str, Any]] = [] seen: set[str] = set() for item in sorted(hotels, key=_hotel_sort_key): hotel_name = str(item.get("hotel_name") or "").strip() if not hotel_name: continue scenic_text = " ".join(str(item.get(field) or "") for field in ("scenic_name", "stop_name", "county", "city", "address")) if hints and not any(hint and hint in scenic_text for hint in hints): continue key = f"{item.get('scenic_name') or item.get('stop_name')}|{hotel_name}" if key in seen: continue seen.add(key) out.append(item) if len(out) >= limit: break if out or hints: return out for item in sorted(hotels, key=_hotel_sort_key): hotel_name = str(item.get("hotel_name") or "").strip() if not hotel_name: continue key = f"{item.get('scenic_name') or item.get('stop_name')}|{hotel_name}" if key in seen: continue seen.add(key) out.append(item) if len(out) >= limit: break return out def _hotel_line(item: dict[str, Any]) -> str: name = str(item.get("hotel_name") or "").strip() scenic = str(item.get("scenic_name") or item.get("stop_name") or "相关景区").strip() star = str(item.get("star_rating") or "").strip() location = " ".join(str(item.get(field) or "").strip() for field in ("county", "address") if item.get(field)).strip() distance = _money(item.get("driving_distance_km")) minutes = _money(item.get("driving_time_minutes")) parts = [name] if star: parts.append(star) if distance: parts.append(f"距{scenic}约{distance}km") if minutes: parts.append(f"车程约{minutes}分钟") if location: parts.append(location) return ",".join(parts) def _route_multihop_template_answer(question: str, graph_result: dict[str, Any]) -> tuple[str, str, list[dict[str, str]], list[dict[str, Any]]] | None: products = _route_multihop_products_from_rows(question, graph_result) if not products: return None product = products[0] product_name = str(product["product_name"]) day_text = f"{product['duration_days']}天" if product.get("duration_days") else "天数待核实" adult_price, child_price, price_samples = _aggregate_price_text(product["price_plans"]) scenic_names = _route_scenic_names(product["route_points"]) hotels = _filter_hotels(question, product["nearby_hotels"], limit=8) price_parts = [] if adult_price: price_parts.append(f"成人参考 {adult_price}") if child_price: price_parts.append(f"儿童参考 {child_price}") price_text = ",".join(price_parts) or "图谱暂未录入明确报价档位" scenic_text = "、".join(scenic_names) if scenic_names else "图谱暂未查到明确途经景区" hotel_texts = [_hotel_line(item) for item in hotels[:6]] if hotel_texts: hotel_text = ";".join(hotel_texts) elif _scenic_hint_terms(question): hotel_text = "该景区附近酒店证据不足,需要按具体入住地再核实。" else: hotel_text = "请指定一个景区后,我可以继续筛选附近可住酒店。" lines = [ f"已按百姓惠图谱匹配到线路:{product_name}。", f"1. 游玩天数:{day_text}。", f"2. 参考价格:{price_text}。", f"3. 期间可去景区/节点:{scenic_text}。", f"4. 附近酒店参考:{hotel_text}", ] if price_samples: lines.append(f"报价档位示例:{price_samples}。") lines.append("以上为图谱证据口径,最终价格、房型、余位、入住酒店和景区政策需按出发日期二次核实。") answer = "\n".join(lines) customer_bits = [f"{product_name} 是 {day_text}线路"] if price_text: customer_bits.append(price_text) if scenic_names: customer_bits.append("可玩 " + "、".join(scenic_names[:5])) if hotels: customer_bits.append("附近可参考 " + "、".join(str(item.get("hotel_name") or "") for item in hotels[:3] if item.get("hotel_name"))) customer_reply = "亲," + ";".join(customer_bits) + "。具体价格、房型和余位需要按出发日期再确认。" evidence = [ { "type": "线路证据", "name": product_name[:120], "summary": f"{product_name};{day_text};{price_text}", "source": "FalkorDB 图查询 TourProduct/ProductPricePlan", }, { "type": "行程景区证据", "name": "途经景区", "summary": scenic_text[:360], "source": "FalkorDB 图查询 ProductDay/RouteStop/ScenicAttraction", }, ] if hotel_texts: evidence.append({ "type": "酒店证据", "name": "景区附近酒店", "summary": ";".join(hotel_texts[:4])[:360], "source": "FalkorDB 图查询 ATTRACTION_NEARBY_HOTEL/Hotel", }) plans = [{ "rank": 1, "label": "线路多跳问答", "plan_name": product_name, "product_name": product_name, "fit_score": 94, "match_reasons": ["命中线路实体", "读取报价、天数、景区和附近酒店多跳证据"], "route_summary": f"{product_name};{day_text};{scenic_text}", "quote_summary": price_text, "variant_summary": hotel_text, "daily_itinerary": [ { "day": point.get("day"), "title": point.get("attraction_name") or point.get("stop_name"), "summary": point.get("stop_name") or "", } for point in sorted(product["route_points"], key=_route_point_key)[:12] if point.get("attraction_name") or point.get("stop_name") ], "hotels": [ { "name": item.get("hotel_name"), "scenic_name": item.get("scenic_name") or item.get("stop_name"), "summary": _hotel_line(item), } for item in hotels[:6] ], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "fast_route_multihop_template", }] return answer, customer_reply, evidence, plans def _valid_travel_item(item: Any) -> bool: return isinstance(item, dict) and bool(str(item.get("name") or "").strip()) def _dedupe_travel_items(items: list[dict[str, Any]], limit: int = 18) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] seen: set[str] = set() for item in items: if not _valid_travel_item(item): continue key = "|".join(str(item.get(field) or "") for field in ("name", "price", "adult_price", "unit", "status")) if key in seen: continue seen.add(key) out.append(item) if len(out) >= limit: break return out def _fee_products_from_rows(graph_result: dict[str, Any]) -> list[dict[str, Any]]: products: list[dict[str, Any]] = [] for row in graph_result.get("rows") or []: if not isinstance(row, list) or len(row) < 6: continue items: list[dict[str, Any]] = [] for group in row[3:6]: if isinstance(group, list): items.extend(item for item in group if isinstance(item, dict)) products.append({ "product_name": str(row[0] or "").strip(), "product_id": row[1] if len(row) > 1 else "", "duration_days": row[2] if len(row) > 2 else None, "items": _dedupe_travel_items(items), }) return [product for product in products if product["product_name"] and product["items"]] def _item_price_text(item: dict[str, Any]) -> str: price = _money(item.get("adult_price") or item.get("price")) unit = str(item.get("unit") or "人").strip() if price: return f"{price}元/{unit}" return "需核价" def _fee_template_answer(question: str, graph_result: dict[str, Any]) -> tuple[str, str, list[dict[str, str]], list[dict[str, Any]]] | None: products = _fee_products_from_rows(graph_result) if not products: return None lines = [f"已在图谱中匹配到 {len(products)} 条线路的费用/小交通项目:"] evidence: list[dict[str, str]] = [] plans: list[dict[str, Any]] = [] for idx, product in enumerate(products[:6], start=1): title = product["product_name"] day_suffix = f"({product['duration_days']}日)" if product.get("duration_days") else "" item_texts: list[str] = [] for item in product["items"][:10]: item_text = f"{item.get('name')}:{_item_price_text(item)}" status = str(item.get("status") or item.get("default_status") or "").strip() if status: item_text += f"({status})" item_texts.append(item_text) summary = ";".join(item_texts) lines.append(f"{idx}. {title}{day_suffix}:" + summary) evidence.append({ "type": "费用证据", "name": title[:120], "summary": summary[:360], "source": "FalkorDB 图查询 TravelItem", }) plans.append({ "rank": idx, "label": "费用/小交通", "plan_name": title, "product_name": title, "fit_score": max(60, 94 - idx * 3), "match_reasons": ["命中线路名称/天数", "读取 TravelItem 费用项目"], "route_summary": f"{title}{day_suffix}", "quote_summary": summary[:360], "variant_summary": summary[:360], "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "fast_fee_template", }) lines.append("以上项目按图谱记录展示,景区门票、小交通和可选项目需按团期及景区政策二次核实。") answer = "\n".join(lines) first = products[0] preview = ";".join(f"{item.get('name')} {_item_price_text(item)}" for item in first["items"][:4]) customer = f"{first['product_name']} 相关费用项目包括:{preview}。具体是否必含、是否可选和儿童政策需要按团期再核实。" return answer, customer, evidence, plans def _vehicle_items_from_rows(graph_result: dict[str, Any]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for row in graph_result.get("rows") or []: if not isinstance(row, list) or not row: continue name = str(row[0] or "").strip() if not name: continue items.append({ "name": name, "type": row[1] if len(row) > 1 else "", "subtype": row[2] if len(row) > 2 else "", "price": row[3] if len(row) > 3 else None, "unit": row[4] if len(row) > 4 else "", "raw_evidence": row[5] if len(row) > 5 else "", }) return _dedupe_travel_items(items, limit=30) def _vehicle_template_answer(question: str, graph_result: dict[str, Any]) -> tuple[str, str, list[dict[str, str]], list[dict[str, Any]]] | None: items = _vehicle_items_from_rows(graph_result) if not items: return None lines = [ f"当前图谱查到 {len(items)} 个用车/车型相关项目:", *[ f"{idx}. {item['name']}" + (f":{_item_price_text(item)}" if item.get("price") not in (None, "") else "") for idx, item in enumerate(items, start=1) ], "具体车型是否可用、座位数、车辆档位和结算口径需要按团期及供应商确认。", ] answer = "\n".join(lines) preview = "、".join( item["name"] + (f"({_item_price_text(item)})" if item.get("price") not in (None, "") else "") for item in items[:8] ) customer = f"目前图谱里可查到 {len(items)} 个用车相关项目,主要包括:{preview}。具体车辆安排需按出发日期、人数和供应商确认。" evidence = [ { "type": "用车证据", "name": item["name"][:120], "summary": f"{item.get('subtype') or item.get('type') or '用车项目'};参考价:{_item_price_text(item)}", "source": "FalkorDB 图查询 TravelItem", } for item in items[:12] ] plans = [ { "rank": idx, "label": "用车项目", "plan_name": item["name"], "product_name": item["name"], "fit_score": max(60, 92 - idx), "match_reasons": ["用车/车型快查", "读取 TravelItem"], "route_summary": item["name"], "quote_summary": _item_price_text(item), "variant_summary": str(item.get("subtype") or ""), "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "fast_vehicle_template", } for idx, item in enumerate(items[:4], start=1) ] return answer, customer, evidence, plans def _context_products_from_rows(graph_result: dict[str, Any]) -> list[dict[str, Any]]: products: list[dict[str, Any]] = [] for row in graph_result.get("rows") or []: if not isinstance(row, list) or len(row) < 5: continue name = str(row[0] or "").strip() if not name: continue price_plans = [item for item in row[3] if isinstance(item, dict)] if isinstance(row[3], list) else [] route_points = [item for item in row[4] if isinstance(item, dict)] if isinstance(row[4], list) else [] min_price = None for plan in price_plans: value = plan.get("adult_price_min") if isinstance(value, (int, float)): min_price = value if min_price is None else min(min_price, value) scenic_names = _route_scenic_names(route_points, limit=30) attraction_count = len(scenic_names) products.append({ "product_name": name, "product_id": row[1] if len(row) > 1 else "", "duration_days": row[2] if len(row) > 2 else None, "adult_price_min": min_price, "price_plans": price_plans[:8], "route_points": route_points[:16], "scenic_names": scenic_names, "attraction_count": attraction_count, }) return products def _condition_route_score(question: str, product: dict[str, Any]) -> tuple[int, int, int]: name = str(product.get("product_name") or "") duration = _safe_int(product.get("duration_days"), 99) price = product.get("adult_price_min") price_int = _safe_int(price, 999999) if price not in (None, "") else 999999 scenic_count = _safe_int(product.get("attraction_count"), len(product.get("scenic_names") or [])) scenic_text = " ".join(product.get("scenic_names") or []) slots = _condition_slots(question) score = 50 if slots["family"]: if any(term in name for term in ("亲子", "轻奢", "纯玩", "小包团", "2+1", "私家")): score += 18 if duration <= 3: score += 10 elif duration <= 4: score += 6 if slots["comfort"]: if any(term in name for term in ("轻奢", "纯玩", "2+1", "私家", "头等舱")): score += 20 if duration <= 3: score += 12 if 0 < scenic_count <= 5: score += 6 if duration >= 5: score -= 8 if "梵净山" in scenic_text: score -= 8 if slots["budget"]: if price_int <= 800: score += 18 elif price_int <= 1200: score += 10 if any(term in name for term in ("购物", "特价购物")): score -= 12 return score, -duration, -price_int def _condition_route_template_answer(question: str, graph_result: dict[str, Any]) -> tuple[str, str, list[dict[str, str]], list[dict[str, Any]]] | None: products = _context_products_from_rows(graph_result) if not products: return None products.sort(key=lambda item: _condition_route_score(question, item), reverse=True) selected = products[:5] lines = ["按“老人/小孩/轻松/预算”等条件从图谱中筛出以下候选线路:"] evidence: list[dict[str, str]] = [] plans: list[dict[str, Any]] = [] for idx, product in enumerate(selected, start=1): name = product["product_name"] duration = f"{product['duration_days']}日" if product.get("duration_days") else "天数待核实" price = _money(product.get("adult_price_min")) price_text = f"成人起价约 {price}元/人" if price else "价格需按团期核实" scenic_names = product.get("scenic_names") or _route_scenic_names(product.get("route_points") or [], limit=8) scenic_text = "、".join(scenic_names[:5]) if scenic_names else "景区信息待核实" reasons: list[str] = [] if any(term in name for term in ("轻奢", "纯玩", "2+1", "私家", "小包团")): reasons.append("线路名称体现舒适/纯玩/小团倾向") if _safe_int(product.get("duration_days"), 99) <= 3: reasons.append("天数较短,行程压力相对低") if price: reasons.append("图谱有报价起价可参考") reason_text = ";".join(reasons[:3]) or "图谱有线路、价格和行程证据" line = f"{idx}. {name}({duration}):{price_text};可玩 {scenic_text};推荐依据:{reason_text}" lines.append(line) evidence.append({ "type": "条件筛选证据", "name": name[:120], "summary": line[:360], "source": "FalkorDB 图查询 TourProduct/ProductPricePlan/ScenicAttraction", }) plans.append({ "rank": idx, "label": "条件推荐线路", "plan_name": name, "product_name": name, "fit_score": max(60, min(96, _condition_route_score(question, product)[0])), "match_reasons": reasons or ["条件筛选命中", "读取线路报价和景区证据"], "route_summary": f"{name}({duration}):{scenic_text}", "quote_summary": price_text, "variant_summary": reason_text, "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "fast_condition_route_advice", }) best = selected[0] best_price = _money(best.get("adult_price_min")) best_price_text = f",成人起价约 {best_price}元/人" if best_price else "" best_duration = f"{best['duration_days']}日" if best.get("duration_days") else "天数待核实" customer = f"亲,带老人小孩又希望轻松,可以优先看 {best['product_name']}({best_duration}{best_price_text})。它在图谱里有线路和景区证据,最终还要按出发日期、人数和住宿档位核实。" lines.append("这些推荐是基于图谱字段做的初筛,不等于最终承诺;老人/儿童还需要二次确认行程强度、用车、住宿和余位。") return "\n".join(lines), customer, evidence, plans def _route_compare_suitability_answer(question: str, graph_result: dict[str, Any]) -> tuple[str, str, list[dict[str, str]], list[dict[str, Any]]] | None: products = _context_products_from_rows(graph_result) if not products: return None compare_terms = _route_name_terms(question) grouped: list[dict[str, Any]] = [] for term in compare_terms: candidates = [item for item in products if term in str(item.get("product_name") or "")] if not candidates: continue other_terms = [other for other in compare_terms if other != term] pure_candidates = [ item for item in candidates if not any(other in str(item.get("product_name") or "") for other in other_terms) ] pool = pure_candidates or candidates pool.sort(key=lambda item: _condition_route_score(question, item), reverse=True) best = pool[0] grouped.append({ "term": term, "best": best, "score": _condition_route_score(question, best)[0], }) if len(grouped) < 2: return None grouped.sort(key=lambda item: item["score"], reverse=True) winner = grouped[0] lines = ["按老人/儿童/轻松度条件,用图谱中的天数、价格和景区节点做适配度对比:"] evidence: list[dict[str, str]] = [] plans: list[dict[str, Any]] = [] for idx, group in enumerate(grouped, start=1): product = group["best"] name = product["product_name"] duration = f"{product['duration_days']}日" if product.get("duration_days") else "天数待核实" price = _money(product.get("adult_price_min")) price_text = f"成人起价约 {price}元/人" if price else "价格需按团期核实" scenic_names = product.get("scenic_names") or [] scenic_text = "、".join(scenic_names[:6]) if scenic_names else "景区信息待核实" reasons: list[str] = [] if _safe_int(product.get("duration_days"), 99) <= 3: reasons.append("天数短,行程压力相对低") if any(term in name for term in ("轻奢", "2+1", "纯玩", "头等舱", "私家")): reasons.append("名称体现舒适/纯玩/车型优势") if "梵净山" in scenic_text: reasons.append("包含梵净山,老人小孩需评估体力") if price: reasons.append("有报价起价可参考") reason_text = ";".join(reasons[:4]) or "有线路、价格和景区证据" line = f"{idx}. {group['term']}:代表线路 {name}({duration}),{price_text};景区:{scenic_text};判断:{reason_text}" lines.append(line) evidence.append({ "type": "适配度对比证据", "name": str(group["term"])[:120], "summary": line[:360], "source": "FalkorDB 图查询 TourProduct/ProductPricePlan/ScenicAttraction", }) plans.append({ "rank": idx, "label": "线路适配度对比", "plan_name": str(group["term"]), "product_name": name, "fit_score": max(60, min(96, int(group["score"]))), "match_reasons": reasons or ["条件适配评分", "读取线路报价和景区证据"], "route_summary": line, "quote_summary": price_text, "variant_summary": reason_text, "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "fast_route_compare_suitability", }) winner_product = winner["best"] customer = f"亲,按老人小孩和不要太累的条件,优先建议 {winner['term']},代表线路可看 {winner_product['product_name']}。它在图谱评分里更偏轻松/舒适;最终还要按出发日期、人数、住宿和老人儿童体力二次确认。" lines.append("该结论是图谱条件评分,不是最终承诺;涉及老人儿童时建议再核实步行强度、用车、住宿和景区政策。") return "\n".join(lines), customer, evidence, plans def _route_compare_scenic_count_answer(question: str, graph_result: dict[str, Any]) -> tuple[str, str, list[dict[str, str]], list[dict[str, Any]]] | None: products = _context_products_from_rows(graph_result) if not products: return None compare_terms = _route_name_terms(question) grouped: list[dict[str, Any]] = [] for term in compare_terms: candidates = [item for item in products if term in str(item.get("product_name") or "")] if not candidates: continue other_terms = [other for other in compare_terms if other != term] pure_candidates = [ item for item in candidates if not any(other in str(item.get("product_name") or "") for other in other_terms) ] pool = pure_candidates or candidates pool.sort(key=lambda item: (_safe_int(item.get("attraction_count"), 0), _route_match_score(question, item)), reverse=True) best = pool[0] grouped.append({ "term": term, "best": best, "count": _safe_int(best.get("attraction_count"), 0), "candidates": pool[:4], }) if grouped: selected = [item["best"] for item in grouped] else: products.sort(key=lambda item: (_route_match_score(question, item), _safe_int(item.get("attraction_count"), 0)), reverse=True) selected = products[:8] max_count = max(_safe_int(item.get("attraction_count"), 0) for item in selected) winners = [item for item in selected if _safe_int(item.get("attraction_count"), 0) == max_count and max_count > 0] lines = ["按当前图谱的 ScenicAttraction 证据统计:"] evidence: list[dict[str, str]] = [] plans: list[dict[str, Any]] = [] if grouped: for idx, group in enumerate(grouped, start=1): product = group["best"] scenic_names = product.get("scenic_names") or _route_scenic_names(product.get("route_points") or [], limit=10) count = _safe_int(product.get("attraction_count"), len(scenic_names)) scenic_text = "、".join(scenic_names[:8]) if scenic_names else "暂无明确景区证据" line = f"{idx}. {group['term']}:代表线路 {product['product_name']},图谱命中 {count} 个景区/游玩节点,包含 {scenic_text}" lines.append(line) evidence.append({ "type": "景区数量对比证据", "name": str(group["term"])[:120], "summary": line[:360], "source": "FalkorDB 图查询 ProductDay/RouteStop/ScenicAttraction", }) plans.append({ "rank": idx, "label": "线路景点数量对比", "plan_name": str(group["term"]), "product_name": product["product_name"], "fit_score": max(60, 92 - idx * 3), "match_reasons": ["按用户提到的线路词分组", "统计 ScenicAttraction 去重数量"], "route_summary": line, "quote_summary": "", "variant_summary": scenic_text, "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "fast_route_compare_scenic_count", }) else: for idx, product in enumerate(selected, start=1): scenic_names = product.get("scenic_names") or _route_scenic_names(product.get("route_points") or [], limit=10) count = _safe_int(product.get("attraction_count"), len(scenic_names)) scenic_text = "、".join(scenic_names[:8]) if scenic_names else "暂无明确景区证据" line = f"{idx}. {product['product_name']}:图谱命中 {count} 个景区/游玩节点,包含 {scenic_text}" lines.append(line) evidence.append({ "type": "景区数量对比证据", "name": product["product_name"][:120], "summary": line[:360], "source": "FalkorDB 图查询 ProductDay/RouteStop/ScenicAttraction", }) plans.append({ "rank": idx, "label": "线路景点数量对比", "plan_name": product["product_name"], "product_name": product["product_name"], "fit_score": max(60, 92 - idx * 3), "match_reasons": ["线路名称命中", "统计 ScenicAttraction 去重数量"], "route_summary": line, "quote_summary": "", "variant_summary": scenic_text, "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "fast_route_compare_scenic_count", }) if winners: if len(winners) > 1 and grouped: winner_names = "、".join( group["term"] for group in grouped if group["best"] in winners ) customer = f"从当前图谱景区数量看,{winner_names} 最高都约 {max_count} 个景区/游玩节点,数量接近;建议再结合天数、价格和行程强度选择。" elif grouped: winner_name = next((group["term"] for group in grouped if group["best"] in winners), "") customer = f"从当前图谱景区数量看,{winner_name or winners[0]['product_name']}的代表线路命中的景区/游玩节点更多(约 {max_count} 个)。具体还要结合出发日期、行程强度和实际游玩安排确认。" else: winner_names = "、".join(item["product_name"] for item in winners[:2]) customer = f"从当前图谱景区数量看,{winner_names} 命中的景区/游玩节点更多(约 {max_count} 个)。具体还要结合出发日期、行程强度和实际游玩安排确认。" else: customer = "当前图谱没有足够的景区数量证据做可靠比较,建议补充具体线路名称或让后台核实行程明细。" lines.append("注意:这里比较的是图谱已结构化的景区/游玩节点数量,不代表实际游玩时长或体验强弱。") return "\n".join(lines), customer, evidence, plans def _context_template_fallback_answer(question: str, graph_result: dict[str, Any]) -> tuple[str, str, list[dict[str, str]], list[dict[str, Any]]]: products = _context_products_from_rows(graph_result) products.sort(key=lambda item: (item.get("adult_price_min") is None, item.get("adult_price_min") or 999999, item.get("duration_days") or 99)) evidence: list[dict[str, str]] = [] plans: list[dict[str, Any]] = [] lines = ["已按线路实体查到以下可对比方案:"] for idx, product in enumerate(products[:6], start=1): price = _money(product.get("adult_price_min")) day_suffix = f"({product['duration_days']}日)" if product.get("duration_days") else "" price_text = f",成人参考起价 {price}元/人" if price else ",价格需按团期核实" line = f"{idx}. {product['product_name']}{day_suffix}{price_text}" lines.append(line) evidence.append({ "type": "线路对比证据", "name": product["product_name"][:120], "summary": line[:360], "source": "FalkorDB 图查询 TourProduct/ProductPricePlan", }) plans.append({ "rank": idx, "label": "线路对比", "plan_name": product["product_name"], "product_name": product["product_name"], "fit_score": max(60, 90 - idx * 3), "match_reasons": ["命中线路实体", "读取报价和行程上下文"], "route_summary": line, "quote_summary": line, "variant_summary": "", "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "deterministic_context_fallback", }) if products: best = products[0] customer = f"从图谱报价看,预算优先可以先看 {best['product_name']}。如果有老人和小孩,还需要结合行程强度、住宿档位和出发日期确认,价格和余位需二次核实。" else: customer = "当前已进入线路对比查询,但图谱证据不足,建议补充具体线路、出发日期、人数和预算后再推荐。" lines.append("建议最终按出发日期、人数、住宿档位、老人/儿童体力情况和供应商余位二次核实。") return "\n".join(lines), customer, evidence, plans async def _deterministic_context_response( question: str, graph_name: str, *, limit: int, customer_context: dict[str, Any] | None, started_at: float, intent: dict[str, Any], intent_ms: int, ) -> dict[str, Any] | None: if intent.get("route") != "deterministic_context_llm_answer": return None if intent.get("intent") == "condition_route_advice": cypher = _condition_context_cypher(limit) else: cypher = _route_context_cypher(question, limit) or "" if not cypher: return None graph_started = time.perf_counter() try: graph_result = await asyncio.to_thread(_run_cypher, graph_name, cypher, limit) except Exception: return None graph_ms = max(1, round((time.perf_counter() - graph_started) * 1000)) if not graph_result.get("row_count"): return None planner = intent.get("_planner_trace") if isinstance(intent.get("_planner_trace"), dict) else {} planner_ms = _safe_int(planner.get("latency_ms"), 0) answer_ms = 0 llm_error = "" answer_data: dict[str, Any] = {} answer_client = await _graph_qa_client(max_tokens=600) if answer_client is not None: payload = { "question": question, "graph_name": graph_name, "intent": intent, "answer_focus": "基于线路报价、天数、景区数量和行程证据做 3 句话以内建议;证据不足必须说明需二次核实。", "cypher": cypher, "graph_result": { "columns": graph_result["columns"], "row_count": graph_result["row_count"], "rows": graph_result["rows"][:6], "nodes": [], "relationships": [], }, "customer_context": customer_context or {}, } answer_started = time.perf_counter() try: answer_data = await _chat_json_timed( answer_client, GRAPH_QA_ANSWER_SYS, json.dumps(payload, ensure_ascii=False), timeout_seconds=GRAPH_QA_ANSWER_TIMEOUT_SECONDS, attempts=1, ) except asyncio.TimeoutError: llm_error = "answer_timeout" except Exception as exc: # noqa: BLE001 llm_error = str(exc)[:260] answer_ms = max(1, round((time.perf_counter() - answer_started) * 1000)) if answer_data: answer = str(answer_data.get("answer") or answer_data.get("customer_reply") or "").strip() customer_reply = str(answer_data.get("customer_reply") or answer).strip() evidence = _evidence_cards(answer_data, graph_result) plans = _plans_from_evidence(evidence, answer) confidence = _safe_confidence(answer_data.get("confidence"), 0.72) followups = _list_texts(answer_data.get("follow_up_questions"), 4, 120) risk_notes = _list_texts(answer_data.get("risk_notes"), 4, 160) else: answer, customer_reply, evidence, plans = _context_template_fallback_answer(question, graph_result) confidence = 0.68 followups = ["请提供出发日期和人数。", "老人和小孩对行程强度有什么要求?", "预算范围和住宿档位是多少?"] risk_notes = ["推荐仅基于图谱已有报价和线路信息,最终需按团期、余位和供应商政策二次核实。"] latency_ms = max(1, round((time.perf_counter() - started_at) * 1000)) return { "question": question, "graph_name": graph_name, "answer": answer, "customer_reply": customer_reply, "copy_text": customer_reply or answer, "plans": plans, "evidence": evidence, "sales_scripts": [], "follow_up_questions": followups, "risk_notes": risk_notes, "confidence": confidence, "graph_result": graph_result, "trace": { "method": "deterministic_context_llm_answer_v1", "query_source": "deterministic_context_cypher", "rule_query_used": False, "llm_used": bool(answer_data), "llm_error": llm_error, "cache_hit": False, "cache": {"response": False, "cypher": False}, "generated_cypher": "", "effective_cypher": cypher, "cypher_cache_hit": False, "fallback_cypher": "", "fallback_query_used": False, "cypher_reason": intent.get("reason") or "", "intent": intent.get("intent") or "", "llm_planner_used": bool(planner.get("used")), "llm_planner": planner, "graph_qa_intent": intent, "routing_strategy": "intent_context_query_then_llm_answer", "cypher_repaired": False, "first_query_error": "", "row_count": graph_result["row_count"], "node_count": len(graph_result["nodes"]), "relationship_count": len(graph_result["relationships"]), "latency_ms": latency_ms, "stage_timings_ms": { "intent_classification": intent_ms, "llm_planner": planner_ms, "schema": 0, "cypher_generation": 0, "graph_query": graph_ms, "fallback_graph_query": 0, "answer_synthesis": answer_ms, }, "performance_target_ms": 1200, "response_mode": "deterministic_context_llm_answer", "response_mode_label": "固定取证 + LLM 证据回答", "graph_capabilities_used": ["意图识别", "固定 Cypher 取证", "LLM 证据回答"], "retrieval_summary": { "cypher": cypher, "llm_generated_cypher": "", "fallback_query_used": False, "rows": graph_result["row_count"], "nodes": len(graph_result["nodes"]), "relationships": len(graph_result["relationships"]), }, }, } def _cache_key(question: str, graph_name: str, limit: int, customer_context: dict[str, Any] | None) -> str: context_key = json.dumps(customer_context or {}, ensure_ascii=False, sort_keys=True, default=str)[:800] normalized_question = re.sub(r"\s+", "", question.strip()) return f"{graph_name}|{limit}|{normalized_question}|{context_key}" def _get_cached_response(cache_key: str, started_at: float) -> dict[str, Any] | None: cached = _QA_RESPONSE_CACHE.get(cache_key) if not cached: return None cached_at, payload = cached if time.monotonic() - cached_at > QA_RESPONSE_CACHE_TTL_SECONDS: _QA_RESPONSE_CACHE.pop(cache_key, None) return None response = copy.deepcopy(payload) latency_ms = max(1, round((time.perf_counter() - started_at) * 1000)) trace = response.setdefault("trace", {}) trace["cache_hit"] = True cache = trace.setdefault("cache", {}) cache["response"] = True trace["latency_ms"] = latency_ms trace["stage_timings_ms"] = { **(trace.get("stage_timings_ms") or {}), "cache_lookup": latency_ms, } return response def _set_cached_response(cache_key: str, response: dict[str, Any]) -> None: if len(_QA_RESPONSE_CACHE) > 256: oldest_key = min(_QA_RESPONSE_CACHE, key=lambda key: _QA_RESPONSE_CACHE[key][0]) _QA_RESPONSE_CACHE.pop(oldest_key, None) _QA_RESPONSE_CACHE[cache_key] = (time.monotonic(), copy.deepcopy(response)) def _cypher_cache_key(question: str, graph_name: str, limit: int, intent: dict[str, Any]) -> str: normalized = _normalized_question(question) return f"{graph_name}|{limit}|{intent.get('intent') or 'general'}|{normalized}" def _get_cached_cypher(cache_key: str) -> dict[str, Any] | None: cached = _CYPHER_CACHE.get(cache_key) if not cached: return None cached_at, payload = cached if time.monotonic() - cached_at > CYPHER_CACHE_TTL_SECONDS: _CYPHER_CACHE.pop(cache_key, None) return None return copy.deepcopy(payload) def _set_cached_cypher(cache_key: str, decision: dict[str, Any]) -> None: if len(_CYPHER_CACHE) > 512: oldest_key = min(_CYPHER_CACHE, key=lambda key: _CYPHER_CACHE[key][0]) _CYPHER_CACHE.pop(oldest_key, None) _CYPHER_CACHE[cache_key] = (time.monotonic(), copy.deepcopy(decision)) async def _fast_graph_response( question: str, graph_name: str, *, limit: int, customer_context: dict[str, Any] | None, started_at: float, intent: dict[str, Any], intent_ms: int, ) -> dict[str, Any] | None: if intent.get("route") != "template_graph_query": return None fast_kind = str(intent.get("intent") or "") cypher = "" if fast_kind == "route_catalog": cypher = _route_catalog_cypher(limit) elif fast_kind == "condition_route_advice": cypher = _condition_context_cypher(limit) elif fast_kind == "route_compare_suitability": cypher = _route_context_cypher(question, limit) or "" elif fast_kind == "route_compare_scenic_count": cypher = _route_context_cypher(question, limit) or "" elif fast_kind == "route_multihop_detail": cypher = _route_multihop_cypher(question, limit) or "" elif fast_kind == "price_quote": cypher = _price_fallback_cypher(question, limit) or "" elif fast_kind == "fee_detail": cypher = _fee_fallback_cypher(question, limit) or "" elif fast_kind == "vehicle_catalog": cypher = _vehicle_catalog_cypher(limit) if not cypher: return None graph_started = time.perf_counter() try: graph_result = await asyncio.to_thread(_run_cypher, graph_name, cypher, limit) except Exception: return None graph_ms = max(1, round((time.perf_counter() - graph_started) * 1000)) if not graph_result.get("row_count"): return None planner = intent.get("_planner_trace") if isinstance(intent.get("_planner_trace"), dict) else {} planner_ms = _safe_int(planner.get("latency_ms"), 0) if fast_kind == "route_catalog": route_answer = _route_list_answer(question, graph_result) if not route_answer: return None answer, customer_reply = route_answer items = _route_items_from_rows(graph_result) evidence = [ { "type": "线路清单", "name": item["name"], "summary": f"{item['name']}" + (f"({item['duration_days']}日)" if item.get("duration_days") else ""), "source": "FalkorDB 图查询 TourProduct", } for item in items[:12] ] plans = [ { "rank": idx, "label": "线路清单", "plan_name": item["name"], "product_name": item["name"], "fit_score": max(60, 95 - idx), "match_reasons": ["线路清单快查", "读取 TourProduct"], "route_summary": item["name"], "quote_summary": "可继续按线路名称、日期和人数查询报价。", "variant_summary": "", "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "fast_route_catalog", } for idx, item in enumerate(items[:4], start=1) ] followups = ["要查哪条线路的价格?", "请提供出发日期、人数和住宿偏好。", "是否需要按天数或景点筛选线路?"] confidence = 0.9 elif fast_kind == "condition_route_advice": condition_answer = _condition_route_template_answer(question, graph_result) if not condition_answer: return None answer, customer_reply, evidence, plans = condition_answer followups = ["请提供出发日期和人数。", "老人或小孩是否有少走路要求?", "预算和住宿档位大概是多少?"] confidence = 0.78 elif fast_kind == "route_compare_suitability": suitability_answer = _route_compare_suitability_answer(question, graph_result) if not suitability_answer: return None answer, customer_reply, evidence, plans = suitability_answer followups = ["请提供出发日期和人数。", "老人小孩是否能接受爬山或较多步行?", "是否需要同时比较价格?"] confidence = 0.8 elif fast_kind == "route_compare_scenic_count": compare_answer = _route_compare_scenic_count_answer(question, graph_result) if not compare_answer: return None answer, customer_reply, evidence, plans = compare_answer followups = ["是否还要比较价格和行程强度?", "请提供出发日期和人数。", "是否需要按老人/儿童适配度再筛选?"] confidence = 0.82 elif fast_kind == "route_multihop_detail": multihop_answer = _route_multihop_template_answer(question, graph_result) if not multihop_answer: return None answer, customer_reply, evidence, plans = multihop_answer followups = ["请提供出发日期和人数。", "是否需要指定住宿档位或房型?", "要继续核实哪个景区附近酒店?"] confidence = 0.88 elif fast_kind == "price_quote": price_answer = _price_template_answer(question, graph_result) if not price_answer: return None answer, customer_reply, evidence, plans = price_answer followups = ["请提供出发日期和人数。", "是否有住宿档位要求?", "是否需要同步查询儿童价和单房差?"] confidence = 0.86 elif fast_kind == "fee_detail": fee_answer = _fee_template_answer(question, graph_result) if not fee_answer: return None answer, customer_reply, evidence, plans = fee_answer followups = ["要按哪一天或哪个景区核实费用?", "是否需要区分必含和自费项目?", "请提供出发日期方便核实景区政策。"] confidence = 0.84 elif fast_kind == "vehicle_catalog": vehicle_answer = _vehicle_template_answer(question, graph_result) if not vehicle_answer: return None answer, customer_reply, evidence, plans = vehicle_answer followups = ["请提供出发日期和人数。", "是否需要指定车型或座位布局?", "是否需要核算包车费用?"] confidence = 0.82 else: return None latency_ms = max(1, round((time.perf_counter() - started_at) * 1000)) return { "question": question, "graph_name": graph_name, "answer": answer, "customer_reply": customer_reply, "copy_text": customer_reply or answer, "plans": plans, "evidence": evidence, "sales_scripts": [], "follow_up_questions": followups, "risk_notes": ["价格、余位、房型、车辆和景区政策以具体团期及供应商二次核实为准。"], "confidence": confidence, "graph_result": graph_result, "trace": { "method": f"fast_{fast_kind}_graph_template_v1", "query_source": "deterministic_cypher_template", "rule_query_used": False, "llm_used": False, "cache_hit": False, "cache": {"response": False, "cypher": False}, "generated_cypher": "", "effective_cypher": cypher, "fallback_cypher": "", "fallback_query_used": False, "cypher_reason": "高频客服问题命中固定只读图查询模板,跳过 LLM 生成 Cypher 和 LLM 答案组织。", "intent": fast_kind, "llm_planner_used": bool(planner.get("used")), "llm_planner": planner, "graph_qa_intent": intent, "routing_strategy": "intent_template_first", "cypher_repaired": False, "first_query_error": "", "row_count": graph_result["row_count"], "node_count": len(graph_result["nodes"]), "relationship_count": len(graph_result["relationships"]), "latency_ms": latency_ms, "stage_timings_ms": { "intent_classification": intent_ms, "llm_planner": planner_ms, "schema": 0, "cypher_generation": 0, "graph_query": graph_ms, "fallback_graph_query": 0, "answer_synthesis": 0, }, "performance_target_ms": 1200, "response_mode": "fast_graph_template", "response_mode_label": "高频客服问题快查", "graph_capabilities_used": ["固定 Cypher 模板", "FalkorDB 只读查询", "客服话术模板"], "retrieval_summary": { "cypher": cypher, "llm_generated_cypher": "", "fallback_query_used": False, "rows": graph_result["row_count"], "nodes": len(graph_result["nodes"]), "relationships": len(graph_result["relationships"]), }, }, } def _evidence_cards(answer_data: dict[str, Any], graph_result: dict[str, Any]) -> list[dict[str, str]]: cards: list[dict[str, str]] = [] raw_cards = answer_data.get("evidence_cards") if isinstance(raw_cards, list): for item in raw_cards[:6]: if not isinstance(item, dict): continue title = str(item.get("title") or item.get("name") or "").strip() summary = str(item.get("summary") or item.get("detail") or "").strip() source = str(item.get("source") or "FalkorDB 图查询").strip() if title or summary: cards.append({"type": "图查询证据", "name": title[:120], "summary": summary[:360], "source": source[:120]}) for node in (graph_result.get("nodes") or [])[:6]: if len(cards) >= 8: break props = node.get("properties") or {} summary = ";".join( f"{k}: {v}" for k, v in list(props.items())[:6] if v not in (None, "") ) cards.append({ "type": "图谱节点", "name": str(node.get("title") or "")[:120], "summary": summary[:360], "source": "FalkorDB 图查询", }) return cards def _plans_from_evidence(cards: list[dict[str, str]], answer: str) -> list[dict[str, Any]]: plans: list[dict[str, Any]] = [] for idx, card in enumerate(cards[:4], start=1): plans.append({ "rank": idx, "label": "图查询证据", "plan_name": card.get("name") or f"图谱证据 {idx}", "product_name": card.get("name") or "", "fit_score": max(60, 92 - idx * 4), "match_reasons": ["LLM 生成 Cypher 查询命中", "来自百姓惠知识图谱"], "route_summary": card.get("summary") or answer[:240], "quote_summary": answer[:360], "variant_summary": card.get("summary") or "", "daily_itinerary": [], "hotels": [], "restaurants": [], "vehicles": [], "policies": [], "cost_breakdown": [], "plan_kind": "llm_graph_evidence", }) return plans def _safe_confidence(value: Any, default: float = 0.72) -> float: try: return max(0.0, min(1.0, float(value))) except Exception: return default async def answer_graph_question( question: str, graph_name: str, *, customer_context: dict[str, Any] | None = None, limit: int = 80, ) -> dict[str, Any]: started_at = time.perf_counter() limit = min(max(int(limit or 80), 1), GRAPH_QA_MAX_LIMIT) question = question.strip() if not question: raise ValueError("question required") cache_key = _cache_key(question, graph_name, limit, customer_context) cached_response = _get_cached_response(cache_key, started_at) if cached_response: return cached_response intent_started = time.perf_counter() rule_intent = _classify_graph_qa_intent(question) intent, planner_trace = await _llm_plan_intent(question, graph_name, rule_intent, customer_context) intent["_planner_trace"] = planner_trace intent_ms = max(1, round((time.perf_counter() - intent_started) * 1000)) fast_response = await _fast_graph_response( question, graph_name, limit=limit, customer_context=customer_context, started_at=started_at, intent=intent, intent_ms=intent_ms, ) if fast_response: _set_cached_response(cache_key, fast_response) return fast_response context_response = await _deterministic_context_response( question, graph_name, limit=limit, customer_context=customer_context, started_at=started_at, intent=intent, intent_ms=intent_ms, ) if context_response: _set_cached_response(cache_key, context_response) return context_response cypher_cache_key = _cypher_cache_key(question, graph_name, limit, intent) cached_decision = _get_cached_cypher(cypher_cache_key) cypher_cache_hit = bool(cached_decision) cypher_client: LlmClient | None = None if cached_decision: decision = cached_decision schema_ms = 0 cypher_ms = 0 else: cypher_client = await _graph_qa_client(max_tokens=1000, use_config_max_tokens=False) if cypher_client is None: raise RuntimeError("LLM 未配置,无法执行自然语言图查询") cypher_client.timeout = min(float(getattr(cypher_client, "timeout", GRAPH_QA_CYPHER_TIMEOUT_SECONDS) or GRAPH_QA_CYPHER_TIMEOUT_SECONDS), GRAPH_QA_CYPHER_TIMEOUT_SECONDS) schema_started = time.perf_counter() schema = await asyncio.to_thread(_schema_snapshot, graph_name) schema_ms = max(1, round((time.perf_counter() - schema_started) * 1000)) plan_payload = { "question": question, "graph_name": graph_name, "intent": intent, "customer_context": customer_context or {}, "graph_schema": schema, "default_limit": limit, } cypher_started = time.perf_counter() decision = await _chat_json_timed( cypher_client, GRAPH_QA_CYPHER_SYS, json.dumps(plan_payload, ensure_ascii=False), timeout_seconds=GRAPH_QA_CYPHER_TIMEOUT_SECONDS, attempts=1, ) cypher_ms = max(1, round((time.perf_counter() - cypher_started) * 1000)) _set_cached_cypher(cypher_cache_key, decision) answer_client = await _graph_qa_client(max_tokens=650) if answer_client is None: raise RuntimeError("LLM 未配置,无法执行自然语言图查询") answer_client.timeout = min(float(getattr(answer_client, "timeout", GRAPH_QA_ANSWER_TIMEOUT_SECONDS) or GRAPH_QA_ANSWER_TIMEOUT_SECONDS), GRAPH_QA_ANSWER_TIMEOUT_SECONDS) generated_cypher = _clean_cypher(decision.get("cypher"), limit) llm_generated_cypher = generated_cypher graph_result: dict[str, Any] query_error = "" repaired = False fallback_used = False fallback_cypher = "" fallback_ms = 0 try: graph_started = time.perf_counter() graph_result = await asyncio.to_thread(_run_cypher, graph_name, generated_cypher, limit) graph_ms = max(1, round((time.perf_counter() - graph_started) * 1000)) except Exception as exc: # noqa: BLE001 query_error = str(exc)[:400] if cypher_client is None: cypher_client = await _graph_qa_client(max_tokens=1000, use_config_max_tokens=False) if cypher_client is None: raise cypher_client.timeout = min(float(getattr(cypher_client, "timeout", GRAPH_QA_REPAIR_TIMEOUT_SECONDS) or GRAPH_QA_REPAIR_TIMEOUT_SECONDS), GRAPH_QA_REPAIR_TIMEOUT_SECONDS) repair_payload = { "question": question, "graph_name": graph_name, "intent": intent, "customer_context": customer_context or {}, "failed_cypher": generated_cypher, "execution_error": query_error, } repaired_decision = await _chat_json_timed( cypher_client, GRAPH_QA_REPAIR_SYS, json.dumps(repair_payload, ensure_ascii=False), timeout_seconds=GRAPH_QA_REPAIR_TIMEOUT_SECONDS, attempts=1, ) generated_cypher = _clean_cypher(repaired_decision.get("cypher"), limit) decision = {**decision, **repaired_decision} repaired = True graph_started = time.perf_counter() graph_result = await asyncio.to_thread(_run_cypher, graph_name, generated_cypher, limit) graph_ms = max(1, round((time.perf_counter() - graph_started) * 1000)) if _is_fee_question(question) and not _has_fee_evidence(graph_result): fallback_cypher = _fee_fallback_cypher(question, limit) or "" if fallback_cypher: try: fallback_started = time.perf_counter() fallback_result = await asyncio.to_thread(_run_cypher, graph_name, fallback_cypher, limit) fallback_ms = max(1, round((time.perf_counter() - fallback_started) * 1000)) if fallback_result.get("row_count") and _has_fee_evidence(fallback_result): graph_result = fallback_result generated_cypher = fallback_cypher fallback_used = True except Exception as exc: # noqa: BLE001 query_error = "; ".join(item for item in [query_error, f"fallback: {str(exc)[:220]}"] if item) if not graph_result.get("row_count"): latency_ms = max(1, round((time.perf_counter() - started_at) * 1000)) planner = intent.get("_planner_trace") if isinstance(intent.get("_planner_trace"), dict) else {} planner_ms = _safe_int(planner.get("latency_ms"), 0) answer = "已完成 LLM 图查询,但当前图谱没有命中可确认数据。建议补充更具体的线路、景区、套餐名称、出发日期或人数后再查。" customer_reply = "抱歉,当前图谱暂未查到相关产品或套餐。您可以补充具体线路、景区、出发日期或套餐名称,我再帮您核实。" response = { "question": question, "graph_name": graph_name, "answer": answer, "customer_reply": customer_reply, "copy_text": customer_reply, "plans": [], "evidence": [], "sales_scripts": [], "follow_up_questions": ["请补充具体线路/产品名称。", "请说明出发日期、人数或套餐名称。", "是否要改查百姓惠旅游线路、价格、景区或酒店?"], "risk_notes": ["图谱查询结果为空,不代表业务一定不存在,需按产品库或人工渠道二次核实。"], "confidence": 0.28, "graph_result": graph_result, "trace": { "method": "llm_to_cypher_graph_qa_v1", "query_source": "llm_generated_cypher", "rule_query_used": False, "llm_used": True, "llm_error": "", "generated_cypher": llm_generated_cypher, "effective_cypher": generated_cypher, "cache": {"response": False, "cypher": cypher_cache_hit}, "cypher_cache_hit": cypher_cache_hit, "fallback_cypher": fallback_cypher, "fallback_query_used": fallback_used, "cypher_reason": decision.get("reason") or "", "intent": intent.get("intent") or "", "llm_planner_used": bool(planner.get("used")), "llm_planner": planner, "graph_qa_intent": intent, "routing_strategy": "intent_template_first_llm_fallback", "cypher_repaired": repaired, "first_query_error": query_error, "row_count": 0, "node_count": 0, "relationship_count": 0, "latency_ms": latency_ms, "stage_timings_ms": { "intent_classification": intent_ms, "llm_planner": planner_ms, "schema": schema_ms, "cypher_generation": cypher_ms, "graph_query": graph_ms, "fallback_graph_query": fallback_ms, "answer_synthesis": 0, }, "performance_target_ms": 1200, "response_mode": "llm_graph_qa", "response_mode_label": "LLM 图查询问答", "graph_capabilities_used": ["LLM-to-Cypher", "FalkorDB 只读查询", "空结果快速返回"], "retrieval_summary": { "cypher": generated_cypher, "llm_generated_cypher": llm_generated_cypher, "fallback_query_used": fallback_used, "rows": 0, "nodes": 0, "relationships": 0, }, }, } _set_cached_response(cache_key, response) return response answer_payload = { "question": question, "graph_name": graph_name, "intent": intent, "answer_focus": decision.get("answer_focus") or "", "cypher": generated_cypher, "graph_result": { "columns": graph_result["columns"], "row_count": graph_result["row_count"], "rows": graph_result["rows"][:8], "nodes": graph_result["nodes"][:10], "relationships": graph_result["relationships"][:10], }, "customer_context": customer_context or {}, } answer_started = time.perf_counter() answer_error = "" try: answer_data = await _chat_json_timed( answer_client, GRAPH_QA_ANSWER_SYS, json.dumps(answer_payload, ensure_ascii=False), timeout_seconds=GRAPH_QA_ANSWER_TIMEOUT_SECONDS, attempts=1, ) except asyncio.TimeoutError: answer_data = {} answer_error = "answer_timeout" except Exception as exc: # noqa: BLE001 answer_data = {} answer_error = str(exc)[:260] answer_ms = max(1, round((time.perf_counter() - answer_started) * 1000)) answer = str(answer_data.get("answer") or answer_data.get("customer_reply") or "").strip() route_list_override = _route_list_answer(question, graph_result) if route_list_override: answer, customer_reply_override = route_list_override answer_data["customer_reply"] = customer_reply_override answer_data.setdefault("follow_up_questions", ["要查哪条线路的价格?", "请提供出发日期和人数。"]) answer_data.setdefault("risk_notes", ["价格、余位和用车需按具体团期二次核实。"]) if not answer: answer = "图查询已完成,但答案组织 LLM 未在时间预算内返回。为避免编造,建议查看返回的 knowledge.evidence,并补充更明确的景点、线路、团期、人数或费用口径。" customer_reply = str(answer_data.get("customer_reply") or answer).strip() evidence = _evidence_cards(answer_data, graph_result) plans = _plans_from_evidence(evidence, answer) latency_ms = max(1, round((time.perf_counter() - started_at) * 1000)) planner = intent.get("_planner_trace") if isinstance(intent.get("_planner_trace"), dict) else {} planner_ms = _safe_int(planner.get("latency_ms"), 0) response = { "question": question, "graph_name": graph_name, "answer": answer, "customer_reply": customer_reply, "copy_text": customer_reply or answer, "plans": plans, "evidence": evidence, "sales_scripts": [], "follow_up_questions": _list_texts(answer_data.get("follow_up_questions"), 4, 120), "risk_notes": _list_texts(answer_data.get("risk_notes"), 4, 160), "confidence": _safe_confidence(answer_data.get("confidence")), "graph_result": graph_result, "trace": { "method": "llm_to_cypher_graph_qa_v1", "query_source": "llm_generated_cypher", "rule_query_used": False, "llm_used": True, "llm_error": answer_error, "generated_cypher": llm_generated_cypher, "effective_cypher": generated_cypher, "cache": {"response": False, "cypher": cypher_cache_hit}, "cypher_cache_hit": cypher_cache_hit, "fallback_cypher": fallback_cypher, "fallback_query_used": fallback_used, "cypher_reason": decision.get("reason") or "", "intent": intent.get("intent") or "", "llm_planner_used": bool(planner.get("used")), "llm_planner": planner, "graph_qa_intent": intent, "routing_strategy": "intent_template_first_llm_fallback", "cypher_repaired": repaired, "first_query_error": query_error, "row_count": graph_result["row_count"], "node_count": len(graph_result["nodes"]), "relationship_count": len(graph_result["relationships"]), "latency_ms": latency_ms, "stage_timings_ms": { "intent_classification": intent_ms, "llm_planner": planner_ms, "schema": schema_ms, "cypher_generation": cypher_ms, "graph_query": graph_ms, "fallback_graph_query": fallback_ms, "answer_synthesis": answer_ms, }, "performance_target_ms": 1200, "response_mode": "llm_graph_qa", "response_mode_label": "LLM 图查询问答", "graph_capabilities_used": ["LLM-to-Cypher", "FalkorDB 只读查询", "LLM evidence synthesis"], "retrieval_summary": { "cypher": generated_cypher, "llm_generated_cypher": llm_generated_cypher, "fallback_query_used": fallback_used, "rows": graph_result["row_count"], "nodes": len(graph_result["nodes"]), "relationships": len(graph_result["relationships"]), }, }, } _set_cached_response(cache_key, response) return response