1 line
44 KiB
JSON
1 line
44 KiB
JSON
{"repo": "alexlmoney83-oss/travel-planning-agent", "files": {"app.py": {"d9b77eb45518bf5205b9637843011a282f27922b": {"t": 1785745406.1023722, "n": 37870, "h": "4f6c57def1f344d6", "c": "\"\"\"\n智能旅游规划助手 - Streamlit UI\n基于LangChain Agent,无递归限制\n\"\"\"\nimport streamlit as st\nimport tempfile\nimport os\nimport asyncio\nimport warnings\nfrom typing import List, Dict, Any\n\n# suppress warnings\nwarnings.filterwarnings('ignore', category=DeprecationWarning)\nwarnings.filterwarnings('ignore', message='.*LangChainDeprecationWarning.*')\nfrom langchain.memory import ConversationBufferMemory\nfrom langchain_community.chat_message_histories import StreamlitChatMessageHistory\nfrom langchain_community.document_loaders import (\n TextLoader,\n PyPDFLoader,\n CSVLoader,\n DirectoryLoader\n)\nfrom langchain_openai import ChatOpenAI\nfrom langchain_chroma import Chroma\nfrom langchain_core.prompts import PromptTemplate\nfrom langchain_text_splitters import (\n RecursiveCharacterTextSplitter,\n CharacterTextSplitter\n)\nfrom langchain.agents import create_react_agent, AgentExecutor\nfrom langchain_community.callbacks.streamlit import StreamlitCallbackHandler\nfrom langchain_community.embeddings import DashScopeEmbeddings\nfrom langchain.tools import Tool\nfrom dotenv import load_dotenv\n\n# 设置页面配置(必须是第一个Streamlit命令)\nst.set_page_config(\n page_title=\"🗺️ 智能旅游规划助手\",\n page_icon=\"🗺️\",\n layout=\"wide\"\n)\n\n# 加载环境变量(指定.env文件路径)\nenv_path = os.path.join(os.path.dirname(__file__), 'aggentic_RAG', '.env')\nload_dotenv(dotenv_path=env_path, override=True)\n\n# 添加项目路径到sys.path\nimport sys\nsys.path.insert(0, os.path.join(os.path.dirname(__file__), 'aggentic_RAG'))\n\n# 应用标题\nst.title(\"🗺️ 智能旅游规划助手\")\nst.markdown(\"基于RAG + Agentic AI的旅游规划系统,无递归限制\")\n\n# 侧边栏 - 文档上传\nwith st.sidebar:\n st.header(\"📚 旅游攻略文档\")\n \n # 上传多种格式的文件\n uploaded_files = st.file_uploader(\n label=\"上传旅游攻略文档\",\n type=[\"txt\", \"pdf\", \"csv\"],\n accept_multiple_files=True,\n help=\"支持 TXT、PDF、CSV 格式的文档\"\n )\n \n st.markdown(\"---\")\n \n # 系统配置\n st.header(\"⚙️ 系统配置\")\n max_iterations = st.slider(\n \"最大迭代次数\",\n min_value=10,\n max_value=100,\n value=50,\n help=\"Agent执行的最大步骤数,无递归限制!\"\n )\n \n st.markdown(\"---\")\n \n # 清空对话按钮\n if st.button(\"🗑️ 清空聊天记录\", use_container_width=True):\n st.session_state.messages = []\n st.rerun()\n\n# 文档上传现在是可选的\nif not uploaded_files:\n st.info(\"💡 提示:您可以选择上传旅游攻略文档,或直接进行实时查询\")\n # 不再强制要求上传文档\n\n\n# 配置RAG检索器\n@st.cache_resource(ttl=\"1h\")\ndef configure_rag_retriever(uploaded_files):\n \"\"\"配置RAG检索器 - 支持多种文件格式\"\"\"\n docs = []\n \n # 创建临时目录存储上传的文件\n temp_dir = tempfile.TemporaryDirectory()\n \n for file in uploaded_files:\n temp_filepath = os.path.join(temp_dir.name, file.name)\n with open(temp_filepath, \"wb\") as f:\n f.write(file.getvalue())\n \n # 根据文件类型选择合适的 loader\n file_extension = os.path.splitext(file.name)[1].lower()\n \n try:\n if file_extension == \".txt\":\n loader = TextLoader(temp_filepath, encoding=\"utf-8\")\n elif file_extension == \".pdf\":\n loader = PyPDFLoader(temp_filepath)\n elif file_extension == \".csv\":\n loader = CSVLoader(temp_filepath, encoding=\"utf-8\")\n else:\n st.warning(f\"跳过不支持的文件格式: {file.name}\")\n continue\n \n docs.extend(loader.load())\n st.success(f\"✅ 已加载: {file.name}\")\n except Exception as e:\n st.error(f\"加载 {file.name} 失败: {e}\")\n \n # 文档分割\n text_splitter = RecursiveCharacterTextSplitter(\n chunk_size=500,\n chunk_overlap=100\n )\n splits = text_splitter.split_documents(docs)\n \n # 使用Qwen的Embedding模型\n embeddings = DashScopeEmbeddings(\n model=\"text-embedding-v3\",\n dashscope_api_key=os.getenv(\"DASHSCOPE_API_KEY\")\n )\n \n # 创建向量数据库\n vectordb = Chroma.from_documents(splits, embeddings)\n \n # 返回检索器\n return vectordb.as_retriever(search_kwargs={\"k\": 5})\n\n\n# 配置检索器(如果有文档)\nif uploaded_files:\n with st.spinner(\"正在加载文档并建立索引...\"):\n retriever = configure_rag_retriever(uploaded_files)\n st.success(f\"✅ 已加载 {len(uploaded_files)} 个文档\")\nelse:\n retriever = None\n\n\n# 启用嵌套asyncio支持\nimport nest_asyncio\nnest_asyncio.apply()\n\n# 创建全局event loop用于MCP调用\nimport asyncio\n_mcp_loop = asyncio.new_event_loop()\n_mcp_manager = None\n\n\n# ========== 预分析层:照搬旧版本 LangGraph 的分流逻辑 ==========\n\ndef detect_multi_destination(user_query: str, extraction: dict) -> dict:\n \"\"\"检测是否为多目的地场景(排除往返/回程误判)\n \n 照搬自 nodes.py 中的 detect_multi_destination 函数\n \"\"\"\n # === 1) 优先排除往返场景 ===\n roundtrip_keywords = [\"往返\", \"来回\", \"回程\", \"返程\", \"返回\"]\n if any(kw in user_query for kw in roundtrip_keywords):\n print(\" 🔄 检测到往返关键词,不算多目的地\")\n return {\n 'is_multi_destination': False,\n 'detected_keywords': [],\n 'raw_destination_text': extraction.get('destination', ''),\n 'detection_method': 'roundtrip_excluded'\n }\n \n # === 2) 多目的地关键词 ===\n multi_dest_keywords = [\n \"再去\", \"然后去\", \"接着去\", \"顺便去\",\n \"再到\", \"然后到\", \"接着到\",\n \"再去看看\", \"再看看\",\n \"之后去\", \"之后到\"\n ]\n detected_keywords = [kw for kw in multi_dest_keywords if kw in user_query]\n if detected_keywords:\n return {\n 'is_multi_destination': True,\n 'detected_keywords': detected_keywords,\n 'raw_destination_text': extraction.get('destination', ''),\n 'detection_method': 'keyword'\n }\n \n # === 3) 目的地字段中包含多个城市(逗号/顿号/“和”分隔) ===\n destination = extraction.get('destination', '') or ''\n origin = extraction.get('origin', '') or ''\n \n # 统一分隔符\n norm = destination.replace(',', ',').replace('、', ',').replace('和', ',')\n cities = [c.strip() for c in norm.split(',') if c.strip()]\n \n # 去重保持顺序\n unique_cities = []\n for c in cities:\n if c not in unique_cities:\n unique_cities.append(c)\n \n if len(unique_cities) >= 3:\n return {\n 'is_multi_destination': True,\n 'detected_keywords': [],\n 'raw_destination_text': destination,\n 'detection_method': 'comma_separated_3plus'\n }\n \n if len(unique_cities) == 2:\n # 如果两个城市中包含出发地,通常是往返(例如 上海, 南京)→ 视为单目的地\n if origin and origin in unique_cities:\n return {\n 'is_multi_destination': False,\n 'detected_keywords': [],\n 'raw_destination_text': destination,\n 'detection_method': 'origin_pair_excluded'\n }\n # 两个且都不是出发地 → 多目的地\n return {\n 'is_multi_destination': True,\n 'detected_keywords': [],\n 'raw_destination_text': destination,\n 'detection_method': 'comma_separated_2'\n }\n \n return {\n 'is_multi_destination': False,\n 'detected_keywords': [],\n 'raw_destination_text': destination\n }\n\n\ndef pre_analyze_query(user_query: str, llm) -> dict:\n \"\"\"预分析用户查询,判断场景类型\n \n 照搬自 nodes.py 中的 planner_node 逻辑\n \n 返回:\n dict: {\n 'scenario_type': 'simple' | 'complex' | 'multi_destination',\n 'needs_deep_analysis': bool, # 是否需要R1主导\n 'extraction': {...}, # 提取的信息\n 'multi_dest_info': {...} # 多目的地检测结果\n }\n \"\"\"\n from datetime import datetime\n \n today = datetime.now().strftime(\"%Y-%m-%d\")\n current_year = datetime.now().year\n \n # 构建提取 prompt,参考旧版本的 PLANNER_SYSTEM_PROMPT\n extraction_prompt = f\"\"\"You are a travel planning assistant. Today's date is {today}.\n\nYour task: Extract key information from the user query and determine if it needs deep analysis.\n\nRULES:\n- Output ONLY valid JSON. NO explanations, NO markdown code blocks.\n- Date conversion: \"今天\"/\"today\" = {today}, \"明天\"/\"tomorrow\" = +1 day, etc.\n- Use Chinese for city names.\n- Set \"needs_deep_analysis\" to true if:\n * Complex multi-city routes (e.g., \"A和B\", \"A再去B\")\n * Budget optimization needed (tight budget with many requirements)\n * Multiple conflicting constraints (e.g., elderly + children, limited time + many places)\n * Special needs: 老人, 小孩, 儿童, 亲子, 残疾, etc.\n\nOutput this exact JSON structure:\n{{\n \"destination\": \"extracted destination city or cities (comma separated if multiple)\",\n \"origin\": \"extracted origin city\",\n \"travel_days\": 0,\n \"budget\": 0,\n \"travel_date\": \"YYYY-MM-DD\",\n \"preferences\": [\"preference1\"],\n \"needs_deep_analysis\": false,\n \"has_special_needs\": false\n}}\n\nUser query: {user_query}\n\"\"\"\n \n try:\n response = llm.invoke(extraction_prompt)\n content = response.content.strip()\n \n # 移除可能的markdown代码块\n if content.startswith('```'):\n lines = content.split('\\n')\n content = '\\n'.join(lines[1:-1]) if len(lines) > 2 else content\n if content.startswith('json'):\n content = content[4:]\n \n import json\n extraction = json.loads(content)\n print(f\"\\n📊 预分析结果: {extraction}\")\n \n except Exception as e:\n print(f\"⚠️ 预分析失败: {e}\")\n # 默认值\n extraction = {\n \"destination\": \"\",\n \"origin\": \"\",\n \"travel_days\": 0,\n \"budget\": 0,\n \"travel_date\": \"\",\n \"preferences\": [],\n \"needs_deep_analysis\": False,\n \"has_special_needs\": False\n }\n \n # 检测多目的地场景\n multi_dest_info = detect_multi_destination(user_query, extraction)\n \n # 确定场景类型和是否需要R1\n if multi_dest_info.get('is_multi_destination', False):\n scenario_type = 'multi_destination'\n needs_r1 = True\n print(f\" 🌍 检测到多目的地场景: {multi_dest_info.get('detection_method')}\")\n elif extraction.get('needs_deep_analysis', False) or extraction.get('has_special_needs', False):\n scenario_type = 'complex'\n needs_r1 = True\n print(f\" 🧠 检测到复杂场景(特殊需求/预算紧张)\")\n else:\n scenario_type = 'simple'\n needs_r1 = False\n print(f\" ✅ 简单场景,Qwen3主导\")\n \n return {\n 'scenario_type': scenario_type,\n 'needs_deep_analysis': needs_r1,\n 'extraction': extraction,\n 'multi_dest_info': multi_dest_info\n }\n\ndef get_mcp_manager_sync():\n \"\"\"Sync wrapper to get MCP manager\"\"\"\n global _mcp_manager\n if _mcp_manager is None:\n from aggentic_RAG.travel_agent.tools.mcp_tools import MCPToolManager\n _mcp_manager = MCPToolManager()\n _mcp_loop.run_until_complete(_mcp_manager.initialize())\n return _mcp_manager\n\ndef call_mcp_tool_sync(server_name: str, tool_name: str, **kwargs) -> str:\n \"\"\"Sync wrapper to call MCP tool\"\"\"\n manager = get_mcp_manager_sync()\n return _mcp_loop.run_until_complete(manager.call_tool(server_name, tool_name, **kwargs))\n\n\n# 创建工具列表\ndef create_tools(retriever) -> List[Tool]:\n \"\"\"创建Agent可用的工具列表\"\"\"\n from langchain.tools.retriever import create_retriever_tool\n from aggentic_RAG.travel_agent.tools.tool_registry import AVAILABLE_TOOLS\n \n tools = []\n \n # 1. RAG检索工具(如果有文档)\n if retriever is not None:\n rag_tool = create_retriever_tool(\n retriever=retriever,\n name=\"rag_search\",\n description=\"用于查询旅游攻略、景点信息、美食推荐等。输入城市名或景点名。\"\n )\n tools.append(rag_tool)\n \n # 2. 初始化MCP管理器\n try:\n get_mcp_manager_sync() # 预初始化MCP连接\n except Exception as e:\n st.warning(f\"MCP初始化失败: {e}\")\n \n # 3. 从tool_registry获取所有MCP工具定义\n import json\n from datetime import datetime\n \n # 特殊处理函数:查询火车票(需要先获取站点代码)\n def query_train_tickets(origin: str, destination: str, date: str) -> str:\n \"\"\"Query train tickets - first get station codes, then query tickets\"\"\"\n print(f\"\\n🚂 [train_query] 调用: {origin} -> {destination}, {date}\")\n try:\n # 1. 修正日期年份\n try:\n date_obj = datetime.strptime(date, \"%Y-%m-%d\")\n current_year = datetime.now().year\n if date_obj.year < current_year:\n date = date.replace(str(date_obj.year), str(current_year))\n print(f\" 日期修正为: {date}\")\n except:\n pass\n \n # 2. 获取站点代码\n from_code = None\n to_code = None\n \n # 分别查询每个城市的站点代码\n for city, is_origin in [(origin, True), (destination, False)]:\n try:\n print(f\" 获取{city}站点代码...\")\n codes_result = call_mcp_tool_sync(\n \"12306 Server\",\n \"get-stations-code-in-city\",\n city=city\n )\n print(f\" {city}站点查询结果: {str(codes_result)[:150]}\")\n \n if codes_result and \"error\" not in str(codes_result).lower():\n codes_data = json.loads(codes_result) if isinstance(codes_result, str) else codes_result\n \n # 处理数组格式 - 找主站(站名与城市名匹配的)\n if isinstance(codes_data, list) and len(codes_data) > 0:\n code = None\n station_name = None\n \n # 优先找完全匹配的主站(如\"上海\"->\"上海\", \"徐州\"->\"徐州\")\n for station in codes_data:\n name = station.get('station_name', '')\n if name == city: # 完全匹配\n code = station.get('station_code') or station.get('code') or station.get('telecode')\n station_name = name\n break\n \n # 其次找包含城市名的站(如\"上海\"->\"上海虹桥\", \"上海南\")\n if not code:\n for station in codes_data:\n name = station.get('station_name', '')\n if city in name and '东' not in name and '南' not in name and '西' not in name and '北' not in name:\n code = station.get('station_code') or station.get('code') or station.get('telecode')\n station_name = name\n break\n \n # 最后尝试第一个包含城市名的站\n if not code:\n for station in codes_data:\n name = station.get('station_name', '')\n if city in name:\n code = station.get('station_code') or station.get('code') or station.get('telecode')\n station_name = name\n break\n \n if code:\n if is_origin:\n from_code = code\n else:\n to_code = code\n print(f\" ✅ {city}站点: {station_name} (代码: {code})\")\n else:\n print(f\" ⚠️ {city}未找到匹配的主站\")\n except Exception as e:\n print(f\" {city}站点查询异常: {e}\")\n \n print(f\" 站点代码汇总: {origin}={from_code}, {destination}={to_code}\")\n \n # 3. 查询车票\n train_result = None\n if from_code and to_code:\n print(f\" 步骤2: 使用站点代码查询车票...\")\n train_result = call_mcp_tool_sync(\n \"12306 Server\",\n \"get-tickets\",\n fromStation=from_code,\n toStation=to_code,\n date=date\n )\n print(f\" 车票查询结果: {str(train_result)[:300]}\")\n else:\n # 备选:直接使用城市名查询\n print(f\" 步骤2(备选): 直接使用城市名查询...\")\n train_result = call_mcp_tool_sync(\n \"12306 Server\",\n \"get-tickets\",\n fromStation=origin,\n toStation=destination,\n date=date\n )\n print(f\" 车票查询结果: {str(train_result)[:300]}\")\n \n # 4. 同时查询自驾路线(照搬旧版本行为)\n driving_result = None\n try:\n print(f\"\\n🚗 步骤3: 自动查询自驾路线信息\")\n \n # 先获取城市经纬度\n origin_coords = None\n dest_coords = None\n \n try:\n origin_geo = call_mcp_tool_sync(\"Gaode Server\", \"maps_geo\", address=origin)\n if origin_geo and \"error\" not in str(origin_geo).lower():\n geo_data = json.loads(origin_geo) if isinstance(origin_geo, str) else origin_geo\n if isinstance(geo_data, dict) and 'return' in geo_data:\n if isinstance(geo_data['return'], list) and len(geo_data['return']) > 0:\n origin_coords = geo_data['return'][0].get('location')\n \n dest_geo = call_mcp_tool_sync(\"Gaode Server\", \"maps_geo\", address=destination)\n if dest_geo and \"error\" not in str(dest_geo).lower():\n geo_data = json.loads(dest_geo) if isinstance(dest_geo, str) else dest_geo\n if isinstance(geo_data, dict) and 'return' in geo_data:\n if isinstance(geo_data['return'], list) and len(geo_data['return']) > 0:\n dest_coords = geo_data['return'][0].get('location')\n \n if origin_coords and dest_coords:\n print(f\" ✅ 经纬度: {origin}={origin_coords}, {destination}={dest_coords}\")\n driving_result = call_mcp_tool_sync(\n \"Gaode Server\",\n \"maps_direction_driving\",\n origin=origin_coords,\n destination=dest_coords\n )\n print(f\" ✅ 自驾路线查询成功: {str(driving_result)[:200]}\")\n else:\n print(f\" ⚠️ 无法获取经纬度,跳过自驾路线\")\n except Exception as geo_err:\n print(f\" ⚠️ 地理编码失败: {geo_err}\")\n except Exception as driving_err:\n print(f\" ⚠️ 自驾路线查询异常: {driving_err}\")\n \n # 5. 组合返回结果(包含火车和自驾信息)\n combined_result = {}\n if train_result:\n combined_result[\"train\"] = train_result\n if driving_result:\n combined_result[\"driving\"] = driving_result\n \n if combined_result:\n return json.dumps(combined_result, ensure_ascii=False)\n else:\n return \"交通查询失败\"\n except Exception as e:\n print(f\" 火车票查询异常: {e}\")\n return f\"火车票查询失败: {str(e)}\"\n \n for tool_def in AVAILABLE_TOOLS:\n if tool_def.tool_type == \"mcp\":\n # 特殊处理train_query\n if tool_def.name == \"train_query\":\n def train_tool_func(tool_input: str) -> str:\n try:\n if tool_input.strip().startswith('{'):\n kwargs = json.loads(tool_input)\n else:\n return \"请提供JSON格式的参数\"\n \n origin = kwargs.get(\"origin\") or kwargs.get(\"from\") or kwargs.get(\"fromStation\", \"\")\n destination = kwargs.get(\"destination\") or kwargs.get(\"to\") or kwargs.get(\"toStation\", \"\")\n date = kwargs.get(\"date\", \"\")\n \n return query_train_tickets(origin, destination, date)\n except Exception as e:\n return f\"参数解析失败: {str(e)}\"\n \n tools.append(Tool(\n name=tool_def.name,\n description=tool_def.description,\n func=train_tool_func\n ))\n continue\n \n # 其他MCP工具的通用处理\n def make_sync_tool(server_name, mcp_tool_name, tool_name):\n def call_tool(tool_input: str) -> str:\n \"\"\"Parse input string and call MCP tool\"\"\"\n print(f\"\\n🛠️ [{tool_name}] 调用: {tool_input[:100]}\")\n try:\n if tool_input.strip().startswith('{'):\n kwargs = json.loads(tool_input)\n else:\n kwargs = {\"query\": tool_input}\n \n # 修正日期年份\n if \"date\" in kwargs:\n date_str = kwargs[\"date\"]\n try:\n date_obj = datetime.strptime(date_str, \"%Y-%m-%d\")\n current_year = datetime.now().year\n if date_obj.year < current_year:\n kwargs[\"date\"] = date_str.replace(str(date_obj.year), str(current_year))\n except:\n pass\n \n # 黄历工具特殊处理 - 转换为ISO时间格式\n if tool_name == \"lucky_day\":\n date_val = kwargs.pop('date', None) or kwargs.pop('query', '')\n # 确保日期格式正确\n if date_val:\n date_val = date_val.strip()\n kwargs = {\"solarDatetime\": f\"{date_val}T12:00:00+08:00\"}\n \n # 航班查询工具 - 参数映射\n if tool_name == \"flight_query\":\n dep = kwargs.pop('dep', None) or kwargs.pop('from', None) or kwargs.pop('origin', '')\n arr = kwargs.pop('arr', None) or kwargs.pop('to', None) or kwargs.pop('destination', '')\n date_val = kwargs.pop('date', '')\n # 修正日期年份\n if date_val:\n try:\n date_obj = datetime.strptime(date_val.strip(), \"%Y-%m-%d\")\n if date_obj.year < datetime.now().year:\n date_val = date_val.replace(str(date_obj.year), str(datetime.now().year))\n except:\n pass\n kwargs = {\"dep\": dep, \"arr\": arr, \"date\": date_val}\n \n # 天气工具 - 确保city参数\n if tool_name == \"gaode_weather\":\n city = kwargs.pop(\"city\", None) or kwargs.pop(\"location\", None) or kwargs.pop(\"query\", \"\")\n # 清理城市名:去除日期、空格、逗号后的内容\n if city:\n city = city.strip()\n # 如果包含逗号,取第一部分\n if ',' in city:\n city = city.split(',')[0].strip()\n if ',' in city:\n city = city.split(',')[0].strip()\n # 去除\"市\"后缀保留纯城市名\n city = city.replace('市', '')\n kwargs = {\"city\": city}\n \n # 地理编码工具 - 确保address参数\n if tool_name == \"gaode_geo\":\n address = kwargs.pop(\"address\", None) or kwargs.pop(\"location\", None) or kwargs.pop(\"query\", \"\")\n kwargs = {\"address\": address}\n \n # 酒店搜索工具 - 确保keywords和city参数\n if tool_name == \"gaode_hotel_search\":\n keywords = kwargs.pop(\"keywords\", None) or kwargs.pop(\"keyword\", None) or kwargs.pop(\"query\", \"\")\n city = kwargs.pop(\"city\", None) or kwargs.pop(\"location\", \"\")\n # 如果keywords没有包含\"酒店\"或\"民宿\",自动补充\n if keywords and \"酒店\" not in keywords and \"民宿\" not in keywords:\n keywords = f\"{keywords} 酒店\"\n kwargs = {\"keywords\": keywords, \"city\": city} if city else {\"keywords\": keywords}\n \n # POI搜索工具 - 确保keywords参数\n if tool_name == \"gaode_poi_search\":\n keywords = kwargs.pop(\"keywords\", None) or kwargs.pop(\"keyword\", None) or kwargs.pop(\"query\", \"\")\n city = kwargs.pop(\"city\", None) or kwargs.pop(\"location\", \"\")\n kwargs = {\"keywords\": keywords, \"city\": city} if city else {\"keywords\": keywords}\n \n # 自驾路线工具 - 确保origin和destination参数(应为坐标)\n if tool_name == \"gaode_driving\":\n origin = kwargs.pop(\"origin\", None) or kwargs.pop(\"from\", \"\")\n destination = kwargs.pop(\"destination\", None) or kwargs.pop(\"to\", \"\")\n kwargs = {\"origin\": origin, \"destination\": destination}\n \n print(f\" 参数: {kwargs}\")\n return call_mcp_tool_sync(server_name, mcp_tool_name, **kwargs)\n except json.JSONDecodeError:\n # 对于简单字符串输入,根据工具类型处理\n print(f\" 简单字符串输入: {tool_input}\")\n tool_input_clean = tool_input.strip()\n \n if tool_name == \"gaode_weather\":\n city = tool_input_clean\n if ',' in city:\n city = city.split(',')[0].strip()\n if ',' in city:\n city = city.split(',')[0].strip()\n city = city.replace('市', '')\n return call_mcp_tool_sync(server_name, mcp_tool_name, city=city)\n elif tool_name == \"gaode_geo\":\n return call_mcp_tool_sync(server_name, mcp_tool_name, address=tool_input_clean)\n elif tool_name == \"lucky_day\":\n iso_dt = f\"{tool_input_clean}T12:00:00+08:00\"\n return call_mcp_tool_sync(server_name, mcp_tool_name, solarDatetime=iso_dt)\n elif tool_name == \"gaode_hotel_search\":\n # 如果输入是城市名,补充酒店关键词\n keywords = f\"{tool_input_clean} 酒店\" if \"酒店\" not in tool_input_clean else tool_input_clean\n return call_mcp_tool_sync(server_name, mcp_tool_name, keywords=keywords)\n elif tool_name == \"gaode_poi_search\":\n return call_mcp_tool_sync(server_name, mcp_tool_name, keywords=tool_input_clean)\n else:\n return call_mcp_tool_sync(server_name, mcp_tool_name, query=tool_input_clean)\n except Exception as e:\n return f\"工具调用失败: {str(e)}\"\n return call_tool\n \n # 创建工具\n mcp_tool = Tool(\n name=tool_def.name,\n description=tool_def.description,\n func=make_sync_tool(tool_def.server_name, tool_def.mcp_tool_name, tool_def.name)\n )\n tools.append(mcp_tool)\n \n elif tool_def.tool_type == \"r1\":\n # R1 深度分析工具 - 使用 DeepSeek R1 API\n def make_r1_tool():\n def call_r1_analysis(tool_input: str) -> str:\n \"\"\"使用DeepSeek R1进行深度分析\"\"\"\n print(f\"\\n🧠 [r1_analysis] 深度分析调用: {tool_input[:200]}\")\n try:\n if tool_input.strip().startswith('{'):\n kwargs = json.loads(tool_input)\n else:\n kwargs = {\"problem\": tool_input, \"context\": {}}\n \n problem = kwargs.get(\"problem\", \"\")\n context = kwargs.get(\"context\", {})\n \n # 调用DeepSeek R1 API(使用正确的 DeepSeek 配置)\n from openai import OpenAI\n \n # DeepSeek R1 使用其自己的 API\n deepseek_api_key = os.getenv(\"DEEPSEEK_API_KEY\", \"\")\n if not deepseek_api_key:\n return \"深度分析不可用: 未配置 DEEPSEEK_API_KEY\"\n \n r1_client = OpenAI(\n api_key=deepseek_api_key,\n base_url=\"https://api.deepseek.com\"\n )\n \n # 构建分析请求 - 参考旧版本 nodes.py 中 R1 的 prompt 风格\n analysis_prompt = f\"\"\"你是一个专业的旅行规划优化师。请对以下旅行规划问题进行深度分析和优化:\n\n问题描述:{problem}\n\n已收集的信息:\n{json.dumps(context, ensure_ascii=False, indent=2) if context else '无额外上下文'}\n\n你的任务:\n1. **路线优化**:分析多段行程的最优顺序和连接方式\n2. **时间安排**:每个目的地的合理停留时间\n3. **预算分配**:根据各段行程的物价和景点密度分配预算\n4. **风险评估**:识别天气、交通、时间等风险\n5. **备选方案**:提供经济型、舒适型等不同方案\n\n输出JSON格式(纯JSON,不要markdown代码块):\n{{\n \"route_optimization\": \"路线优化建议\",\n \"time_arrangement\": \"时间安排建议\",\n \"budget_allocation\": {{\n \"城市名\": 预算金额\n }},\n \"risk_warnings\": [\"风险1\", \"风险2\"],\n \"alternative_plans\": [\n {{\n \"name\": \"方案名称\",\n \"description\": \"方案描述\",\n \"total_cost\": 总费用,\n \"pros\": [\"优点1\"],\n \"cons\": [\"缺点1\"]\n }}\n ],\n \"final_recommendation\": \"最终建议\"\n}}\n\"\"\"\n \n response = r1_client.chat.completions.create(\n model=\"deepseek-reasoner\", # 使用正确的模型名\n messages=[{\"role\": \"user\", \"content\": analysis_prompt}],\n max_tokens=4000\n )\n \n result = response.choices[0].message.content\n print(f\" R1分析完成,返回 {len(result)} 字符\")\n return result\n except Exception as e:\n print(f\" R1分析失败: {e}\")\n import traceback\n traceback.print_exc()\n return f\"深度分析暂时不可用: {str(e)}\"\n return call_r1_analysis\n \n tools.append(Tool(\n name=tool_def.name,\n description=tool_def.description,\n func=make_r1_tool()\n ))\n \n return tools\n\n\n# 创建工具\ntry:\n tools = create_tools(retriever)\n if tools:\n tool_names = [t.name for t in tools]\n st.sidebar.success(f\"✅ 已加载 {len(tools)} 个工具\")\n # 显示工具列表以便调试\n with st.sidebar.expander(\"查看工具列表\"):\n for t in tools:\n st.write(f\"- {t.name}\")\n else:\n st.sidebar.warning(\"⚠️ 未加载任何工具\")\nexcept Exception as e:\n st.sidebar.error(f\"❌ 工具加载失败: {e}\")\n tools = []\n\n\n# 初始化聊天历史\nif \"messages\" not in st.session_state:\n st.session_state.messages = [\n {\"role\": \"assistant\", \"content\": \"您好!我是智能旅游规划助手🗺️\\n\\n我可以帮您:\\n- 📍 查询景点攻略和美食推荐\\n- 🚆 查询火车票和航班信息\\n- 🏨 推荐酒店和住宿\\n- ☀️ 查询天气预报\\n- 🗓️ 查询黄历吉日\\n- 🚗 规划自驾路线\\n\\n请告诉我您的旅行需求吧!\"}\n ]\n\n# 显示历史消息\nfor msg in st.session_state.messages:\n with st.chat_message(msg[\"role\"]):\n st.markdown(msg[\"content\"])\n\n\n# 创建聊天历史管理\nmsgs = StreamlitChatMessageHistory()\nmemory = ConversationBufferMemory(\n chat_memory=msgs,\n return_messages=True,\n memory_key=\"chat_history\",\n output_key=\"output\"\n)\n\n\n# ReAct Prompt模板\nfrom datetime import datetime\ncurrent_date = datetime.now().strftime(\"%Y-%m-%d\")\ncurrent_year = datetime.now().year\n\ninstructions = f\"\"\"你是一个专业的旅游规划助手。当前日期是{current_date}。\n\n**完整旅行规划必须包含以下信息,请按顺序调用相应工具:**\n\n1. **交通信息**:\n - 调用 train_query 查询火车票(必须)\n - 如果火车不便,调用 gaode_driving 查询自驾路线\n - 长途(>800km)可调用 flight_query 查询航班\n\n2. **天气预报**:\n - 对每个目的地城市调用 gaode_weather 查询天气(必须)\n - 例如去徐州和青岛,需要分别查询两个城市的天气\n\n3. **住宿推荐**:\n - 调用 gaode_hotel_search 搜索酒店(必须)\n - 根据预算选择关键词:预算紧张用\"经济型酒店\",预算充足用\"酒店\"\n\n4. **黄历吉日**:\n - 调用 lucky_day 查询出发日期的黄历信息\n\n5. **复杂行程优化**(多城市、多段行程):\n - 调用 r1_analysis 进行深度路线优化\n\n**重要规则:**\n- 日期必须使用{current_year}年,例如\"{current_year}-12-18\"\n- 每个工具可以针对不同参数调用多次(如查询多个城市的天气)\n- 用中文回答,提供详细的行程规划\n\"\"\"\n\nbase_prompt_template = \"\"\"\n{instructions}\n\nTOOLS:\n------\nYou have access to the following tools:\n{tools}\n\nTo use a tool, you MUST strictly follow this EXACT format:\n\nThought: Do I need to use a tool? Yes\nAction: the action to take, must be one of [{tool_names}]\nAction Input: the input to the action\nObservation: the result of the action\n\nWhen you have a response to say to the Human, or if you do not need to use a tool, you MUST strictly follow this EXACT format:\n\nThought: Do I need to use a tool? No\nFinal Answer: [your response here]\n\nCRITICAL RULES:\n1. For complete travel planning, you MUST call: train_query, gaode_weather (for each destination), gaode_hotel_search, lucky_day\n2. You CAN call the same tool multiple times with DIFFERENT parameters (e.g. weather for different cities)\n3. NEVER call the same tool with the SAME parameters twice\n4. If a tool returns an error, explain the error to the user and move on\n5. For multi-city complex trips, call r1_analysis to optimize the route\n6. Use Chinese to respond to users\n7. Provide detailed and complete travel plans\n\nBegin!\n\nPrevious conversation history:\n{chat_history}\n\nNew input: {input}\n{agent_scratchpad}\"\"\"\n\n# 创建Prompt\nbase_prompt = PromptTemplate.from_template(base_prompt_template)\nprompt = base_prompt.partial(instructions=instructions)\n\n\n# 创建LLM(使用Qwen3)\n@st.cache_resource\ndef get_llm():\n return ChatOpenAI(\n model=\"qwen-plus\",\n openai_api_key=os.getenv(\"DASHSCOPE_API_KEY\"),\n openai_api_base=\"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n temperature=0.7\n )\n\nllm = get_llm()\n\n\n# 创建ReAct Agent\nagent = create_react_agent(llm, tools, prompt)\n\n# 创建Agent执行器\nmax_iterations = 30 # 复杂行程需要更多迭代\nagent_executor = AgentExecutor(\n agent=agent,\n tools=tools,\n memory=memory,\n verbose=False, # 关闭详细日志,避免UI显示调试信息\n handle_parsing_errors=True,\n max_iterations=max_iterations\n)\n\n\n# 聊天输入框\nif user_query := st.chat_input(placeholder=\"请输入您的旅行需求,例如:我想12月去杭州玩3天\"):\n # 添加用户消息\n st.session_state.messages.append({\"role\": \"user\", \"content\": user_query})\n \n # 显示用户消息\n with st.chat_message(\"user\"):\n st.markdown(user_query)\n \n # 显示助手响应\n with st.chat_message(\"assistant\"):\n # 隐藏调试信息,不显示工具调用过程\n # st_container = st.container()\n # st_cb = StreamlitCallbackHandler(st_container)\n \n # 配置回调(空配置,不显示执行过程)\n config = {}\n \n # ========== 预分析层:判断场景类型 ==========\n with st.spinner(\"🔍 正在分析您的旅行需求...\"):\n pre_analysis = pre_analyze_query(user_query, llm)\n \n scenario_type = pre_analysis['scenario_type']\n needs_r1 = pre_analysis['needs_deep_analysis']\n extraction = pre_analysis['extraction']\n \n # 显示场景类型提示(隐藏技术细节)\n if scenario_type == 'multi_destination':\n st.info(\"🌍 检测到多目的地行程,将调用深度路线优化...\")\n elif scenario_type == 'complex':\n st.info(\"🧠 检测到复杂场景(特殊需求/预算紧张),将进行深度分析...\")\n else:\n st.info(\"✅ 正在为您规划旅行...\")\n \n # ========== 根据场景类型选择不同的处理路径 ==========\n with st.spinner(\"正在规划您的旅行...\"):\n try:\n if needs_r1:\n # 复杂场景 / 多目的地:R1 主导\n # 先调用 R1 进行深度分析和规划\n print(f\"\\n🧠 R1 主导模式: {scenario_type}\")\n \n # 构建 R1 分析的上下文\n r1_context = {\n \"user_query\": user_query,\n \"extraction\": extraction,\n \"scenario_type\": scenario_type,\n \"multi_dest_info\": pre_analysis.get('multi_dest_info', {})\n }\n \n # 构建增强的输入,强制 Agent 先调用 r1_analysis\n enhanced_input = f\"\"\"用户查询: {user_query}\n\nℹ️ **重要提示**:系统检测到这是一个{'多目的地' if scenario_type == 'multi_destination' else '复杂'}场景。\n\n请按以下顺序处理:\n1. **首先调用 r1_analysis** 进行深度路线规划和优化,输入应包含完整的用户需求\n2. 然后根据 R1 的建议,调用其他工具查询具体信息(火车票、天气、酒店等)\n3. 最后综合所有信息生成完整的旅行规划\n\n已提取的信息:\n- 目的地: {extraction.get('destination', '未知')}\n- 出发地: {extraction.get('origin', '未知')}\n- 旅行天数: {extraction.get('travel_days', 0)}\n- 预算: {extraction.get('budget', 0)}元\n- 出发日期: {extraction.get('travel_date', '未知')}\n- 偏好: {extraction.get('preferences', [])}\n\"\"\"\n response = agent_executor.invoke({\"input\": enhanced_input}, config=config)\n else:\n # 简单场景:Qwen3 主导\n print(f\"\\n✅ Qwen3 主导模式: {scenario_type}\")\n response = agent_executor.invoke({\"input\": user_query}, config=config)\n \n # 提取回答\n answer = response.get(\"output\", \"抱歉,我无法生成回答。\")\n \n # 添加助手消息\n st.session_state.messages.append({\"role\": \"assistant\", \"content\": answer})\n \n # 显示回答\n st.markdown(answer)\n \n except Exception as e:\n error_msg = f\"抱歉,处理您的请求时出现错误:{str(e)}\"\n st.error(error_msg)\n st.session_state.messages.append({\"role\": \"assistant\", \"content\": error_msg})\n\n\n# 侧边栏 - 显示当前配置\nwith st.sidebar:\n st.markdown(\"---\")\n st.header(\"📊 当前配置\")\n st.write(f\"- 文档数量: {len(uploaded_files)}\")\n st.write(f\"- 工具数量: {len(tools)}\")\n st.write(f\"- 最大迭代: {max_iterations}\")\n st.write(f\"- 对话轮数: {len(st.session_state.messages)}\")\n"}}}} |