travel-agent-cli 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +6 -6
- package/package.json +2 -2
- package/python/agents/__init__.py +19 -0
- package/python/agents/analysis_agent.py +234 -0
- package/python/agents/base.py +377 -0
- package/python/agents/collector_agent.py +304 -0
- package/python/agents/manager_agent.py +251 -0
- package/python/agents/planning_agent.py +161 -0
- package/python/agents/product_agent.py +672 -0
- package/python/agents/report_agent.py +172 -0
- package/python/analyzers/__init__.py +10 -0
- package/python/analyzers/hot_score.py +123 -0
- package/python/analyzers/ranker.py +225 -0
- package/python/analyzers/route_planner.py +86 -0
- package/python/cli/commands.py +254 -0
- package/python/collectors/__init__.py +14 -0
- package/python/collectors/ota/ctrip.py +120 -0
- package/python/collectors/ota/fliggy.py +152 -0
- package/python/collectors/weibo.py +235 -0
- package/python/collectors/wenlv.py +155 -0
- package/python/collectors/xiaohongshu.py +170 -0
- package/python/config/__init__.py +30 -0
- package/python/config/models.py +119 -0
- package/python/config/prompts.py +105 -0
- package/python/config/settings.py +172 -0
- package/python/export/__init__.py +6 -0
- package/python/export/report.py +192 -0
- package/python/main.py +632 -0
- package/python/pyproject.toml +51 -0
- package/python/scheduler/tasks.py +77 -0
- package/python/tools/fliggy_mcp.py +553 -0
- package/python/tools/flyai_tools.py +251 -0
- package/python/tools/mcp_tools.py +412 -0
- package/python/utils/__init__.py +9 -0
- package/python/utils/http.py +73 -0
- package/python/utils/storage.py +288 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""报告 Agent - 负责生成旅行推荐报告"""
|
|
2
|
+
from typing import Dict, Any, List, Optional
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from agents.base import BaseAgent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReportAgent(BaseAgent):
|
|
9
|
+
"""报告 Agent
|
|
10
|
+
|
|
11
|
+
职责:
|
|
12
|
+
- 撰写旅行推荐报告
|
|
13
|
+
- 格式化输出
|
|
14
|
+
- 生成 Markdown 内容
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name = "report_agent"
|
|
18
|
+
role = "旅行专栏作家"
|
|
19
|
+
goal = "撰写生动有趣、专业实用的旅行推荐报告"
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
provider: Optional[str] = None,
|
|
24
|
+
model: Optional[str] = None,
|
|
25
|
+
use_tools: bool = False,
|
|
26
|
+
):
|
|
27
|
+
super().__init__(provider, model, use_tools)
|
|
28
|
+
|
|
29
|
+
async def execute_local(self, task: str, context: Dict[str, Any]) -> str:
|
|
30
|
+
"""本地生成报告"""
|
|
31
|
+
destinations = context.get("destinations", [])
|
|
32
|
+
routes = context.get("routes", [])
|
|
33
|
+
|
|
34
|
+
# 生成简化的 Markdown 报告
|
|
35
|
+
lines = [
|
|
36
|
+
"# 旅行目的地推荐报告",
|
|
37
|
+
"",
|
|
38
|
+
f"> 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
|
39
|
+
"",
|
|
40
|
+
f"本期推荐 {len(destinations)} 个热门旅行目的地。",
|
|
41
|
+
"",
|
|
42
|
+
"---",
|
|
43
|
+
"",
|
|
44
|
+
"## 🏆 TOP 目的地推荐",
|
|
45
|
+
""
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
for dest in destinations[:10]:
|
|
49
|
+
lines.extend([
|
|
50
|
+
f"### {dest.get('rank', '?')}. {dest.get('name', '未知')}",
|
|
51
|
+
"",
|
|
52
|
+
f"**得分:** {dest.get('score', 0)}/10",
|
|
53
|
+
f"**理由:** {dest.get('reason', '')}",
|
|
54
|
+
"",
|
|
55
|
+
f"- 💰 **费用:** {dest.get('estimated_cost', 'N/A')}",
|
|
56
|
+
f"- 📅 **天数:** {dest.get('suggested_days', 0)}天",
|
|
57
|
+
f"- 🕐 **最佳时间:** {dest.get('best_time', 'N/A')}",
|
|
58
|
+
""
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
return "\n".join(lines)
|
|
62
|
+
|
|
63
|
+
async def write_report(
|
|
64
|
+
self,
|
|
65
|
+
recommendations: List[Dict[str, Any]],
|
|
66
|
+
routes: List[Dict[str, Any]],
|
|
67
|
+
summary: str = None
|
|
68
|
+
) -> str:
|
|
69
|
+
"""撰写完整报告
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
recommendations: 推荐目的地列表
|
|
73
|
+
routes: 路线规划列表
|
|
74
|
+
summary: 可选的总结文字
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Markdown 格式报告
|
|
78
|
+
"""
|
|
79
|
+
task = f"""你是一位资深旅行专栏作家。请根据以下数据撰写一篇完整的旅行推荐报告。
|
|
80
|
+
|
|
81
|
+
## 推荐目的地
|
|
82
|
+
{json.dumps(recommendations, ensure_ascii=False, indent=2, default=str)}
|
|
83
|
+
|
|
84
|
+
## 路线规划
|
|
85
|
+
{json.dumps(routes, ensure_ascii=False, indent=2, default=str)}
|
|
86
|
+
|
|
87
|
+
## 报告要求
|
|
88
|
+
|
|
89
|
+
1. **标题**:要有吸引力,体现"热点推荐"主题
|
|
90
|
+
2. **导语**:100-200 字引人入胜的开场
|
|
91
|
+
3. **TOP10 目的地**:每个目的地包含:
|
|
92
|
+
- 排名和名称
|
|
93
|
+
- 综合得分
|
|
94
|
+
- 推荐理由(生动详细,100-200 字)
|
|
95
|
+
- 实用信息(费用、天数、最佳时间)
|
|
96
|
+
- 亮点特色
|
|
97
|
+
4. **详细路线**:为 TOP3-5 目的地提供详细行程
|
|
98
|
+
5. **出行提示**:实用的旅行建议
|
|
99
|
+
6. **语言风格**:轻松有趣,适合社交媒体传播
|
|
100
|
+
|
|
101
|
+
## 输出格式
|
|
102
|
+
|
|
103
|
+
请输出完整的 Markdown 格式报告,包含:
|
|
104
|
+
- 吸引人的标题(# 一级标题)
|
|
105
|
+
- 导语(引用块)
|
|
106
|
+
- 各目的地介绍(## 二级标题 + ### 三级标题)
|
|
107
|
+
- 路线详情(使用表格)
|
|
108
|
+
- 出行提示列表
|
|
109
|
+
|
|
110
|
+
{f"参考总结:{summary}" if summary else ""}
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
return await self.execute(task, {
|
|
114
|
+
"recommendations": recommendations,
|
|
115
|
+
"routes": routes,
|
|
116
|
+
"summary": summary
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
async def write_summary(
|
|
120
|
+
self,
|
|
121
|
+
destinations: List[Dict[str, Any]]
|
|
122
|
+
) -> str:
|
|
123
|
+
"""生成报告摘要
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
destinations: 目的地列表
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
摘要文字
|
|
130
|
+
"""
|
|
131
|
+
task = f"""请为以下旅行目的地推荐生成一段简短的摘要(100-200 字)。
|
|
132
|
+
|
|
133
|
+
目的地列表:
|
|
134
|
+
{json.dumps([{"name": d.get("name"), "score": d.get("score"), "reason": d.get("reason")} for d in destinations[:10]], ensure_ascii=False, indent=2)}
|
|
135
|
+
|
|
136
|
+
摘要要求:
|
|
137
|
+
- 概括本期推荐特点
|
|
138
|
+
- 提及 TOP3 目的地
|
|
139
|
+
- 语言生动有吸引力
|
|
140
|
+
- 适合放在报告开头
|
|
141
|
+
|
|
142
|
+
直接输出摘要内容,不需要标题。"""
|
|
143
|
+
|
|
144
|
+
return await self.execute(task, {"destinations": destinations})
|
|
145
|
+
|
|
146
|
+
async def format_report(
|
|
147
|
+
self,
|
|
148
|
+
content: str,
|
|
149
|
+
output_format: str = "markdown"
|
|
150
|
+
) -> str:
|
|
151
|
+
"""格式化报告
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
content: 报告内容
|
|
155
|
+
output_format: 输出格式(markdown/html/text)
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
格式化后的内容
|
|
159
|
+
"""
|
|
160
|
+
if output_format == "markdown":
|
|
161
|
+
return content
|
|
162
|
+
elif output_format == "html":
|
|
163
|
+
# 简单的 Markdown 转 HTML
|
|
164
|
+
task = f"""请将以下 Markdown 内容转换为 HTML:
|
|
165
|
+
|
|
166
|
+
{content}
|
|
167
|
+
|
|
168
|
+
直接输出 HTML 代码,不需要解释。"""
|
|
169
|
+
return await self.execute(task, {"content": content})
|
|
170
|
+
else:
|
|
171
|
+
# 纯文本
|
|
172
|
+
return content
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""热度评分计算模块"""
|
|
2
|
+
from typing import List, Dict
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from config.models import SocialPost, WenlvInfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HotScoreCalculator:
|
|
8
|
+
"""热度评分计算器"""
|
|
9
|
+
|
|
10
|
+
def calculate_post_score(self, post: SocialPost, days_decay: float = 0.1) -> float:
|
|
11
|
+
"""计算单条帖子的热度得分
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
post: 社交媒体帖子
|
|
15
|
+
days_decay: 每日衰减系数
|
|
16
|
+
|
|
17
|
+
评分维度:
|
|
18
|
+
- 基础互动分:点赞、评论、转发
|
|
19
|
+
- 时间衰减:越新的内容权重越高
|
|
20
|
+
- 平台权重:不同平台影响力不同
|
|
21
|
+
"""
|
|
22
|
+
# 基础互动分
|
|
23
|
+
base_score = post.engagement_score
|
|
24
|
+
|
|
25
|
+
# 时间衰减
|
|
26
|
+
if post.published_at:
|
|
27
|
+
days_old = (datetime.now() - post.published_at).days
|
|
28
|
+
time_factor = 0.9 ** days_old # 每天衰减 10%
|
|
29
|
+
else:
|
|
30
|
+
time_factor = 0.5
|
|
31
|
+
|
|
32
|
+
# 平台权重
|
|
33
|
+
platform_weights = {
|
|
34
|
+
"xiaohongshu": 1.2, # 小红书权重较高
|
|
35
|
+
"weibo": 1.0,
|
|
36
|
+
"wenlv": 0.8,
|
|
37
|
+
}
|
|
38
|
+
platform_weight = platform_weights.get(post.source.value, 1.0)
|
|
39
|
+
|
|
40
|
+
return base_score * time_factor * platform_weight
|
|
41
|
+
|
|
42
|
+
def calculate_destination_score(
|
|
43
|
+
self,
|
|
44
|
+
destination: str,
|
|
45
|
+
posts: List[SocialPost],
|
|
46
|
+
wenlv_infos: List[WenlvInfo],
|
|
47
|
+
flight_price: float = 0,
|
|
48
|
+
hotel_price: float = 0,
|
|
49
|
+
) -> Dict:
|
|
50
|
+
"""计算目的地综合得分
|
|
51
|
+
|
|
52
|
+
评分维度:
|
|
53
|
+
- 社交媒体热度(40%)
|
|
54
|
+
- 政策支持(20%)
|
|
55
|
+
- 性价比(20%)
|
|
56
|
+
- 季节性(20%)
|
|
57
|
+
"""
|
|
58
|
+
# 1. 社交媒体热度
|
|
59
|
+
related_posts = [
|
|
60
|
+
p for p in posts
|
|
61
|
+
if destination in p.title or destination in p.content or
|
|
62
|
+
any(destination in tag for tag in p.tags)
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
social_score = sum(self.calculate_post_score(p) for p in related_posts)
|
|
66
|
+
# 归一化到 0-10
|
|
67
|
+
social_score = min(10, social_score / 1000) if social_score > 0 else 5
|
|
68
|
+
|
|
69
|
+
# 2. 政策支持
|
|
70
|
+
related_wenlv = [w for w in wenlv_infos if destination in w.title or destination in w.content]
|
|
71
|
+
policy_score = len(related_wenlv) * 2 # 每条政策加 2 分
|
|
72
|
+
policy_score = min(10, policy_score)
|
|
73
|
+
|
|
74
|
+
# 3. 性价比(价格越低分越高)
|
|
75
|
+
avg_price = (flight_price + hotel_price) / 2 if (flight_price and hotel_price) else 500
|
|
76
|
+
if avg_price > 0:
|
|
77
|
+
price_score = max(1, 10 - (avg_price / 200)) # 每 200 元扣 1 分
|
|
78
|
+
else:
|
|
79
|
+
price_score = 5
|
|
80
|
+
|
|
81
|
+
# 4. 季节性(当前月份适合度)
|
|
82
|
+
current_month = datetime.now().month
|
|
83
|
+
seasonal_score = self._get_seasonal_score(destination, current_month)
|
|
84
|
+
|
|
85
|
+
# 综合得分
|
|
86
|
+
total_score = (
|
|
87
|
+
social_score * 0.4 +
|
|
88
|
+
policy_score * 0.2 +
|
|
89
|
+
price_score * 0.2 +
|
|
90
|
+
seasonal_score * 0.2
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"destination": destination,
|
|
95
|
+
"social_score": round(social_score, 2),
|
|
96
|
+
"policy_score": round(policy_score, 2),
|
|
97
|
+
"price_score": round(price_score, 2),
|
|
98
|
+
"seasonal_score": round(seasonal_score, 2),
|
|
99
|
+
"total_score": round(total_score, 2),
|
|
100
|
+
"post_count": len(related_posts),
|
|
101
|
+
"policy_count": len(related_wenlv),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def _get_seasonal_score(self, destination: str, month: int) -> float:
|
|
105
|
+
"""获取目的地季节性评分"""
|
|
106
|
+
# 简化的季节性匹配
|
|
107
|
+
seasonal_destinations = {
|
|
108
|
+
# 春季 (3-5 月)
|
|
109
|
+
(3, 4, 5): ["婺源", "林芝", "罗平", "兴化", "杭州", "苏州"],
|
|
110
|
+
# 夏季 (6-8 月)
|
|
111
|
+
(6, 7, 8): ["青海湖", "呼伦贝尔", "丽江", "大理", "香格里拉", "西北大环线"],
|
|
112
|
+
# 秋季 (9-11 月)
|
|
113
|
+
(9, 10, 11): ["九寨沟", "喀纳斯", "稻城亚丁", "长白山", "腾冲"],
|
|
114
|
+
# 冬季 (12-2 月)
|
|
115
|
+
(12, 1, 2): ["三亚", "西双版纳", "哈尔滨", "雪乡", "北海"],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for months, dests in seasonal_destinations.items():
|
|
119
|
+
if month in months:
|
|
120
|
+
if any(d in destination for d in dests):
|
|
121
|
+
return 10.0
|
|
122
|
+
|
|
123
|
+
return 6.0 # 默认分数
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""目的地排序器 - 使用 Claude API 进行分析排序"""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
from typing import List, Optional, Dict
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from anthropic import Anthropic
|
|
8
|
+
from config.models import (
|
|
9
|
+
SocialPost,
|
|
10
|
+
WenlvInfo,
|
|
11
|
+
FlightInfo,
|
|
12
|
+
HotelInfo,
|
|
13
|
+
DestinationRecommendation,
|
|
14
|
+
)
|
|
15
|
+
from config.settings import get_settings
|
|
16
|
+
from config.prompts import DESTINATION_ANALYSIS_PROMPT
|
|
17
|
+
from utils.storage import Storage
|
|
18
|
+
from analyzers.hot_score import HotScoreCalculator
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DestinationRanker:
|
|
22
|
+
"""目的地排序器"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.settings = get_settings()
|
|
26
|
+
self.storage = Storage()
|
|
27
|
+
self.score_calculator = HotScoreCalculator()
|
|
28
|
+
|
|
29
|
+
# 初始化 Claude 客户端
|
|
30
|
+
if self.settings.anthropic_api_key:
|
|
31
|
+
self.client = Anthropic(api_key=self.settings.anthropic_api_key)
|
|
32
|
+
else:
|
|
33
|
+
self.client = None
|
|
34
|
+
print("警告:未配置 ANTHROPIC_API_KEY,将使用本地排序")
|
|
35
|
+
|
|
36
|
+
async def generate_recommendations(
|
|
37
|
+
self,
|
|
38
|
+
limit: int = 15,
|
|
39
|
+
) -> List[DestinationRecommendation]:
|
|
40
|
+
"""生成目的地推荐列表"""
|
|
41
|
+
# 1. 从数据库获取采集的数据
|
|
42
|
+
posts = await self.storage.get_recent_posts(limit=200)
|
|
43
|
+
wenlv_infos = await self.storage.get_recent_wenlv(limit=50)
|
|
44
|
+
|
|
45
|
+
# 2. 从数据中提取目的地
|
|
46
|
+
destinations = self._extract_destinations(posts, wenlv_infos)
|
|
47
|
+
|
|
48
|
+
# 3. 计算每个目的地的得分
|
|
49
|
+
scored_destinations = []
|
|
50
|
+
for dest, info in destinations.items():
|
|
51
|
+
score_info = self.score_calculator.calculate_destination_score(
|
|
52
|
+
destination=dest,
|
|
53
|
+
posts=info["posts"],
|
|
54
|
+
wenlv_infos=info["wenlv"],
|
|
55
|
+
)
|
|
56
|
+
scored_destinations.append((dest, score_info, info))
|
|
57
|
+
|
|
58
|
+
# 4. 排序
|
|
59
|
+
scored_destinations.sort(key=lambda x: x[1]["total_score"], reverse=True)
|
|
60
|
+
|
|
61
|
+
# 5. 使用 Claude 生成详细推荐(如果 API 可用)
|
|
62
|
+
if self.client:
|
|
63
|
+
return await self._generate_with_clause(
|
|
64
|
+
scored_destinations[:limit]
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
return self._generate_local_recommendations(
|
|
68
|
+
scored_destinations[:limit]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _extract_destinations(
|
|
72
|
+
self, posts: List[SocialPost], wenlv_infos: List[WenlvInfo]
|
|
73
|
+
) -> Dict:
|
|
74
|
+
"""从采集的数据中提取目的地"""
|
|
75
|
+
destinations = {}
|
|
76
|
+
|
|
77
|
+
# 常见旅游目的地列表
|
|
78
|
+
common_destinations = [
|
|
79
|
+
"三亚", "海口", "云南", "大理", "丽江", "香格里拉", "西双版纳",
|
|
80
|
+
"四川", "成都", "九寨沟", "川西", "稻城亚丁",
|
|
81
|
+
"北京", "上海", "广州", "深圳",
|
|
82
|
+
"浙江", "杭州", "乌镇", "普陀山",
|
|
83
|
+
"江苏", "苏州", "南京", "无锡",
|
|
84
|
+
"陕西", "西安",
|
|
85
|
+
"广西", "桂林", "阳朔",
|
|
86
|
+
"贵州", "黄果树", "西江千户苗寨",
|
|
87
|
+
"湖南", "张家界", "凤凰古城",
|
|
88
|
+
"福建", "厦门", "武夷山",
|
|
89
|
+
"海南", "三亚", "海口",
|
|
90
|
+
"西藏", "拉萨", "林芝",
|
|
91
|
+
"新疆", "喀纳斯", "伊犁",
|
|
92
|
+
"甘肃", "敦煌", "张掖",
|
|
93
|
+
"青海", "青海湖",
|
|
94
|
+
"黑龙江", "哈尔滨", "雪乡",
|
|
95
|
+
"吉林", "长白山",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
# 从帖子中提取
|
|
99
|
+
for post in posts:
|
|
100
|
+
text = f"{post.title} {post.content}"
|
|
101
|
+
for dest in common_destinations:
|
|
102
|
+
if dest in text:
|
|
103
|
+
if dest not in destinations:
|
|
104
|
+
destinations[dest] = {"posts": [], "wenlv": []}
|
|
105
|
+
destinations[dest]["posts"].append(post)
|
|
106
|
+
|
|
107
|
+
# 从文旅信息中提取
|
|
108
|
+
for info in wenlv_infos:
|
|
109
|
+
region = info.region
|
|
110
|
+
if region and region != "全国":
|
|
111
|
+
if region not in destinations:
|
|
112
|
+
destinations[region] = {"posts": [], "wenlv": []}
|
|
113
|
+
destinations[region]["wenlv"].append(info)
|
|
114
|
+
|
|
115
|
+
return destinations
|
|
116
|
+
|
|
117
|
+
async def _generate_with_clause(
|
|
118
|
+
self, scored_destinations: List[tuple]
|
|
119
|
+
) -> List[DestinationRecommendation]:
|
|
120
|
+
"""使用 Claude 生成推荐"""
|
|
121
|
+
# 构建上下文数据
|
|
122
|
+
context_data = []
|
|
123
|
+
for dest, score_info, info in scored_destinations:
|
|
124
|
+
context_data.append({
|
|
125
|
+
"name": dest,
|
|
126
|
+
"scores": score_info,
|
|
127
|
+
"post_count": len(info["posts"]),
|
|
128
|
+
"wenlv_count": len(info["wenlv"]),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
# 调用 Claude API
|
|
132
|
+
prompt = DESTINATION_ANALYSIS_PROMPT.format(
|
|
133
|
+
context_data=json.dumps(context_data, ensure_ascii=False, indent=2)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
response = self.client.messages.create(
|
|
138
|
+
model="claude-sonnet-4-5",
|
|
139
|
+
max_tokens=4096,
|
|
140
|
+
messages=[
|
|
141
|
+
{"role": "user", "content": prompt}
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# 解析响应
|
|
146
|
+
content = response.content[0].text
|
|
147
|
+
result = json.loads(content)
|
|
148
|
+
|
|
149
|
+
recommendations = []
|
|
150
|
+
for item in result.get("destinations", []):
|
|
151
|
+
rec = DestinationRecommendation(
|
|
152
|
+
name=item.get("name", ""),
|
|
153
|
+
rank=item.get("rank", 0),
|
|
154
|
+
score=item.get("score", 0),
|
|
155
|
+
reason=item.get("reason", ""),
|
|
156
|
+
estimated_cost=item.get("estimated_cost", ""),
|
|
157
|
+
suggested_days=item.get("suggested_days", 3),
|
|
158
|
+
best_time=item.get("best_time", ""),
|
|
159
|
+
highlights=item.get("highlights", []),
|
|
160
|
+
)
|
|
161
|
+
recommendations.append(rec)
|
|
162
|
+
|
|
163
|
+
return recommendations
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
print(f"Claude API 调用失败:{e},降级为本地排序")
|
|
167
|
+
return self._generate_local_recommendations(scored_destinations)
|
|
168
|
+
|
|
169
|
+
def _generate_local_recommendations(
|
|
170
|
+
self, scored_destinations: List[tuple]
|
|
171
|
+
) -> List[DestinationRecommendation]:
|
|
172
|
+
"""本地生成推荐(不使用 AI)"""
|
|
173
|
+
recommendations = []
|
|
174
|
+
|
|
175
|
+
for i, (dest, score_info, info) in enumerate(scored_destinations):
|
|
176
|
+
# 生成推荐理由
|
|
177
|
+
reasons = []
|
|
178
|
+
if score_info["social_score"] > 7:
|
|
179
|
+
reasons.append("社交媒体热度高")
|
|
180
|
+
if score_info["policy_score"] > 7:
|
|
181
|
+
reasons.append("有政策支持")
|
|
182
|
+
if score_info["price_score"] > 7:
|
|
183
|
+
reasons.append("性价比高")
|
|
184
|
+
if score_info["seasonal_score"] > 8:
|
|
185
|
+
reasons.append("当季最佳目的地")
|
|
186
|
+
|
|
187
|
+
reason = ",".join(reasons) if reasons else "综合推荐"
|
|
188
|
+
|
|
189
|
+
# 预估价格
|
|
190
|
+
if score_info["price_score"] > 7:
|
|
191
|
+
estimated_cost = "¥2000-4000"
|
|
192
|
+
elif score_info["price_score"] > 5:
|
|
193
|
+
estimated_cost = "¥4000-6000"
|
|
194
|
+
else:
|
|
195
|
+
estimated_cost = "¥6000+"
|
|
196
|
+
|
|
197
|
+
rec = DestinationRecommendation(
|
|
198
|
+
name=dest,
|
|
199
|
+
rank=i + 1,
|
|
200
|
+
score=score_info["total_score"],
|
|
201
|
+
reason=reason,
|
|
202
|
+
estimated_cost=estimated_cost,
|
|
203
|
+
suggested_days=3 + int(score_info["seasonal_score"] / 2),
|
|
204
|
+
best_time=self._get_best_time(dest),
|
|
205
|
+
highlights=[f"热度评分:{score_info['social_score']}"],
|
|
206
|
+
)
|
|
207
|
+
recommendations.append(rec)
|
|
208
|
+
|
|
209
|
+
return recommendations
|
|
210
|
+
|
|
211
|
+
def _get_best_time(self, destination: str) -> str:
|
|
212
|
+
"""获取目的地最佳旅行时间"""
|
|
213
|
+
# 简化逻辑
|
|
214
|
+
if any(kw in destination for kw in ["三亚", "海南", "西双版纳"]):
|
|
215
|
+
return "10 月 - 次年 4 月"
|
|
216
|
+
elif any(kw in destination for kw in ["哈尔滨", "雪乡", "长白山"]):
|
|
217
|
+
return "12 月 -2 月"
|
|
218
|
+
elif any(kw in destination for kw in ["婺源", "林芝"]):
|
|
219
|
+
return "3 月 -4 月"
|
|
220
|
+
elif any(kw in destination for kw in ["青海湖", "呼伦贝尔"]):
|
|
221
|
+
return "6 月 -8 月"
|
|
222
|
+
elif any(kw in destination for kw in ["九寨沟", "喀纳斯"]):
|
|
223
|
+
return "9 月 -10 月"
|
|
224
|
+
else:
|
|
225
|
+
return "春秋最佳"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""路线规划器 - 使用 Claude 生成详细行程"""
|
|
2
|
+
import json
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from anthropic import Anthropic
|
|
6
|
+
from config.models import DestinationRecommendation, TravelRoute
|
|
7
|
+
from config.settings import get_settings
|
|
8
|
+
from config.prompts import ROUTE_PLANNING_PROMPT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RoutePlanner:
|
|
12
|
+
"""旅行路线规划器"""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.settings = get_settings()
|
|
16
|
+
if self.settings.anthropic_api_key:
|
|
17
|
+
self.client = Anthropic(api_key=self.settings.anthropic_api_key)
|
|
18
|
+
else:
|
|
19
|
+
self.client = None
|
|
20
|
+
|
|
21
|
+
async def plan_route(
|
|
22
|
+
self,
|
|
23
|
+
destination: DestinationRecommendation,
|
|
24
|
+
) -> Optional[TravelRoute]:
|
|
25
|
+
"""为目的地生成详细路线"""
|
|
26
|
+
if not self.client:
|
|
27
|
+
return self._generate_local_route(destination)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
prompt = ROUTE_PLANNING_PROMPT.format(
|
|
31
|
+
destination_name=destination.name,
|
|
32
|
+
days=destination.suggested_days,
|
|
33
|
+
highlights=json.dumps(destination.highlights, ensure_ascii=False),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
response = self.client.messages.create(
|
|
37
|
+
model="claude-sonnet-4-5",
|
|
38
|
+
max_tokens=4096,
|
|
39
|
+
messages=[{"role": "user", "content": prompt}]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
content = response.content[0].text
|
|
43
|
+
route_data = json.loads(content)
|
|
44
|
+
|
|
45
|
+
return TravelRoute(
|
|
46
|
+
destination=route_data.get("destination", destination.name),
|
|
47
|
+
duration_days=route_data.get("duration_days", destination.suggested_days),
|
|
48
|
+
daily_plan=route_data.get("daily_plan", []),
|
|
49
|
+
budget_estimate=route_data.get("budget_estimate", {}),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(f"路线规划失败:{e}")
|
|
54
|
+
return self._generate_local_route(destination)
|
|
55
|
+
|
|
56
|
+
def _generate_local_route(self, dest: DestinationRecommendation) -> TravelRoute:
|
|
57
|
+
"""本地生成简单路线"""
|
|
58
|
+
days = dest.suggested_days
|
|
59
|
+
daily_plan = []
|
|
60
|
+
|
|
61
|
+
for i in range(days):
|
|
62
|
+
day_plan = {
|
|
63
|
+
"day": i + 1,
|
|
64
|
+
"theme": f"第{i + 1}天探索",
|
|
65
|
+
"activities": [
|
|
66
|
+
{"time": "上午", "item": "景点游览", "duration": "3 小时"},
|
|
67
|
+
{"time": "下午", "item": "深度体验", "duration": "4 小时"},
|
|
68
|
+
{"time": "晚上", "item": "美食探索", "duration": "2 小时"},
|
|
69
|
+
],
|
|
70
|
+
"food_recommendation": "当地特色美食",
|
|
71
|
+
"accommodation_area": "市中心/景区附近",
|
|
72
|
+
}
|
|
73
|
+
daily_plan.append(day_plan)
|
|
74
|
+
|
|
75
|
+
return TravelRoute(
|
|
76
|
+
destination=dest.name,
|
|
77
|
+
duration_days=days,
|
|
78
|
+
daily_plan=daily_plan,
|
|
79
|
+
budget_estimate={
|
|
80
|
+
"accommodation": "¥300-500/晚",
|
|
81
|
+
"food": "¥100-200/天",
|
|
82
|
+
"transport": "¥50-100/天",
|
|
83
|
+
"activities": "¥100-300/天",
|
|
84
|
+
"total": f"¥{500 * days}-{1100 * days}",
|
|
85
|
+
},
|
|
86
|
+
)
|