travel-agent-cli 0.2.0 → 0.2.2
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
- package/scripts/postinstall.js +59 -65
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""数据模型定义"""
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SourceType(str, Enum):
|
|
9
|
+
"""数据来源类型"""
|
|
10
|
+
XIAOHONGSHU = "xiaohongshu"
|
|
11
|
+
WEIBO = "weibo"
|
|
12
|
+
WENLV = "wenlv"
|
|
13
|
+
OTA = "ota"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SocialPost(BaseModel):
|
|
17
|
+
"""社交媒体帖子"""
|
|
18
|
+
id: str
|
|
19
|
+
source: SourceType
|
|
20
|
+
title: str
|
|
21
|
+
content: str
|
|
22
|
+
author: str
|
|
23
|
+
author_id: str
|
|
24
|
+
images: List[str] = Field(default_factory=list)
|
|
25
|
+
likes: int = 0
|
|
26
|
+
comments: int = 0
|
|
27
|
+
shares: int = 0
|
|
28
|
+
tags: List[str] = Field(default_factory=list)
|
|
29
|
+
url: str
|
|
30
|
+
published_at: Optional[datetime] = None
|
|
31
|
+
collected_at: datetime = Field(default_factory=datetime.now)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def engagement_score(self) -> float:
|
|
35
|
+
"""计算互动得分"""
|
|
36
|
+
return self.likes * 1 + self.comments * 2 + self.shares * 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WenlvInfo(BaseModel):
|
|
40
|
+
"""文旅局信息"""
|
|
41
|
+
id: str
|
|
42
|
+
source: SourceType
|
|
43
|
+
title: str
|
|
44
|
+
content: str
|
|
45
|
+
region: str
|
|
46
|
+
url: str
|
|
47
|
+
info_type: str # 政策/活动/推荐路线
|
|
48
|
+
published_at: Optional[datetime] = None
|
|
49
|
+
collected_at: datetime = Field(default_factory=datetime.now)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FlightInfo(BaseModel):
|
|
53
|
+
"""航班信息"""
|
|
54
|
+
id: str
|
|
55
|
+
departure_city: str
|
|
56
|
+
arrival_city: str
|
|
57
|
+
price: float
|
|
58
|
+
currency: str = "CNY"
|
|
59
|
+
airline: Optional[str] = None
|
|
60
|
+
departure_time: Optional[str] = None
|
|
61
|
+
travel_time: Optional[str] = None
|
|
62
|
+
platform: str = "fliggy" # fliggy/ctrip
|
|
63
|
+
collected_at: datetime = Field(default_factory=datetime.now)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class HotelInfo(BaseModel):
|
|
67
|
+
"""酒店信息"""
|
|
68
|
+
id: str
|
|
69
|
+
name: str
|
|
70
|
+
destination: str
|
|
71
|
+
price_per_night: float
|
|
72
|
+
currency: str = "CNY"
|
|
73
|
+
rating: Optional[float] = None
|
|
74
|
+
stars: Optional[int] = None
|
|
75
|
+
platform: str = "fliggy" # fliggy/ctrip
|
|
76
|
+
url: Optional[str] = None
|
|
77
|
+
collected_at: datetime = Field(default_factory=datetime.now)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Destination(BaseModel):
|
|
81
|
+
"""目的地信息"""
|
|
82
|
+
name: str
|
|
83
|
+
region: str # 省份/国家
|
|
84
|
+
city: Optional[str] = None
|
|
85
|
+
tags: List[str] = Field(default_factory=list)
|
|
86
|
+
description: Optional[str] = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DestinationRecommendation(BaseModel):
|
|
90
|
+
"""目的地推荐结果"""
|
|
91
|
+
name: str
|
|
92
|
+
rank: int
|
|
93
|
+
score: float
|
|
94
|
+
reason: str
|
|
95
|
+
estimated_cost: str
|
|
96
|
+
suggested_days: int
|
|
97
|
+
best_time: str
|
|
98
|
+
highlights: List[str]
|
|
99
|
+
flight_info: Optional[FlightInfo] = None
|
|
100
|
+
hotel_info: Optional[HotelInfo] = None
|
|
101
|
+
related_posts: List[SocialPost] = Field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TravelRoute(BaseModel):
|
|
105
|
+
"""旅行路线"""
|
|
106
|
+
destination: str
|
|
107
|
+
duration_days: int
|
|
108
|
+
daily_plan: List[dict]
|
|
109
|
+
budget_estimate: dict
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Report(BaseModel):
|
|
113
|
+
"""生成的报告"""
|
|
114
|
+
id: str
|
|
115
|
+
generated_at: datetime
|
|
116
|
+
destinations: List[DestinationRecommendation]
|
|
117
|
+
routes: List[TravelRoute]
|
|
118
|
+
summary: str
|
|
119
|
+
markdown_content: str
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""LLM Prompt 模板"""
|
|
2
|
+
|
|
3
|
+
# 目的地分析 Prompt
|
|
4
|
+
DESTINATION_ANALYSIS_PROMPT = """
|
|
5
|
+
你是一个专业的旅行目的地分析师。请根据以下采集到的数据,分析并推荐最佳旅行目的地。
|
|
6
|
+
|
|
7
|
+
## 输入数据
|
|
8
|
+
{context_data}
|
|
9
|
+
|
|
10
|
+
## 任务要求
|
|
11
|
+
1. 从数据中识别出有潜力的旅行目的地
|
|
12
|
+
2. 综合考虑以下维度进行评分(1-10 分):
|
|
13
|
+
- 热度:社交媒体讨论度、点赞评论数
|
|
14
|
+
- 季节性:当前季节是否适合前往
|
|
15
|
+
- 性价比:机酒价格水平
|
|
16
|
+
- 独特性:是否有特色活动/景观
|
|
17
|
+
- 政策利好:是否有免签、补贴等优惠
|
|
18
|
+
|
|
19
|
+
3. 推荐 10-15 个目的地,按综合得分排序
|
|
20
|
+
4. 为每个目的地提供:
|
|
21
|
+
- 目的地名称
|
|
22
|
+
- 推荐理由(100-200 字)
|
|
23
|
+
- 预估机酒价格范围
|
|
24
|
+
- 建议游玩天数
|
|
25
|
+
- 最佳旅行时间
|
|
26
|
+
|
|
27
|
+
## 输出格式
|
|
28
|
+
请严格按照以下 JSON 格式输出:
|
|
29
|
+
{{
|
|
30
|
+
"destinations": [
|
|
31
|
+
{{
|
|
32
|
+
"name": "目的地名称",
|
|
33
|
+
"rank": 排名,
|
|
34
|
+
"score": 综合得分,
|
|
35
|
+
"reason": "推荐理由",
|
|
36
|
+
"estimated_cost": "机酒价格范围",
|
|
37
|
+
"suggested_days": 建议天数,
|
|
38
|
+
"best_time": "最佳旅行时间",
|
|
39
|
+
"highlights": ["亮点 1", "亮点 2"]
|
|
40
|
+
}}
|
|
41
|
+
],
|
|
42
|
+
"summary": "整体分析总结"
|
|
43
|
+
}}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# 路线规划 Prompt
|
|
47
|
+
ROUTE_PLANNING_PROMPT = """
|
|
48
|
+
你是一个专业的旅行规划师。请为以下目的地设计详细的旅行路线。
|
|
49
|
+
|
|
50
|
+
## 目的地信息
|
|
51
|
+
- 名称:{destination_name}
|
|
52
|
+
- 建议游玩天数:{days}
|
|
53
|
+
- 亮点:{highlights}
|
|
54
|
+
|
|
55
|
+
## 任务要求
|
|
56
|
+
设计一个详细的 {days} 天行程,包括:
|
|
57
|
+
1. 每日行程安排(景点、活动、用餐建议)
|
|
58
|
+
2. 交通建议(市内交通、景点间接驳)
|
|
59
|
+
3. 住宿区域推荐
|
|
60
|
+
4. 预算估算
|
|
61
|
+
|
|
62
|
+
## 输出格式
|
|
63
|
+
{{
|
|
64
|
+
"destination": "目的地名称",
|
|
65
|
+
"duration_days": {days},
|
|
66
|
+
"daily_plan": [
|
|
67
|
+
{{
|
|
68
|
+
"day": 1,
|
|
69
|
+
"theme": "今日主题",
|
|
70
|
+
"activities": [
|
|
71
|
+
{{"time": "上午", "item": "活动/景点", "duration": "时长"}},
|
|
72
|
+
{{"time": "下午", "item": "活动/景点", "duration": "时长"}},
|
|
73
|
+
{{"time": "晚上", "item": "活动/景点", "duration": "时长"}}
|
|
74
|
+
],
|
|
75
|
+
"food_recommendation": "用餐建议",
|
|
76
|
+
"accommodation_area": "住宿区域建议"
|
|
77
|
+
}}
|
|
78
|
+
],
|
|
79
|
+
"budget_estimate": {{
|
|
80
|
+
"accommodation": "住宿预算",
|
|
81
|
+
"food": "餐饮预算",
|
|
82
|
+
"transport": "交通预算",
|
|
83
|
+
"activities": "活动预算",
|
|
84
|
+
"total": "总计"
|
|
85
|
+
}}
|
|
86
|
+
}}
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
# 报告总结 Prompt
|
|
90
|
+
REPORT_SUMMARY_PROMPT = """
|
|
91
|
+
你是一个旅行专栏作家。请根据以下目的地推荐数据,撰写一篇吸引人的旅行推荐报告。
|
|
92
|
+
|
|
93
|
+
## 数据
|
|
94
|
+
{destinations_data}
|
|
95
|
+
|
|
96
|
+
## 要求
|
|
97
|
+
1. 标题要有吸引力,体现"热点"和"推荐"
|
|
98
|
+
2. 开篇有一段引人入胜的导语
|
|
99
|
+
3. 对 TOP10 目的地分别进行生动描述
|
|
100
|
+
4. 加入实用建议(预订时机、注意事项等)
|
|
101
|
+
5. 语言风格轻松有趣,适合社交媒体传播
|
|
102
|
+
|
|
103
|
+
## 输出
|
|
104
|
+
直接输出 Markdown 格式的报告正文。
|
|
105
|
+
"""
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""配置管理模块 - 支持多 LLM 提供商"""
|
|
2
|
+
from pydantic_settings import BaseSettings
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Optional, Literal
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# 支持的 LLM 提供商
|
|
11
|
+
LLM_PROVIDERS = {
|
|
12
|
+
"anthropic": {
|
|
13
|
+
"models": ["claude-sonnet-4-5-20250929", "claude-opus-4-0-20250514", "claude-haiku-4-5-20250417"],
|
|
14
|
+
"env_prefix": "ANTHROPIC",
|
|
15
|
+
"key_name": "ANTHROPIC_API_KEY",
|
|
16
|
+
},
|
|
17
|
+
"openai": {
|
|
18
|
+
"models": ["gpt-4o", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo"],
|
|
19
|
+
"env_prefix": "OPENAI",
|
|
20
|
+
"key_name": "OPENAI_API_KEY",
|
|
21
|
+
},
|
|
22
|
+
"deepseek": {
|
|
23
|
+
"models": ["deepseek-chat", "deepseek-coder"],
|
|
24
|
+
"env_prefix": "DEEPSEEK",
|
|
25
|
+
"key_name": "DEEPSEEK_API_KEY",
|
|
26
|
+
},
|
|
27
|
+
"azure": {
|
|
28
|
+
"models": ["gpt-4", "gpt-35-turbo"],
|
|
29
|
+
"env_prefix": "AZURE",
|
|
30
|
+
"key_name": "AZURE_OPENAI_API_KEY",
|
|
31
|
+
"extra": ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_VERSION"],
|
|
32
|
+
},
|
|
33
|
+
"ollama": {
|
|
34
|
+
"models": ["llama3", "mistral", "qwen2"],
|
|
35
|
+
"env_prefix": "OLLAMA",
|
|
36
|
+
"key_name": "", # 本地部署,不需要 API Key
|
|
37
|
+
"extra": ["OLLAMA_BASE_URL"],
|
|
38
|
+
},
|
|
39
|
+
"qwen": {
|
|
40
|
+
"models": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
|
|
41
|
+
"env_prefix": "DASHSCOPE",
|
|
42
|
+
"key_name": "DASHSCOPE_API_KEY",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Settings(BaseSettings):
|
|
48
|
+
"""应用配置 - 支持多 LLM 提供商"""
|
|
49
|
+
|
|
50
|
+
# ========== LLM 配置 ==========
|
|
51
|
+
# 当前使用的 LLM 提供商:anthropic, openai, deepseek, azure, ollama
|
|
52
|
+
llm_provider: str = Field(default="anthropic", description="LLM 提供商")
|
|
53
|
+
|
|
54
|
+
# 使用的模型名称(留空则使用默认模型)
|
|
55
|
+
llm_model: str = Field(default="", description="模型名称,留空则使用默认")
|
|
56
|
+
|
|
57
|
+
# 各厂商 API Key(可选,至少配置一个)
|
|
58
|
+
anthropic_api_key: str = Field(default="", description="Anthropic API Key")
|
|
59
|
+
openai_api_key: str = Field(default="", description="OpenAI API Key")
|
|
60
|
+
deepseek_api_key: str = Field(default="", description="DeepSeek API Key")
|
|
61
|
+
azure_openai_api_key: str = Field(default="", description="Azure OpenAI API Key")
|
|
62
|
+
azure_openai_endpoint: str = Field(default="", description="Azure OpenAI Endpoint")
|
|
63
|
+
azure_openai_api_version: str = Field(default="2024-02-15-preview", description="Azure API 版本")
|
|
64
|
+
ollama_base_url: str = Field(default="http://localhost:11434", description="Ollama 服务地址")
|
|
65
|
+
dashscope_api_key: str = Field(default="", description="阿里云 DashScope API Key")
|
|
66
|
+
|
|
67
|
+
# ========== 其他配置 ==========
|
|
68
|
+
# 微博 API
|
|
69
|
+
weibo_app_key: str = Field(default="", description="微博 App Key")
|
|
70
|
+
weibo_app_secret: str = Field(default="", description="微博 App Secret")
|
|
71
|
+
|
|
72
|
+
# 代理配置
|
|
73
|
+
http_proxy: str = Field(default="", description="HTTP 代理")
|
|
74
|
+
https_proxy: str = Field(default="", description="HTTPS 代理")
|
|
75
|
+
|
|
76
|
+
# 路径配置
|
|
77
|
+
database_path: str = Field(default="./data/travel_agent.db", description="数据库路径")
|
|
78
|
+
output_dir: str = Field(default="./output", description="报告输出目录")
|
|
79
|
+
|
|
80
|
+
# 爬取配置
|
|
81
|
+
request_timeout: int = Field(default=30, description="HTTP 请求超时 (秒)")
|
|
82
|
+
max_retries: int = Field(default=3, description="最大重试次数")
|
|
83
|
+
request_delay: float = Field(default=1.0, description="请求间隔 (秒)")
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def data_dir(self) -> Path:
|
|
87
|
+
return Path(self.database_path).parent
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def output_path(self) -> Path:
|
|
91
|
+
return Path(self.output_dir)
|
|
92
|
+
|
|
93
|
+
def ensure_dirs(self):
|
|
94
|
+
"""确保所需目录存在"""
|
|
95
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
self.output_path.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
|
|
98
|
+
def get_active_api_key(self) -> str:
|
|
99
|
+
"""获取当前激活的 API Key"""
|
|
100
|
+
key_map = {
|
|
101
|
+
"anthropic": self.anthropic_api_key,
|
|
102
|
+
"openai": self.openai_api_key,
|
|
103
|
+
"deepseek": self.deepseek_api_key,
|
|
104
|
+
"azure": self.azure_openai_api_key,
|
|
105
|
+
"ollama": "local", # Ollama 不需要 Key
|
|
106
|
+
"qwen": self.dashscope_api_key,
|
|
107
|
+
}
|
|
108
|
+
return key_map.get(self.llm_provider, "")
|
|
109
|
+
|
|
110
|
+
def get_active_model(self) -> str:
|
|
111
|
+
"""获取当前使用的模型"""
|
|
112
|
+
if self.llm_model:
|
|
113
|
+
return self.llm_model
|
|
114
|
+
|
|
115
|
+
# 返回各提供商的默认模型
|
|
116
|
+
defaults = {
|
|
117
|
+
"anthropic": "claude-sonnet-4-5-20250929",
|
|
118
|
+
"openai": "gpt-4o",
|
|
119
|
+
"deepseek": "deepseek-chat",
|
|
120
|
+
"azure": "gpt-4",
|
|
121
|
+
"ollama": "llama3",
|
|
122
|
+
"qwen": "qwen-max",
|
|
123
|
+
}
|
|
124
|
+
return defaults.get(self.llm_provider, "claude-sonnet-4-5-20250929")
|
|
125
|
+
|
|
126
|
+
def is_provider_configured(self, provider: str) -> bool:
|
|
127
|
+
"""检查指定提供商是否已配置"""
|
|
128
|
+
if provider == "ollama":
|
|
129
|
+
return True # 本地部署,始终可用
|
|
130
|
+
|
|
131
|
+
key_map = {
|
|
132
|
+
"anthropic": bool(self.anthropic_api_key),
|
|
133
|
+
"openai": bool(self.openai_api_key),
|
|
134
|
+
"deepseek": bool(self.deepseek_api_key),
|
|
135
|
+
"azure": bool(self.azure_openai_api_key) and bool(self.azure_openai_endpoint),
|
|
136
|
+
"qwen": bool(self.dashscope_api_key),
|
|
137
|
+
}
|
|
138
|
+
return key_map.get(provider, False)
|
|
139
|
+
|
|
140
|
+
def get_configured_providers(self) -> list:
|
|
141
|
+
"""获取已配置的提供商列表"""
|
|
142
|
+
return [p for p in LLM_PROVIDERS.keys() if self.is_provider_configured(p)]
|
|
143
|
+
|
|
144
|
+
def get_llm_config(self) -> dict:
|
|
145
|
+
"""获取当前 LLM 配置"""
|
|
146
|
+
return {
|
|
147
|
+
"provider": self.llm_provider,
|
|
148
|
+
"model": self.get_active_model(),
|
|
149
|
+
"api_key_configured": bool(self.get_active_api_key()),
|
|
150
|
+
"available_providers": self.get_configured_providers(),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class Config:
|
|
154
|
+
env_file = ".env"
|
|
155
|
+
env_file_encoding = "utf-8"
|
|
156
|
+
extra = "ignore"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@lru_cache()
|
|
160
|
+
def get_settings() -> Settings:
|
|
161
|
+
"""获取配置单例"""
|
|
162
|
+
return Settings()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def list_providers() -> str:
|
|
166
|
+
"""列出所有支持的提供商和模型"""
|
|
167
|
+
lines = ["支持的 LLM 提供商和模型:", ""]
|
|
168
|
+
for provider, info in LLM_PROVIDERS.items():
|
|
169
|
+
lines.append(f" {provider}:")
|
|
170
|
+
for model in info["models"]:
|
|
171
|
+
lines.append(f" - {model}")
|
|
172
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""报告生成模块"""
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from jinja2 import Template
|
|
9
|
+
from config.models import DestinationRecommendation, TravelRoute, Report
|
|
10
|
+
from config.settings import get_settings
|
|
11
|
+
from config.prompts import REPORT_SUMMARY_PROMPT
|
|
12
|
+
from analyzers.ranker import DestinationRanker
|
|
13
|
+
from analyzers.route_planner import RoutePlanner
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Markdown 报告模板
|
|
17
|
+
REPORT_TEMPLATE = """
|
|
18
|
+
# 旅行目的地推荐报告
|
|
19
|
+
|
|
20
|
+
> 生成时间:{{ generated_at }}
|
|
21
|
+
|
|
22
|
+
{{ summary }}
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 🏆 TOP 目的地推荐
|
|
27
|
+
|
|
28
|
+
{% for dest in destinations %}
|
|
29
|
+
### {{ dest.rank }}. {{ dest.name }}
|
|
30
|
+
|
|
31
|
+
**综合得分:** {{ dest.score }}/10
|
|
32
|
+
|
|
33
|
+
**推荐理由:** {{ dest.reason }}
|
|
34
|
+
|
|
35
|
+
- 💰 **预估费用:** {{ dest.estimated_cost }}
|
|
36
|
+
- 📅 **建议天数:** {{ dest.suggested_days }}天
|
|
37
|
+
- 🕐 **最佳时间:** {{ dest.best_time }}
|
|
38
|
+
- ✨ **亮点:** {{ dest.highlights | join(', ') }}
|
|
39
|
+
|
|
40
|
+
{% endfor %}
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 🗺️ 推荐路线
|
|
45
|
+
|
|
46
|
+
{% for route in routes %}
|
|
47
|
+
### {{ route.destination }} - {{ route.duration_days }}天行程
|
|
48
|
+
|
|
49
|
+
{% for day in route.daily_plan %}
|
|
50
|
+
#### Day {{ day.day }}:{{ day.theme }}
|
|
51
|
+
|
|
52
|
+
| 时间 | 活动 | 时长 |
|
|
53
|
+
|------|------|------|
|
|
54
|
+
{% for activity in day.activities %}| {{ activity.time }} | {{ activity.item }} | {{ activity.duration }} |
|
|
55
|
+
{% endfor %}
|
|
56
|
+
|
|
57
|
+
**用餐建议:** {{ day.food_recommendation }}
|
|
58
|
+
|
|
59
|
+
**住宿区域:** {{ day.accommodation_area }}
|
|
60
|
+
|
|
61
|
+
{% endfor %}
|
|
62
|
+
|
|
63
|
+
**预算估算:**
|
|
64
|
+
- 住宿:{{ route.budget_estimate.get('accommodation', 'N/A') }}
|
|
65
|
+
- 餐饮:{{ route.budget_estimate.get('food', 'N/A') }}
|
|
66
|
+
- 交通:{{ route.budget_estimate.get('transport', 'N/A') }}
|
|
67
|
+
- 活动:{{ route.budget_estimate.get('activities', 'N/A') }}
|
|
68
|
+
- **总计:** {{ route.budget_estimate.get('total', 'N/A') }}
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
{% endfor %}
|
|
73
|
+
|
|
74
|
+
## 📌 出行提示
|
|
75
|
+
|
|
76
|
+
1. **预订时机:** 建议提前 2-4 周预订机票酒店
|
|
77
|
+
2. **证件准备:** 检查身份证/护照有效期
|
|
78
|
+
3. **天气查询:** 出行前关注目的地天气预报
|
|
79
|
+
4. **保险建议:** 建议购买旅行意外险
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
*本报告由 AI 驱动生成,数据来源于社交媒体热点和 OTA 平台*
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ReportGenerator:
|
|
88
|
+
"""报告生成器"""
|
|
89
|
+
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self.settings = get_settings()
|
|
92
|
+
self.settings.ensure_dirs()
|
|
93
|
+
self.ranker = DestinationRanker()
|
|
94
|
+
self.route_planner = RoutePlanner()
|
|
95
|
+
self.template = Template(REPORT_TEMPLATE)
|
|
96
|
+
|
|
97
|
+
async def generate(
|
|
98
|
+
self,
|
|
99
|
+
format: str = "markdown",
|
|
100
|
+
) -> Report:
|
|
101
|
+
"""生成完整报告"""
|
|
102
|
+
# 1. 获取目的地推荐
|
|
103
|
+
destinations = await self.ranker.generate_recommendations(limit=12)
|
|
104
|
+
|
|
105
|
+
# 2. 为 TOP 目的地生成路线
|
|
106
|
+
routes = []
|
|
107
|
+
for dest in destinations[:5]: # 为 TOP5 生成路线
|
|
108
|
+
route = await self.route_planner.plan_route(dest)
|
|
109
|
+
if route:
|
|
110
|
+
routes.append(route)
|
|
111
|
+
|
|
112
|
+
# 3. 生成报告摘要
|
|
113
|
+
summary = await self._generate_summary(destinations)
|
|
114
|
+
|
|
115
|
+
# 4. 渲染 Markdown
|
|
116
|
+
markdown_content = self.template.render(
|
|
117
|
+
generated_at=datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
118
|
+
summary=summary,
|
|
119
|
+
destinations=destinations,
|
|
120
|
+
routes=routes,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
report = Report(
|
|
124
|
+
id=f"report_{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
|
125
|
+
generated_at=datetime.now(),
|
|
126
|
+
destinations=destinations,
|
|
127
|
+
routes=routes,
|
|
128
|
+
summary=summary,
|
|
129
|
+
markdown_content=markdown_content,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return report
|
|
133
|
+
|
|
134
|
+
async def _generate_summary(self, destinations: List[DestinationRecommendation]) -> str:
|
|
135
|
+
"""生成报告摘要"""
|
|
136
|
+
if self.ranker.client:
|
|
137
|
+
try:
|
|
138
|
+
dest_data = [
|
|
139
|
+
{
|
|
140
|
+
"name": d.name,
|
|
141
|
+
"score": d.score,
|
|
142
|
+
"reason": d.reason,
|
|
143
|
+
}
|
|
144
|
+
for d in destinations[:10]
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
prompt = REPORT_SUMMARY_PROMPT.format(
|
|
148
|
+
destinations_data=json.dumps(dest_data, ensure_ascii=False)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
response = self.ranker.client.messages.create(
|
|
152
|
+
model="claude-sonnet-4-5",
|
|
153
|
+
max_tokens=1024,
|
|
154
|
+
messages=[{"role": "user", "content": prompt}]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return response.content[0].text
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
print(f"摘要生成失败:{e}")
|
|
161
|
+
|
|
162
|
+
# 本地生成简化摘要
|
|
163
|
+
top3 = destinations[:3]
|
|
164
|
+
summary = f"本期推荐 {len(destinations)} 个热门旅行目的地。"
|
|
165
|
+
summary += f"排名前三的目的地分别是:"
|
|
166
|
+
for i, dest in enumerate(top3):
|
|
167
|
+
summary += f"{i + 1}. {dest.name}(得分:{dest.score})"
|
|
168
|
+
if i < len(top3) - 1:
|
|
169
|
+
summary += ","
|
|
170
|
+
summary += "。这些目的地在社交媒体上热度较高,且当前季节适宜出行。"
|
|
171
|
+
return summary
|
|
172
|
+
|
|
173
|
+
def save(
|
|
174
|
+
self,
|
|
175
|
+
report: Report,
|
|
176
|
+
output_path: Optional[str] = None,
|
|
177
|
+
) -> Path:
|
|
178
|
+
"""保存报告到文件"""
|
|
179
|
+
if output_path:
|
|
180
|
+
path = Path(output_path)
|
|
181
|
+
else:
|
|
182
|
+
# 默认路径
|
|
183
|
+
filename = f"report_{report.generated_at.strftime('%Y%m%d_%H%M%S')}.md"
|
|
184
|
+
path = self.settings.output_path / filename
|
|
185
|
+
|
|
186
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
|
|
188
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
189
|
+
f.write(report.markdown_content)
|
|
190
|
+
|
|
191
|
+
print(f"报告已保存:{path}")
|
|
192
|
+
return path
|