travel_api.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. #!/usr/bin/env python3
  2. """
  3. travelagent67 v3 — Native Function Calling + Real Amap API
  4. Uses OpenAI tools/function_calling protocol (not text parsing).
  5. """
  6. import json
  7. import os
  8. import re
  9. import sys
  10. import time
  11. import urllib.request
  12. import urllib.parse
  13. from datetime import datetime
  14. from flask import Flask, request, jsonify
  15. app = Flask(__name__)
  16. SILICONFLOW_URL = "https://api.siliconflow.cn/v1/chat/completions"
  17. # 设置: export SILICONFLOW_API_KEY=... (申请: https://cloud.siliconflow.cn/account/ak)
  18. SILICONFLOW_KEY = os.environ.get("SILICONFLOW_API_KEY", "")
  19. MODEL = "Qwen/Qwen2.5-32B-Instruct"
  20. # 设置: export AMAP_MCP_KEY=... (申请: https://lbs.amap.com/dev/key/app)
  21. AMAP_KEY = os.environ.get("AMAP_MCP_KEY", "")
  22. AMAP_BASE = "https://restapi.amap.com/v3"
  23. HISTORY_FILE = os.environ.get("HISTORY_FILE", "/app/qademo/travel_history.json")
  24. # ══════════════════════════════════════════
  25. # Amap Tools (real API)
  26. # ══════════════════════════════════════════
  27. def amap_text_search(keywords, city="", types=""):
  28. params = f"key={AMAP_KEY}&keywords={urllib.parse.quote(keywords)}&output=json"
  29. if city: params += f"&city={urllib.parse.quote(city)}"
  30. if types: params += f"&types={urllib.parse.quote(types)}"
  31. with urllib.request.urlopen(f"{AMAP_BASE}/place/text?{params}", timeout=10) as r:
  32. data = json.loads(r.read())
  33. return [{"name": p.get("name",""), "address": p.get("address",""),
  34. "type": p.get("type",""), "location": p.get("location",""),
  35. "tel": p.get("tel","")} for p in data.get("pois",[])[:5]]
  36. def amap_around_search(location, keywords="", types="", radius=3000):
  37. params = f"key={AMAP_KEY}&location={location}&radius={radius}&output=json"
  38. if keywords: params += f"&keywords={urllib.parse.quote(keywords)}"
  39. if types: params += f"&types={urllib.parse.quote(types)}"
  40. with urllib.request.urlopen(f"{AMAP_BASE}/place/around?{params}", timeout=10) as r:
  41. data = json.loads(r.read())
  42. return [{"name": p.get("name",""), "address": p.get("address",""),
  43. "distance": p.get("distance",""), "location": p.get("location","")}
  44. for p in data.get("pois",[])[:5]]
  45. def amap_geocode(address, city=""):
  46. params = f"key={AMAP_KEY}&address={urllib.parse.quote(address)}&output=json"
  47. if city:
  48. params += f"&city={urllib.parse.quote(city)}"
  49. with urllib.request.urlopen(f"{AMAP_BASE}/geocode/geo?{params}", timeout=10) as r:
  50. data = json.loads(r.read())
  51. g = data.get("geocodes",[{}])[0]
  52. return {"address": g.get("formatted_address",""), "location": g.get("location",""),
  53. "city": g.get("city",""), "district": g.get("district","")}
  54. def amap_weather(city):
  55. params = f"key={AMAP_KEY}&city={urllib.parse.quote(city)}&extensions=all&output=json"
  56. with urllib.request.urlopen(f"{AMAP_BASE}/weather/weatherInfo?{params}", timeout=10) as r:
  57. data = json.loads(r.read())
  58. f = data.get("forecasts",[{}])[0]
  59. return {"city": f.get("city",""), "forecasts": [
  60. {"date": c.get("date",""), "day": c.get("dayweather",""),
  61. "night": c.get("nightweather",""), "daytemp": c.get("daytemp",""),
  62. "nighttemp": c.get("nighttemp","")} for c in f.get("casts",[])[:4]]}
  63. def amap_driving(origin, destination):
  64. params = f"key={AMAP_KEY}&origin={origin}&destination={destination}&output=json"
  65. with urllib.request.urlopen(f"{AMAP_BASE}/direction/driving?{params}", timeout=10) as r:
  66. data = json.loads(r.read())
  67. p = data.get("route",{}).get("paths",[{}])[0]
  68. return {"distance_m": p.get("distance",""), "duration_s": p.get("duration",""),
  69. "strategy": p.get("strategy","")}
  70. def amap_district(keywords):
  71. params = f"key={AMAP_KEY}&keywords={urllib.parse.quote(keywords)}&subdistrict=1&output=json"
  72. with urllib.request.urlopen(f"{AMAP_BASE}/config/district?{params}", timeout=10) as r:
  73. data = json.loads(r.read())
  74. d = data.get("districts",[{}])[0]
  75. return {"name": d.get("name",""), "center": d.get("center",""),
  76. "level": d.get("level",""),
  77. "sub": [s.get("name","") for s in d.get("districts",[])[:10]]}
  78. # Tool dispatch — args may be dict or JSON string (LLM sometimes sends string)
  79. def _ensure_dict(a):
  80. if isinstance(a, str):
  81. try:
  82. return json.loads(a)
  83. except:
  84. return {"input": a}
  85. return a
  86. TOOL_FNS = {
  87. "maps_text_search": lambda a: amap_text_search(_ensure_dict(a).get("keywords",""), _ensure_dict(a).get("city",""), _ensure_dict(a).get("types","")),
  88. "maps_around_search": lambda a: amap_around_search(_ensure_dict(a).get("location",""), _ensure_dict(a).get("keywords",""), _ensure_dict(a).get("types",""), _ensure_dict(a).get("radius",3000)),
  89. "maps_geo": lambda a: amap_geocode(_ensure_dict(a).get("address",""), _ensure_dict(a).get("city","")),
  90. "maps_weather": lambda a: amap_weather(_ensure_dict(a).get("city","")),
  91. "maps_driving": lambda a: amap_driving(_ensure_dict(a).get("origin",""), _ensure_dict(a).get("destination","")),
  92. "maps_district_search": lambda a: amap_district(_ensure_dict(a).get("keywords","")),
  93. }
  94. # OpenAI tools schema for function calling
  95. TOOLS_SCHEMA = [
  96. {"type": "function", "function": {
  97. "name": "maps_text_search",
  98. "description": "按关键词搜索POI(景点、餐厅、酒店等),返回名称、地址、经纬度",
  99. "parameters": {"type": "object", "properties": {
  100. "keywords": {"type": "string", "description": "搜索关键词,如'北京 景点'"},
  101. "city": {"type": "string", "description": "城市名称"},
  102. "types": {"type": "string", "description": "POI类型: 风景名胜|餐饮服务|住宿服务|购物服务|交通设施"},
  103. }, "required": ["keywords"]}}},
  104. {"type": "function", "function": {
  105. "name": "maps_around_search",
  106. "description": "在指定坐标附近搜索POI",
  107. "parameters": {"type": "object", "properties": {
  108. "location": {"type": "string", "description": "中心坐标,格式'经度,纬度'"},
  109. "keywords": {"type": "string", "description": "搜索关键词"},
  110. "types": {"type": "string", "description": "POI类型"},
  111. "radius": {"type": "integer", "description": "搜索半径(米),默认3000"},
  112. }, "required": ["location"]}}},
  113. {"type": "function", "function": {
  114. "name": "maps_geo",
  115. "description": "将地址转换为经纬度坐标。务必传入city参数以确保定位到正确城市",
  116. "parameters": {"type": "object", "properties": {
  117. "address": {"type": "string", "description": "地址,如'故宫博物院'"},
  118. "city": {"type": "string", "description": "所在城市,如'北京'。必填,防止同名地点定位到错误城市"},
  119. }, "required": ["address", "city"]}}},
  120. {"type": "function", "function": {
  121. "name": "maps_weather",
  122. "description": "查询城市天气预报",
  123. "parameters": {"type": "object", "properties": {
  124. "city": {"type": "string", "description": "城市名称,如'北京'"},
  125. }, "required": ["city"]}}},
  126. {"type": "function", "function": {
  127. "name": "maps_driving",
  128. "description": "查询两点间驾车路线、距离和耗时",
  129. "parameters": {"type": "object", "properties": {
  130. "origin": {"type": "string", "description": "起点坐标'经度,纬度'"},
  131. "destination": {"type": "string", "description": "终点坐标'经度,纬度'"},
  132. }, "required": ["origin", "destination"]}}},
  133. {"type": "function", "function": {
  134. "name": "maps_district_search",
  135. "description": "查询行政区划信息",
  136. "parameters": {"type": "object", "properties": {
  137. "keywords": {"type": "string", "description": "城市或区域名称"},
  138. }, "required": ["keywords"]}}},
  139. ]
  140. SYSTEM_PROMPT = """你是一位专业的旅行规划助手。请根据用户请求,使用提供的地图工具查询真实POI和路线,生成详细行程计划。
  141. 【规划策略】
  142. 1. 先用 maps_weather 查天气,maps_district_search 了解区划
  143. 2. 搜索景点: maps_text_search(keywords="故宫 天坛 颐和园", city="北京", types="风景名胜")
  144. 搜索美食: maps_text_search(keywords="北京烤鸭", city="北京", types="餐饮服务")
  145. 搜索酒店: maps_text_search(keywords="酒店", city="北京", types="住宿服务")
  146. 注意: types 必须用以下标准值之一: 风景名胜|餐饮服务|住宿服务|购物服务
  147. 搜索景点时,keywords 要包含具体知名景点名称,不要只写泛泛的"景点"
  148. 多次搜索不同类型的景点,确保覆盖充足:
  149. - 第一次: 核心景点(故宫、天坛等)
  150. - 第二次: 特色景点(南锣鼓巷、798等)
  151. - 第三次: 自然景点(颐和园、北海公园等)
  152. 3. 用 maps_geo 验证关键地点坐标,**必须传 city 参数**防止定位到其他城市的同名地点
  153. 4. 编排行程后,用 maps_driving 验证相邻景点的通勤时间
  154. 5. 所有地点必须来自工具返回的真实数据,严禁编造
  155. 6. 每天安排:上午景点→午餐→下午景点→晚餐→住宿,至少5项
  156. 7. **严禁重复**:不同天的行程中不能出现相同的地点名称
  157. 【最终输出】
  158. 规划完成后,直接输出纯JSON(不要代码块),格式:
  159. {"title":"行程标题","estimatedCost":1500.0,"totalDays":3,"dailyPlans":[{"dayNumber":1,"items":[{"type":"景点|餐饮|住宿|购物","name":"地点名","description":"描述","location":{"lat":39.9,"lng":116.4,"name":"位置"}}]}]}"""
  160. # ══════════════════════════════════════════
  161. # ReAct with native function calling
  162. # ══════════════════════════════════════════
  163. def llm_call_with_tools(messages, tools=None, max_tokens=4096):
  164. """Call LLM with function calling support"""
  165. body = {
  166. "model": MODEL,
  167. "messages": messages,
  168. "temperature": 0.2,
  169. "max_tokens": max_tokens,
  170. }
  171. if tools:
  172. body["tools"] = tools
  173. body["tool_choice"] = "auto"
  174. data = json.dumps(body, ensure_ascii=False).encode('utf-8')
  175. req = urllib.request.Request(
  176. SILICONFLOW_URL, data=data,
  177. headers={"Content-Type": "application/json", "Authorization": f"Bearer {SILICONFLOW_KEY}"},
  178. method="POST"
  179. )
  180. with urllib.request.urlopen(req, timeout=120) as resp:
  181. return json.loads(resp.read())
  182. def plan_travel(query, max_steps=25):
  183. """ReAct loop with native function calling"""
  184. messages = [
  185. {"role": "system", "content": SYSTEM_PROMPT},
  186. {"role": "user", "content": query},
  187. ]
  188. tool_log = []
  189. for step in range(max_steps):
  190. resp = llm_call_with_tools(messages, tools=TOOLS_SCHEMA)
  191. msg = resp["choices"][0]["message"]
  192. # Check for tool calls
  193. tool_calls = msg.get("tool_calls")
  194. if tool_calls:
  195. # Append assistant message with tool_calls
  196. messages.append(msg)
  197. # Execute each tool call
  198. for tc in tool_calls:
  199. fn_name = tc["function"]["name"]
  200. fn_args = json.loads(tc["function"]["arguments"])
  201. if fn_name in TOOL_FNS:
  202. try:
  203. result = TOOL_FNS[fn_name](fn_args)
  204. result_str = json.dumps(result, ensure_ascii=False)
  205. if len(result_str) > 3000:
  206. result_str = result_str[:3000] + "...[truncated]"
  207. except Exception as e:
  208. result_str = json.dumps({"error": str(e)})
  209. else:
  210. result_str = json.dumps({"error": f"Unknown tool: {fn_name}"})
  211. tool_log.append({"step": step+1, "tool": fn_name, "args": fn_args, "result_preview": result_str[:200]})
  212. # Add tool result as tool message
  213. messages.append({
  214. "role": "tool",
  215. "tool_call_id": tc["id"],
  216. "content": result_str,
  217. })
  218. else:
  219. # No tool calls — LLM returned final content
  220. content = msg.get("content", "")
  221. # Try to extract JSON
  222. try:
  223. # Strip code fences
  224. clean = content.strip()
  225. if clean.startswith("```"):
  226. clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
  227. if clean.endswith("```"):
  228. clean = clean[:-3]
  229. clean = clean.strip()
  230. if clean.startswith("json"):
  231. clean = clean[4:].strip()
  232. itinerary = json.loads(clean)
  233. return itinerary, tool_log, step + 1
  234. except:
  235. m = re.search(r'\{[\s\S]*"dailyPlans"[\s\S]*\}', content)
  236. if m:
  237. try:
  238. return json.loads(m.group()), tool_log, step + 1
  239. except:
  240. pass
  241. # Ask to finalize
  242. messages.append({"role": "user", "content": "请直接输出最终行程JSON,不要其他文字。"})
  243. return None, tool_log, max_steps
  244. def _postprocess_itinerary(itinerary):
  245. """Post-process: deduplicate items, validate structure"""
  246. if not itinerary or "dailyPlans" not in itinerary:
  247. return itinerary
  248. seen_names = set()
  249. for day in itinerary.get("dailyPlans", []):
  250. deduped = []
  251. for item in day.get("items", []):
  252. name = item.get("name", "")
  253. if name and name not in seen_names:
  254. seen_names.add(name)
  255. deduped.append(item)
  256. day["items"] = deduped
  257. return itinerary
  258. # ══════════════════════════════════════════
  259. # API
  260. # ══════════════════════════════════════════
  261. @app.route('/v1/chat/completions', methods=['POST'])
  262. def chat_completions():
  263. data = request.json or {}
  264. msgs = data.get('messages', [])
  265. if not msgs: return jsonify({'error': 'messages required'}), 400
  266. user_msg = next((m['content'] for m in msgs if m['role'] == 'user'), '')
  267. t0 = time.time()
  268. try:
  269. itinerary, log, steps = plan_travel(user_msg)
  270. itinerary = _postprocess_itinerary(itinerary)
  271. content = json.dumps(itinerary, ensure_ascii=False) if itinerary else "Failed"
  272. return jsonify({"id": f"travel-{int(time.time()*1000)}", "object": "chat.completion",
  273. "model": "travelagent67-v3-react", "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
  274. "usage": {"steps": steps, "tools_called": len(log)}, "tool_log": log})
  275. except Exception as e:
  276. return jsonify({"error": {"message": str(e)}}), 500
  277. @app.route('/api/travel/plan', methods=['POST'])
  278. def api_plan():
  279. data = request.json or {}
  280. query = data.get('query', '').strip()
  281. if not query: return jsonify({'error': 'query required'}), 400
  282. t0 = time.time()
  283. try:
  284. itinerary, log, steps = plan_travel(query)
  285. itinerary = _postprocess_itinerary(itinerary)
  286. return jsonify({'status': 'done', 'query': query, 'itinerary': itinerary,
  287. 'elapsed': round(time.time()-t0, 1), 'steps': steps, 'tools_called': len(log), 'tool_log': log})
  288. except Exception as e:
  289. return jsonify({'status': 'error', 'error': str(e)}), 500
  290. @app.route('/api/health')
  291. def health():
  292. try:
  293. amap_weather("北京")
  294. amap_ok = True
  295. except:
  296. amap_ok = False
  297. return jsonify({'status': 'ok', 'agent': 'travelagent67-v3-react',
  298. 'model': MODEL, 'amap_api': 'connected' if amap_ok else 'error',
  299. 'function_calling': True, 'tools': list(TOOL_FNS.keys())})
  300. @app.route('/')
  301. def index():
  302. return jsonify({'service': 'travelagent67-v3 (Native Function Calling + Amap API)',
  303. 'tools': list(TOOL_FNS.keys()), 'function_calling': True})
  304. if __name__ == '__main__':
  305. print("travelagent67 v3 (Native Function Calling)")
  306. print(f"Tools: {list(TOOL_FNS.keys())}")
  307. try:
  308. w = amap_weather("北京")
  309. print(f"Amap: OK ({w['city']} {w['forecasts'][0]['day']})")
  310. except Exception as e:
  311. print(f"Amap: {e}")
  312. print("http://0.0.0.0:8081")
  313. app.run(host='0.0.0.0', port=8081, debug=False, threaded=True)