diff --git a/Co-creation-projects/275145-TripPlanner/.gitignore b/Co-creation-projects/275145-TripPlanner/.gitignore new file mode 100644 index 00000000..2eea525d --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/LICENSE b/Co-creation-projects/275145-TripPlanner/LICENSE new file mode 100644 index 00000000..d781d438 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 echo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Co-creation-projects/275145-TripPlanner/README.md b/Co-creation-projects/275145-TripPlanner/README.md new file mode 100644 index 00000000..327f08fb --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/README.md @@ -0,0 +1,399 @@ +# 智能旅行规划系统 + +> 基于 AI 的智能旅行规划助手,自动为您生成个性化旅行方案 + +## 📝 项目简介 + +智能旅行规划系统是一个结合大语言模型(LLM)、向量数据库和地图服务的全栈旅游规划应用。它通过多智能体协作,为用户提供从景点搜索、酒店推荐、天气查询到完整行程生成的端到端服务。 + +### 解决什么问题? + +- **繁琐的行程规划**:传统规划需要查阅大量攻略、地图和多个服务,耗时长且效率低 +- **个性化不足**:通用攻略无法满足个人偏好和特殊需求 +- **信息分散**:景点、酒店、交通、天气等信息需要从不同渠道获取,整合困难 +- **缺乏记忆**:无法记住用户的旅行历史和偏好,每次都需要重新输入 + +### 有什么特色功能? + +- 采用多智能体协作架构,专业化分工提升规划质量 +- 基于向量数据库的记忆系统,越用越智能 +- 地理位置验证确保行程可行性 +- 并行查询优化,响应时间从 8-13 秒优化到 3-5 秒 +- 支持地图可视化、预算计算、行程编辑、导出等完整功能 + +### 适用于什么场景? + +- **个人旅行规划**:快速生成个性化行程,节省大量时间 +- **家庭出游**:根据家庭成员偏好推荐合适行程,照顾每个人的需求 +- **商务旅行**:高效安排出差行程,平衡工作效率与休息 +- **深度游**:基于历史记忆的渐进式探索,发现更多特色景点 + +## ✨ 核心功能 + +- [ ] **智能行程规划**:输入目的地、日期、偏好,AI 自动生成完整行程,目前支持30个热门旅游城市的相关精确规划 +- [ ] **地图可视化**:高德地图集成,标注景点位置和游览路线,直观展示行程安排 +- [ ] **预算计算**:自动统计门票、酒店、餐饮、交通费用,帮助控制旅行成本 +- [ ] **用户认证**:支持注册登录和行程记录,保存个人旅行历史 +- [ ] **记忆学习**:向量数据库记录用户偏好,越用越智能,推荐更符合个人口味 +- [ ] **实时天气**:查询行程期间天气预报,提前做好行程调整准备 +- [ ] **行程编辑**:支持添加、删除、调整景点和活动,灵活定制行程 +- [ ] **导出功能**:支持导出为 PDF 或图片格式,方便分享和保存 +- [ ] **多智能体协作**:景点搜索专家、酒店推荐专家、天气查询专家、行程规划专家协同工作 +- [ ] **地理位置验证**:确保景点位置准确性,同一天景点距离控制在 50 公里内 +- [ ] **性能优化**:并行查询提升响应速度,提供更好的用户体验 +- [ ] **限流熔断**:API 请求限流和熔断保护,确保系统稳定性 + +## 🛠️ 技术栈 + +### 后端技术栈 + +- **Web框架**:FastAPI - 高性能异步 Web 框架 +- **LLM服务**:OpenAI API / 智谱 AI / 通义千问 - 大语言模型支持 +- **Agent框架**:HelloAgents - 多智能体协作框架 +- **向量数据库**:FAISS + Sentence-Transformers - 向量存储和检索 +- **缓存数据库**:Redis - 高性能缓存和会话管理 +- **地图服务**:高德地图 API(MCP 协议)- 地理位置服务和路线规划 +- **图片服务**:Unsplash API - 高质量图片素材 +- **认证**:JWT + Bcrypt - 安全的用户认证机制 + +### 前端技术栈 + +- **框架**:Vue 3 + TypeScript - 渐进式 JavaScript 框架 +- **构建工具**:Vite - 下一代前端构建工具 +- **组件库**:Element Plus - Vue 3 组件库 +- **路由**:Vue Router - 官方路由管理器 +- **状态管理**:Pinia - Vue 3 官方状态管理库 +- **地图**:高德地图 JS API - 地图可视化 +- **导出**:html2canvas + jsPDF - PDF 和图片导出 + +### 使用的智能体范式 + +- **多智能体协作**:采用 ReAct 范式,多个专业化 Agent 分工协作 +- **专业分工**:景点搜索 Agent、酒店推荐 Agent、天气查询 Agent、行程规划 Agent +- **并行执行**:各 Agent 独立工作,提升整体效率 + +### 使用的工具和API + +- **高德地图 API**:地理位置搜索、距离计算、路线规划 +- **Unsplash API**:高质量图片素材获取 +- **LLM API**:自然语言理解和行程生成 + +### 其他依赖库 + +- FastAPI 相关:pydantic、uvicorn、python-multipart +- 数据处理:numpy、requests +- 向量处理:faiss-cpu、sentence-transformers +- 认证安全:pyjwt、bcrypt +- 日志监控:huggingface-hub + +## 🚀 快速开始 + +### 环境要求 + +**后端环境**: +- Python 3.11+ +- pip 包管理器 +- Redis 缓存服务 + +**前端环境**: +- Node.js 16+ +- npm 包管理器 + +**外部服务**: +- 高德地图 API Key +- Unsplash API Key +- LLM API Key(OpenAI、DeepSeek、智谱 AI、通义千问等) + +### 安装依赖 + +#### 前期准备工作 + +1. **准备 API 密钥** + + 你需要准备以下 API 密钥: + + - **LLM API Key**:OpenAI、DeepSeek、智谱 AI 或通义千问等任一平台的 API 密钥 + - **高德地图 Web 服务 Key**:访问 https://console.amap.com/ 注册并创建应用 + - **Unsplash Access Key**:访问 https://unsplash.com/developers 注册并创建应用 + +2. **克隆项目** + +```bash +git clone +cd trip_planner +``` + +#### 后端安装步骤 + +1. **安装并启动 Redis** + + - **Windows**:下载并安装 Redis for Windows,在 redis 安装目录下使用命令: + ```bash + redis-server.exe + ``` + + - **macOS**: + ```bash + brew install redis && brew services start redis + ``` + + - **Linux**: + ```bash + sudo apt-get install redis-server && sudo systemctl start redis + ``` + +2. **安装后端依赖** + +```bash +cd backend +pip install -r requirements.txt +``` + +3. **配置后端环境变量** + +```bash +# 复制环境变量模板 +cp .env.example .env + +# 编辑 .env 文件,填入必要配置: +# - LLM_API_KEY(必需):LLM API 密钥 +# - LLM_BASE_URL(可选):LLM 服务地址 +# - LLM_MODEL_ID(可选):模型名称 +# - AMAP_API_KEY(必需):高德地图 API 密钥 +# - UNSPLASH_ACCESS_KEY(必需):Unsplash API 密钥 +# - REDIS_HOST、REDIS_PORT(默认 localhost:6379):Redis 连接信息 +``` + +4. **启动后端服务** + +```bash +python run.py +``` + +后端服务将在 http://localhost:8000 启动 + +#### 前端安装步骤 + +1. **安装前端依赖** + +```bash +cd ../frontend +npm install +``` + +2. **配置前端环境变量** + +```bash +# 复制环境变量模板 +cp .env.example .env + +# 编辑 .env 文件,配置: +# - VITE_API_BASE_URL:后端服务地址(默认 http://localhost:8000) +# - VITE_AMAP_KEY:高德地图 JavaScript API Key(与后端的 Web 服务 Key 不同) +# - VITE_AMAP_SECURITY_CODE:高德地图安全密钥(如果需要) +``` + +3. **启动前端服务** + +```bash +npm run dev +``` + +前端服务将在 http://localhost:5173 启动 + +### 访问应用 + +打开浏览器访问 http://localhost:5173,注册或登录账号,然后输入目的地、日期、偏好等信息,点击"生成行程"即可使用。 + +## 📖 使用示例 + +### 示例 1:规划北京三日游 + +```javascript +// 前端表单提交示例 +const tripRequest = { + destination: "北京", + start_date: "2024-03-01", + end_date: "2024-03-03", + preferences: ["历史文化", "美食", "博物馆"], + hotel_preferences: ["市中心", "交通便利"], + budget: "中等" +}; + +// 调用 API 生成行程 +const response = await fetch('/api/v1/trips/plan', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(tripRequest) +}); + +const tripPlan = await response.json(); +``` + +**返回结果示例**: +```json +{ + "trip_title": "北京历史文化三日深度游", + "total_budget": 2500, + "hotels": [ + { + "name": "北京王府井希尔顿酒店", + "address": "东城区王府井东街8号", + "price_per_night": 800, + "rating": 4.8 + } + ], + "days": [ + { + "day": 1, + "date": "2024-03-01", + "weather": "晴天,温度 8-18°C", + "activities": [ + { + "name": "故宫博物院", + "type": "景点", + "description": "中国古代皇家宫殿,世界文化遗产", + "duration": "4小时", + "cost": 60, + "image": "https://images.unsplash.com/photo-...", + "location": {"lat": 39.9163, "lng": 116.3972} + } + ] + } + ] +} +``` + +### 示例 2:地图可视化展示 + +系统会在结果页面自动加载高德地图,展示: +- 📍 各景点的位置标记 +- 🛤️ 每日的游览路线 +- 💡 点击标记显示景点详细信息 +- 🎯 自动调整视野以显示所有景点 + +### 示例 3:预算计算 + +系统自动统计并分类显示: +- **景点门票**:故宫 60元 + 天坛 35元 + 颐和园 30元 = 125元 +- **酒店住宿**:800元/晚 × 2晚 = 1600元 +- **餐饮美食**:预计 500元 +- **交通及其他**:预计 275元 +- **总预算**:2500元 + +### 示例 4:导出行程 + +用户可以点击"导出 PDF"或"导出图片"按钮,将生成的行程保存为 PDF 文档或 PNG 图片,方便分享给同行的朋友或保存到本地。 + +## 🎯 项目亮点 + +- **多智能体协作**:景点搜索专家、酒店推荐专家、天气查询专家、行程规划专家协同工作,各司其职,提升规划质量 + +- **向量记忆系统**:基于 FAISS 的向量数据库,记录用户偏好和历史行程,系统会根据用户的旅行习惯不断优化推荐,越用越智能 + +- **地理位置验证**:确保所有景点都在目标城市范围内,同一天景点距离控制在 50 公里内,避免行程过于紧张或不可行 + +- **并行性能优化**:景点、酒店、天气查询并行执行,响应时间从 8-13 秒优化到 3-5 秒,大幅提升用户体验 + +- **企业级架构**:包含中间件、异常处理、日志系统、限流熔断等完整的企业级特性,确保系统稳定性和可维护性 + +- **智能体范式应用**:采用 ReAct 范式,结合推理和行动,让智能体能够自主思考和执行任务 + +## 🔮 未来计划 + +- [ ] **智能体增强**:增加餐厅推荐、交通规划等专业化 Agent,提供更全面的旅行服务 + +- [ ] **社交功能**:支持行程分享、评论、收藏,让用户可以与朋友一起规划旅行 + +- [ ] **多语言支持**:国际化支持多语言界面,方便海外用户使用 + +- [ ] **移动端优化**:开发小程序或 APP,提供更便捷的移动端体验 + +- [ ] **实时协作**:支持多人共同编辑行程,适合团队旅行规划 + +- [ ] **预算智能**:基于历史数据预测实际花费,提供更准确的预算估算 + +- [ ] **智能推荐**:基于用户画像和历史行为,推荐目的地和景点 + +- [ ] **行程优化**:提供多种行程方案对比,让用户选择最满意的方案 + +## 🤝 贡献指南 + +欢迎提出 Issue 和 Pull Request! + +### 如何贡献 + +1. Fork 本仓库 +2. 创建您的特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交您的更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启一个 Pull Request + +### 代码规范 + +- **Python 代码**:遵循 PEP 8 规范,使用 black 进行格式化 +- **JavaScript/TypeScript 代码**:遵循 ESLint 规范,使用 Prettier 进行格式化 +- **提交信息**:使用清晰的提交信息,说明修改内容和原因 + +### Issue 提交 + +提交 Issue 时,请提供: +- 清晰的问题描述 +- 重现步骤 +- 期望行为 +- 实际行为 +- 环境信息(操作系统、Python/Node 版本等) +- 相关的日志或错误信息 + +## 📄 许可证 + +本项目采用 MIT 许可证。详情请参阅 [LICENSE](LICENSE) 文件。 + +MIT License + +Copyright (c) 2024 Trip Planner Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## 👤 作者 + +- GitHub: [@你的用户名](https://github.com/你的用户名) +- Email: 你的邮箱(可选) + +## 🙏 致谢 + +感谢以下开源项目和社区的支持: + +- **HelloAgents 框架**:提供了强大的多智能体协作能力 +- **Datawhale 社区**:提供了学习和交流的平台 +- **Hello-Agents 项目**:为智能体应用开发提供了灵感和参考 +- **FastAPI**:高性能的 Web 框架 +- **Vue.js**:优雅的前端框架 +- **Element Plus**:精美的 Vue 3 组件库 +- **高德地图**:提供专业的地图服务 +- **Unsplash**:提供高质量的图片素材 + +同时感谢所有为本项目做出贡献的开发者和用户! + +--- + +**注意**:本项目仅用于学习和研究目的,请遵守各 API 服务商的使用条款和隐私政策。 diff --git a/Co-creation-projects/275145-TripPlanner/backend/.env.example b/Co-creation-projects/275145-TripPlanner/backend/.env.example new file mode 100644 index 00000000..8c02985c --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/.env.example @@ -0,0 +1,66 @@ +# LLM配置 (从HelloAgents继承,如需覆盖可在此配置) +# 模型名称 +LLM_MODEL_ID=gpt-4-turbo + +# API密钥 (通用) +LLM_API_KEY=your-api-key-here + +# 服务地址 (通用) +LLM_BASE_URL= + +# 也支持特定服务商的变量,优先级更高 +# OPENAI_API_KEY= +# ZHIPU_API_KEY= +# MODELSCOPE_API_KEY= + +# 超时时间(可选,默认60秒) +LLM_TIMEOUT=60 + +# 服务器配置 +HOST=0.0.0.0 +PORT=8000 + +# CORS配置 +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# 日志级别 +LOG_LEVEL=INFO + +# Unsplash API Credentials +UNSPLASH_ACCESS_KEY="" +UNSPLASH_SECRET_KEY="" + +# 高德地图API配置 +AMAP_API_KEY=your_amap_api_key_here + +# JWT 认证配置 +JWT_SECRET=your-secret-key-change-in-production +JWT_EXPIRY_HOURS=24 + +# Redis 配置 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 +# REDIS_PASSWORD=your_redis_password_if_needed + +# 密码加密配置 +BCRYPT_ROUNDS=12 + +# 向量数据库配置 +VECTOR_MEMORY_DIR=vector_memory +EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 +VECTOR_DIM=384 + +# HuggingFace 配置 +# HF_ENDPOINT: HuggingFace镜像地址,默认使用 hf-mirror.com +# 常用镜像: https://hf-mirror.com (国内推荐) +# 官方源: https://huggingface.co +HF_ENDPOINT=https://hf-mirror.com + +# HF_HUB_OFFLINE: 离线模式,设置为 true 时仅使用本地缓存 +HF_HUB_OFFLINE=false + +# HF_HUB_CACHE_DIR: HuggingFace模型缓存目录,默认使用系统默认缓存 +# Windows: C:\Users\\.cache\huggingface\hub +# Linux/Mac: ~/.cache/huggingface/hub +# HF_HUB_CACHE_DIR= \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/.gitignore b/Co-creation-projects/275145-TripPlanner/backend/.gitignore new file mode 100644 index 00000000..e99888de --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/.gitignore @@ -0,0 +1,59 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ +.venv + +# Environment variables +.env +.env.local + +# Logs +logs/ +*.log + +# Uploads (用户上传的文件) +uploads/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# pytest +.pytest_cache/ +.coverage +htmlcov/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/__init__.py b/Co-creation-projects/275145-TripPlanner/backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/agents/__init__.py b/Co-creation-projects/275145-TripPlanner/backend/app/agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/agents/agent_communication.py b/Co-creation-projects/275145-TripPlanner/backend/app/agents/agent_communication.py new file mode 100644 index 00000000..ebe05218 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/agents/agent_communication.py @@ -0,0 +1,337 @@ +""" +智能体通信模块 +实现智能体之间的消息传递和协商机制 +""" +from typing import Dict, List, Optional, Any, Callable +from enum import Enum +from datetime import datetime +from app.observability.logger import default_logger as logger + + +class MessageType(Enum): + """消息类型""" + QUERY = "query" # 查询 + SUGGESTION = "suggestion" # 建议 + NEGOTIATION = "negotiation" # 协商 + FEEDBACK = "feedback" # 反馈 + RESULT = "result" # 结果 + REQUEST = "request" # 请求 + + +class AgentMessage: + """智能体消息""" + + def __init__( + self, + sender: str, + receiver: str, + message_type: MessageType, + content: Dict[str, Any], + context: Optional[Dict[str, Any]] = None + ): + """ + 初始化消息 + + Args: + sender: 发送者名称 + receiver: 接收者名称 + message_type: 消息类型 + content: 消息内容 + context: 上下文信息 + """ + self.sender = sender + self.receiver = receiver + self.message_type = message_type + self.content = content + self.context = context or {} + self.timestamp = datetime.now().isoformat() + self.message_id = f"{sender}_{receiver}_{int(datetime.now().timestamp())}" + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + "message_id": self.message_id, + "sender": self.sender, + "receiver": self.receiver, + "message_type": self.message_type.value, + "content": self.content, + "context": self.context, + "timestamp": self.timestamp + } + + +class AgentCommunicationHub: + """ + 智能体通信中心 + 管理智能体之间的消息传递 + """ + + def __init__(self): + """初始化通信中心""" + self.agents: Dict[str, Any] = {} # 注册的智能体 + self.message_history: List[AgentMessage] = [] + self.message_handlers: Dict[str, Dict[MessageType, Callable]] = {} + logger.info("智能体通信中心初始化完成") + + def register_agent(self, agent_name: str, agent_instance: Any): + """ + 注册智能体 + + Args: + agent_name: 智能体名称 + agent_instance: 智能体实例 + """ + self.agents[agent_name] = agent_instance + self.message_handlers[agent_name] = {} + logger.info(f"智能体已注册 - Name: {agent_name}") + + def register_message_handler( + self, + agent_name: str, + message_type: MessageType, + handler: Callable + ): + """ + 注册消息处理器 + + Args: + agent_name: 智能体名称 + message_type: 消息类型 + handler: 处理函数 + """ + if agent_name not in self.message_handlers: + self.message_handlers[agent_name] = {} + + self.message_handlers[agent_name][message_type] = handler + logger.debug(f"消息处理器已注册 - Agent: {agent_name}, Type: {message_type.value}") + + def send_message(self, message: AgentMessage) -> Optional[Dict[str, Any]]: + """ + 发送消息 + + Args: + message: 消息对象 + + Returns: + 响应内容(如果有) + """ + # 记录消息历史 + self.message_history.append(message) + + # 检查接收者是否存在 + if message.receiver not in self.agents: + logger.warning(f"接收者不存在 - Receiver: {message.receiver}") + return None + + # 查找消息处理器 + handlers = self.message_handlers.get(message.receiver, {}) + handler = handlers.get(message.message_type) + + if handler: + try: + response = handler(message) + logger.debug( + f"消息已处理 - From: {message.sender}, To: {message.receiver}, " + f"Type: {message.message_type.value}" + ) + return response + except Exception as e: + logger.error(f"消息处理失败: {e}", exc_info=True) + return None + else: + # 如果没有注册处理器,尝试直接调用智能体的处理方法 + agent = self.agents[message.receiver] + if hasattr(agent, 'handle_message'): + try: + response = agent.handle_message(message) + return response + except Exception as e: + logger.error(f"智能体消息处理失败: {e}", exc_info=True) + return None + + logger.warning(f"未找到消息处理器 - Receiver: {message.receiver}, Type: {message.message_type.value}") + return None + + def broadcast_message( + self, + sender: str, + message_type: MessageType, + content: Dict[str, Any], + exclude: Optional[List[str]] = None + ) -> List[Dict[str, Any]]: + """ + 广播消息给所有智能体 + + Args: + sender: 发送者名称 + message_type: 消息类型 + content: 消息内容 + exclude: 排除的智能体列表 + + Returns: + 响应列表 + """ + exclude = exclude or [] + responses = [] + + for agent_name in self.agents.keys(): + if agent_name == sender or agent_name in exclude: + continue + + message = AgentMessage( + sender=sender, + receiver=agent_name, + message_type=message_type, + content=content + ) + + response = self.send_message(message) + if response: + responses.append({ + "agent": agent_name, + "response": response + }) + + return responses + + def negotiate( + self, + initiator: str, + participants: List[str], + topic: str, + proposals: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 协商机制 + + Args: + initiator: 发起者 + participants: 参与者列表 + topic: 协商主题 + proposals: 提案 + + Returns: + 协商结果 + """ + logger.info(f"开始协商 - Initiator: {initiator}, Topic: {topic}, Participants: {participants}") + + negotiation_round = 1 + max_rounds = 3 + consensus = False + final_proposal = proposals + + while negotiation_round <= max_rounds and not consensus: + logger.debug(f"协商第 {negotiation_round} 轮") + + responses = {} + for participant in participants: + if participant not in self.agents: + continue + + message = AgentMessage( + sender=initiator, + receiver=participant, + message_type=MessageType.NEGOTIATION, + content={ + "topic": topic, + "proposal": final_proposal, + "round": negotiation_round + } + ) + + response = self.send_message(message) + if response: + responses[participant] = response + + # 检查是否达成共识 + if len(responses) == len(participants): + # 简单的共识检查:所有参与者都同意 + all_agree = all( + resp.get("status") == "agree" or resp.get("agreement", False) + for resp in responses.values() + ) + + if all_agree: + consensus = True + logger.info(f"协商达成共识 - Round: {negotiation_round}") + else: + # 整合反馈,调整提案 + feedback = [resp.get("feedback", {}) for resp in responses.values()] + final_proposal = self._integrate_feedback(final_proposal, feedback) + negotiation_round += 1 + else: + break + + return { + "consensus": consensus, + "final_proposal": final_proposal, + "rounds": negotiation_round, + "responses": responses + } + + def _integrate_feedback( + self, + proposal: Dict[str, Any], + feedback_list: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + 整合反馈,调整提案 + + Args: + proposal: 原始提案 + feedback_list: 反馈列表 + + Returns: + 调整后的提案 + """ + # 简单的反馈整合逻辑 + # 实际应用中可以使用更复杂的策略 + adjusted_proposal = proposal.copy() + + for feedback in feedback_list: + if "suggestions" in feedback: + for key, value in feedback["suggestions"].items(): + if key in adjusted_proposal: + # 简单的平均值调整 + if isinstance(adjusted_proposal[key], (int, float)) and isinstance(value, (int, float)): + adjusted_proposal[key] = (adjusted_proposal[key] + value) / 2 + else: + adjusted_proposal[key] = value + + return adjusted_proposal + + def get_message_history( + self, + agent_name: Optional[str] = None, + message_type: Optional[MessageType] = None + ) -> List[AgentMessage]: + """ + 获取消息历史 + + Args: + agent_name: 智能体名称(可选) + message_type: 消息类型(可选) + + Returns: + 消息列表 + """ + filtered_messages = self.message_history + + if agent_name: + filtered_messages = [ + msg for msg in filtered_messages + if msg.sender == agent_name or msg.receiver == agent_name + ] + + if message_type: + filtered_messages = [ + msg for msg in filtered_messages + if msg.message_type == message_type + ] + + return filtered_messages + + +# 创建全局通信中心实例 +communication_hub = AgentCommunicationHub() + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/agents/enhanced_agent.py b/Co-creation-projects/275145-TripPlanner/backend/app/agents/enhanced_agent.py new file mode 100644 index 00000000..72f36419 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/agents/enhanced_agent.py @@ -0,0 +1,493 @@ +""" +增强的智能体基类 +基于SimpleAgent,增加记忆、上下文、通信能力 +""" +import re +from typing import Optional, Iterator, Dict, Any, List +from hello_agents import SimpleAgent, HelloAgentsLLM, Config, Message +# from app.services.memory_service import memory_service # 替换为向量记忆服务 +from app.services.vector_memory_service import VectorMemoryService +from app.services.context_manager import ContextManager +from app.agents.agent_communication import ( + AgentCommunicationHub, + AgentMessage, + MessageType +) +from app.observability.logger import default_logger as logger + + +class EnhancedAgent(SimpleAgent): + """ + 增强的智能体基类 + 在SimpleAgent基础上增加: + - 记忆能力(检索和存储记忆) + - 上下文感知(使用上下文管理器) + - 通信能力(与其他智能体通信) + """ + + def __init__( + self, + name: str, + llm: HelloAgentsLLM, + system_prompt: Optional[str] = None, + config: Optional[Config] = None, + tool_registry: Optional['ToolRegistry'] = None, + enable_tool_calling: bool = True, + context_manager: Optional[ContextManager] = None, + communication_hub: Optional[AgentCommunicationHub] = None, + user_id: Optional[str] = None, + memory_service: Optional[VectorMemoryService] = None + ): + """ + 初始化增强智能体 + + Args: + name: 智能体名称 + llm: LLM服务 + system_prompt: 系统提示词 + config: 配置 + tool_registry: 工具注册表 + enable_tool_calling: 是否启用工具调用 + context_manager: 上下文管理器 + communication_hub: 通信中心 + user_id: 用户ID(用于记忆检索) + memory_service: 向量记忆服务实例 + """ + super().__init__(name, llm, system_prompt, config) + self.tool_registry = tool_registry + self.enable_tool_calling = enable_tool_calling and tool_registry is not None + self.context_manager = context_manager + self.communication_hub = communication_hub + self.user_id = user_id + self.memory_service = memory_service or VectorMemoryService() + + # 注册到通信中心 + if self.communication_hub: + self.communication_hub.register_agent(self.name, self) + # 注册默认消息处理器 + self.communication_hub.register_message_handler( + self.name, + MessageType.QUERY, + self._handle_query_message + ) + self.communication_hub.register_message_handler( + self.name, + MessageType.REQUEST, + self._handle_request_message + ) + + logger.info(f"✅ {name} 增强智能体初始化完成,工具调用: {'启用' if self.enable_tool_calling else '禁用'}") + + def _get_enhanced_system_prompt(self) -> str: + """构建增强的系统提示词,包含工具信息和记忆上下文""" + base_prompt = self.system_prompt or "你是一个有用的AI助手。" + + # 添加工具信息 + if self.enable_tool_calling and self.tool_registry: + tools_description = self.tool_registry.get_tools_description() + if tools_description and tools_description != "暂无可用工具": + tools_section = "\n\n## 可用工具\n" + tools_section += "你可以使用以下工具来帮助回答问题:\n" + tools_section += tools_description + "\n" + tools_section += "\n## 工具调用格式\n" + tools_section += "当需要使用工具时,请使用以下格式:\n" + tools_section += "`[TOOL_CALL:{tool_name}:{parameters}]`\n" + tools_section += "例如:`[TOOL_CALL:search:Python编程]` 或 `[TOOL_CALL:memory:recall=用户信息]`\n\n" + tools_section += "工具调用结果会自动插入到对话中,然后你可以基于结果继续回答。\n" + base_prompt += tools_section + + # 添加记忆上下文(性能优化:只在context_manager中没有记忆时才检索) + if self.user_id: + # 优先从context_manager获取已检索的记忆 + memory_context = "" + if self.context_manager: + user_memories = self.context_manager.get_shared_data("user_memories") + knowledge_memories = self.context_manager.get_shared_data("knowledge_memories") + + if user_memories or knowledge_memories: + # 使用已检索的记忆(避免重复检索) + parts = [] + if user_memories: + mem_texts = [mem.get("text_representation", "")[:100] for mem in user_memories] + parts.append(f"用户历史记忆: {'; '.join(mem_texts)}") + if knowledge_memories: + mem_texts = [mem.get("text_representation", "")[:100] for mem in knowledge_memories] + parts.append(f"相关知识: {'; '.join(mem_texts)}") + memory_context = "\n".join(parts) + logger.debug(f"{self.name} 使用context_manager中的记忆,跳过重复检索") + + # 如果context_manager中没有,才进行检索(降级方案) + if not memory_context: + memory_context = self._get_memory_context() + logger.debug(f"{self.name} context_manager中没有记忆,执行向量检索") + + if memory_context: + memory_section = "\n\n## 相关记忆信息\n" + memory_section += "以下是与当前任务相关的历史信息,你可以参考这些信息来更好地完成任务:\n" + memory_section += memory_context + "\n" + base_prompt += memory_section + + # 添加上下文信息 + if self.context_manager: + shared_data = self.context_manager.get_all_shared_data() + if shared_data: + context_section = "\n\n## 共享上下文信息\n" + context_section += "以下是从其他智能体共享的信息:\n" + for key, value in shared_data.items(): + context_section += f"- {key}: {str(value)[:200]}\n" + base_prompt += context_section + + return base_prompt + + def _get_memory_context(self) -> str: + """获取记忆上下文(使用向量记忆服务)""" + if not self.user_id: + return "" + + context_parts = [] + + # 构建查询文本 + query_text = "" + if self.context_manager: + request_context = self.context_manager.get_shared_data("request") + if request_context: + destination = request_context.get("destination", "") + prefs = request_context.get("preferences", []) + query_text = f"{destination} {' '.join(prefs)}" + + # 检索用户记忆 + user_memories = self.memory_service.retrieve_user_memories( + user_id=self.user_id, + query=query_text, + limit=3, + memory_types=["preference", "trip"] + ) + if user_memories: + memory_texts = [mem.get("text_representation", "")[:100] for mem in user_memories] + context_parts.append(f"用户历史记忆: {'; '.join(memory_texts)}") + + # 检索相关知识记忆 + if query_text: + knowledge_memories = self.memory_service.retrieve_knowledge_memories( + query=query_text, + limit=2, + knowledge_types=["destination", "experience"] + ) + if knowledge_memories: + knowledge_texts = [mem.get("text_representation", "")[:100] for mem in knowledge_memories] + context_parts.append(f"相关知识: {'; '.join(knowledge_texts)}") + + return "\n".join(context_parts) + + def run( + self, + input_text: str, + max_tool_iterations: int = 3, + **kwargs + ) -> str: + """ + 重写的运行方法 - 增强版,支持记忆和上下文 + """ + logger.info(f"🤖 {self.name} 正在处理: {input_text[:100]}...") + + # 更新上下文 + if self.context_manager: + self.context_manager.update_context( + self.name, + {"input": input_text, "status": "processing"}, + "info" + ) + + # 构建消息列表 + messages = [] + + # 添加增强的系统消息 + enhanced_system_prompt = self._get_enhanced_system_prompt() + messages.append({"role": "system", "content": enhanced_system_prompt}) + + # 添加历史消息 + for msg in self._history: + messages.append({"role": msg.role, "content": msg.content}) + + # 添加当前用户消息 + messages.append({"role": "user", "content": input_text}) + + # 如果没有启用工具调用,使用简单对话逻辑 + if not self.enable_tool_calling: + response = self.llm.invoke(messages, **kwargs) + self.add_message(Message(input_text, "user")) + self.add_message(Message(response, "assistant")) + + # 更新上下文 + if self.context_manager: + self.context_manager.update_context( + self.name, + {"output": response, "status": "completed"}, + "result" + ) + + logger.info(f"✅ {self.name} 响应完成") + return response + + # 支持多轮工具调用的逻辑 + return self._run_with_tools(messages, input_text, max_tool_iterations, **kwargs) + + def _run_with_tools( + self, + messages: list, + input_text: str, + max_tool_iterations: int, + **kwargs + ) -> str: + """支持工具调用的运行逻辑(增强版)""" + current_iteration = 0 + final_response = "" + + while current_iteration < max_tool_iterations: + # 调用LLM + response = self.llm.invoke(messages, **kwargs) + + # 检查是否有工具调用 + tool_calls = self._parse_tool_calls(response) + + if tool_calls: + logger.debug(f"🔧 {self.name} 检测到 {len(tool_calls)} 个工具调用") + + # 执行所有工具调用并收集结果 + tool_results = [] + clean_response = response + + for call in tool_calls: + result = self._execute_tool_call(call['tool_name'], call['parameters']) + tool_results.append(result) + # 从响应中移除工具调用标记 + clean_response = clean_response.replace(call['original'], "") + + # 构建包含工具结果的消息 + messages.append({"role": "assistant", "content": clean_response}) + + # 添加工具结果 + tool_results_text = "\n\n".join(tool_results) + messages.append({ + "role": "user", + "content": f"工具执行结果:\n{tool_results_text}\n\n请基于这些结果给出完整的回答。" + }) + + current_iteration += 1 + continue + + # 没有工具调用,这是最终回答 + final_response = response + break + + # 如果超过最大迭代次数,获取最后一次回答 + if current_iteration >= max_tool_iterations and not final_response: + final_response = self.llm.invoke(messages, **kwargs) + + # 保存到历史记录 + self.add_message(Message(input_text, "user")) + self.add_message(Message(final_response, "assistant")) + + # 更新上下文 + if self.context_manager: + self.context_manager.update_context( + self.name, + { + "output": final_response, + "status": "completed", + "tool_iterations": current_iteration + }, + "result" + ) + + # 共享结果数据 + self.context_manager.share_data( + f"{self.name}_result", + final_response, + from_agent=self.name + ) + + logger.info(f"✅ {self.name} 响应完成") + return final_response + + def _parse_tool_calls(self, text: str) -> list: + """解析文本中的工具调用""" + pattern = r'\[TOOL_CALL:([^:]+):([^\]]+)\]' + matches = re.findall(pattern, text) + tool_calls = [] + for tool_name, parameters in matches: + tool_calls.append({ + 'tool_name': tool_name.strip(), + 'parameters': parameters.strip(), + 'original': f'[TOOL_CALL:{tool_name}:{parameters}]' + }) + return tool_calls + + def _execute_tool_call(self, tool_name: str, parameters: str) -> str: + """执行工具调用""" + if not self.tool_registry: + return "❌ 错误:未配置工具注册表" + + try: + # 智能参数解析 + if tool_name == 'calculator': + result = self.tool_registry.execute_tool(tool_name, parameters) + else: + param_dict = self._parse_tool_parameters(tool_name, parameters) + tool = self.tool_registry.get_tool(tool_name) + if not tool: + return f"❌ 错误:未找到工具 '{tool_name}'" + result = tool.run(param_dict) + + return f"🔧 工具 {tool_name} 执行结果:\n{result}" + except Exception as e: + logger.error(f"工具调用失败: {e}", exc_info=True) + return f"❌ 工具调用失败:{str(e)}" + + def _parse_tool_parameters(self, tool_name: str, parameters: str) -> dict: + """智能解析工具参数""" + param_dict = {} + if '=' in parameters: + if ',' in parameters: + pairs = parameters.split(',') + for pair in pairs: + if '=' in pair: + key, value = pair.split('=', 1) + param_dict[key.strip()] = value.strip() + else: + key, value = parameters.split('=', 1) + param_dict[key.strip()] = value.strip() + else: + if tool_name == 'search': + param_dict = {'query': parameters} + elif tool_name == 'memory': + param_dict = {'action': 'search', 'query': parameters} + else: + param_dict = {'input': parameters} + return param_dict + + def send_message_to_agent( + self, + receiver: str, + message_type: MessageType, + content: Dict[str, Any] + ) -> Optional[Dict[str, Any]]: + """ + 向其他智能体发送消息 + + Args: + receiver: 接收者名称 + message_type: 消息类型 + content: 消息内容 + + Returns: + 响应内容 + """ + if not self.communication_hub: + logger.warning(f"{self.name} 未配置通信中心,无法发送消息") + return None + + message = AgentMessage( + sender=self.name, + receiver=receiver, + message_type=message_type, + content=content, + context=self.context_manager.get_all_context() if self.context_manager else {} + ) + + return self.communication_hub.send_message(message) + + def handle_message(self, message: AgentMessage) -> Dict[str, Any]: + """ + 处理接收到的消息(子类可以重写) + + Args: + message: 消息对象 + + Returns: + 响应内容 + """ + logger.debug(f"{self.name} 收到消息 - From: {message.sender}, Type: {message.message_type.value}") + + # 默认处理:根据消息类型返回响应 + if message.message_type == MessageType.QUERY: + return { + "status": "received", + "agent": self.name, + "message": "查询已收到,正在处理" + } + elif message.message_type == MessageType.REQUEST: + return { + "status": "received", + "agent": self.name, + "message": "请求已收到,正在处理" + } + else: + return { + "status": "received", + "agent": self.name + } + + def _handle_query_message(self, message: AgentMessage) -> Dict[str, Any]: + """处理查询消息""" + return self.handle_message(message) + + def _handle_request_message(self, message: AgentMessage) -> Dict[str, Any]: + """处理请求消息""" + return self.handle_message(message) + + def store_memory( + self, + memory_type: str, + memory_data: Dict[str, Any] + ): + """ + 存储记忆(使用向量记忆服务) + + Args: + memory_type: 记忆类型 + memory_data: 记忆数据 + """ + if not self.user_id: + return + + if memory_type == "preference": + self.memory_service.store_user_preference( + self.user_id, + memory_data.get("preference_type", "general"), + memory_data + ) + elif memory_type == "feedback": + self.memory_service.store_user_feedback( + self.user_id, + memory_data.get("trip_id", ""), + memory_data + ) + elif memory_type == "trip": + self.memory_service.store_user_trip( + self.user_id, + memory_data + ) + + # 保存向量索引 + self.memory_service.save() + + def add_tool(self, tool) -> None: + """添加工具到Agent(便利方法)""" + if not self.tool_registry: + from hello_agents import ToolRegistry + self.tool_registry = ToolRegistry() + self.enable_tool_calling = True + self.tool_registry.register_tool(tool) + logger.debug(f"🔧 工具 '{tool.name}' 已添加到 {self.name}") + + def has_tools(self) -> bool: + """检查是否有可用工具""" + return self.enable_tool_calling and self.tool_registry is not None + + def list_tools(self) -> list: + """列出所有可用工具""" + if self.tool_registry: + return self.tool_registry.list_tools() + return [] + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/agents/planner.py b/Co-creation-projects/275145-TripPlanner/backend/app/agents/planner.py new file mode 100644 index 00000000..147ed809 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/agents/planner.py @@ -0,0 +1,555 @@ +import json +import math +import asyncio +from datetime import datetime +from app.models.trip_model import TripPlanRequest, TripPlanResponse +from app.models.common_model import Attraction, Hotel, Weather +from app.services.llm_service import LLMService +from app.observability.logger import default_logger as logger +from typing import List, Optional, Tuple +from app.tools.mcp_tool import MCPTool +from app.config import settings +from app.services.unsplash_service import UnsplashService +from concurrent.futures import ThreadPoolExecutor, as_completed +# from app.services.memory_service import memory_service # 替换为向量记忆服务 +from app.services.vector_memory_service import VectorMemoryService +from app.services.context_manager import ContextManager, get_context_manager +from app.agents.agent_communication import communication_hub +from app.agents.specialized_agents import ( + AttractionSearchAgent, + HotelRecommendationAgent, + WeatherQueryAgent, + PlannerAgent as EnhancedPlannerAgent +) +from hello_agents import ToolRegistry +from app.observability.logger import get_request_id + +# 主要城市的经纬度范围(用于验证)- 扩展至30个热门旅游城市 +CITY_BOUNDS = { + # 一线城市 + "北京": {"lat_min": 39.4, "lat_max": 41.1, "lng_min": 115.7, "lng_max": 117.4}, + "上海": {"lat_min": 30.7, "lat_max": 31.9, "lng_min": 120.8, "lng_max": 122.2}, + "广州": {"lat_min": 22.7, "lat_max": 23.8, "lng_min": 112.9, "lng_max": 114.0}, + "深圳": {"lat_min": 22.4, "lat_max": 22.9, "lng_min": 113.7, "lng_max": 114.6}, + + # 新一线城市 + "成都": {"lat_min": 30.4, "lat_max": 30.9, "lng_min": 103.9, "lng_max": 104.5}, + "杭州": {"lat_min": 30.0, "lat_max": 30.5, "lng_min": 119.5, "lng_max": 120.5}, + "重庆": {"lat_min": 29.3, "lat_max": 29.9, "lng_min": 106.2, "lng_max": 106.8}, + "武汉": {"lat_min": 30.3, "lat_max": 31.0, "lng_min": 113.9, "lng_max": 114.6}, + "西安": {"lat_min": 34.0, "lat_max": 34.5, "lng_min": 108.7, "lng_max": 109.2}, + "苏州": {"lat_min": 31.1, "lat_max": 31.5, "lng_min": 120.3, "lng_max": 121.0}, + "天津": {"lat_min": 38.9, "lat_max": 39.6, "lng_min": 116.9, "lng_max": 117.9}, + "南京": {"lat_min": 31.9, "lat_max": 32.2, "lng_min": 118.4, "lng_max": 119.2}, + "长沙": {"lat_min": 28.1, "lat_max": 28.4, "lng_min": 112.8, "lng_max": 113.2}, + "郑州": {"lat_min": 34.4, "lat_max": 34.9, "lng_min": 113.4, "lng_max": 113.9}, + + # 热门旅游城市 + "厦门": {"lat_min": 24.4, "lat_max": 24.6, "lng_min": 118.0, "lng_max": 118.2}, + "青岛": {"lat_min": 35.9, "lat_max": 36.4, "lng_min": 119.9, "lng_max": 120.7}, + "大连": {"lat_min": 38.7, "lat_max": 39.2, "lng_min": 121.3, "lng_max": 122.0}, + "三亚": {"lat_min": 18.1, "lat_max": 18.4, "lng_min": 109.3, "lng_max": 109.7}, + "丽江": {"lat_min": 26.8, "lat_max": 27.2, "lng_min": 100.1, "lng_max": 100.5}, + "桂林": {"lat_min": 25.1, "lat_max": 25.5, "lng_min": 110.1, "lng_max": 110.6}, + "昆明": {"lat_min": 24.7, "lat_max": 25.3, "lng_min": 102.5, "lng_max": 103.1}, + "哈尔滨": {"lat_min": 45.5, "lat_max": 46.0, "lng_min": 126.4, "lng_max": 127.1}, + "沈阳": {"lat_min": 41.5, "lat_max": 42.0, "lng_min": 123.2, "lng_max": 123.8}, + "济南": {"lat_min": 36.5, "lat_max": 36.8, "lng_min": 116.8, "lng_max": 117.3}, + + # 特色旅游城市 + "黄山": {"lat_min": 29.8, "lat_max": 30.2, "lng_min": 118.1, "lng_max": 118.5}, + "张家界": {"lat_min": 28.9, "lat_max": 29.3, "lng_min": 110.2, "lng_max": 110.7}, + "敦煌": {"lat_min": 39.8, "lat_max": 40.3, "lng_min": 94.4, "lng_max": 95.1}, + "拉萨": {"lat_min": 29.5, "lat_max": 30.0, "lng_min": 90.9, "lng_max": 91.5}, + "乌鲁木齐": {"lat_min": 43.7, "lat_max": 44.2, "lng_min": 87.4, "lng_max": 88.0}, + "宁波": {"lat_min": 29.8, "lat_max": 30.0, "lng_min": 121.3, "lng_max": 121.8}, +} +# 注意:Agent提示词已移至 specialized_agents.py +class PlannerAgent: + """ + 行程规划专家 (Orchestrator) - 增强版 + 负责协调多个增强智能体,整合信息,并生成最终的行程计划。 + 支持记忆、上下文、智能体间通信等功能。 + """ + def __init__(self, llm_service: LLMService, memory_service: VectorMemoryService = None): + self.llm = LLMService() + self.settings = settings + self.unsplash_service = UnsplashService(settings.UNSPLASH_ACCESS_KEY) + self.memory_service = memory_service or VectorMemoryService() + + # 创建工具注册表 + self.tool_registry = ToolRegistry() + + # 创建高德地图工具 + self.amap_tool = MCPTool( + name="amap", + description="高德地图服务", + server_command=["uvx", "amap-mcp-server"], + env={"AMAP_MAPS_API_KEY": settings.AMAP_API_KEY}, + auto_expand=True + ) + self.tool_registry.register_tool(self.amap_tool) + + logger.info("✅ 多智能体系统初始化完成(增强版)") + def _validate_location_in_city(self, lat: float, lng: float, city: str) -> bool: + """ + 验证位置是否在指定城市范围内 + + Args: + lat: 纬度 + lng: 经度 + city: 城市名称 + + Returns: + 是否在范围内 + """ + if city not in CITY_BOUNDS: + # 如果城市不在预定义列表中,给出警告并拒绝验证 + logger.warning( + f"⚠️ 城市 '{city}' 不在支持的城市范围内,该城市可能无法提供精确的行程规划。" + f"目前支持的城市包括:{', '.join(list(CITY_BOUNDS.keys())[:10])} 等30个热门旅游城市。" + ) + return False # 不再宽容处理,拒绝不在列表中的城市 + + bounds = CITY_BOUNDS[city] + is_valid = ( + bounds["lat_min"] <= lat <= bounds["lat_max"] and + bounds["lng_min"] <= lng <= bounds["lng_max"] + ) + + if not is_valid: + logger.warning( + f"⚠️ 位置 ({lat}, {lng}) 不在城市 '{city}' 的合理范围内,该景点可能不属于目标城市" + ) + + return is_valid + + def _calculate_distance(self, lat1: float, lng1: float, lat2: float, lng2: float) -> float: + """ + 计算两点之间的距离(公里) + + Args: + lat1, lng1: 第一个点的经纬度 + lat2, lng2: 第二个点的经纬度 + + Returns: + 距离(公里) + """ + # 使用Haversine公式计算两点间距离 + R = 6371 # 地球半径(公里) + dlat = math.radians(lat2 - lat1) + dlng = math.radians(lng2 - lng1) + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2 + ) + c = 2 * math.asin(math.sqrt(a)) + return R * c + + def _validate_and_filter_plan(self, plan: TripPlanResponse, destination: str) -> TripPlanResponse: + """ + 验证并过滤行程计划,移除不在目标城市范围内的景点 + + Args: + plan: 行程计划 + destination: 目标城市 + + Returns: + 验证后的行程计划 + """ + filtered_days = [] + removed_count = 0 + + for day in plan.days: + # 过滤景点 + valid_attractions = [] + for attraction in day.attractions: + if attraction.location: + lat = float(attraction.location.lat) + lng = float(attraction.location.lng) + + if self._validate_location_in_city(lat, lng, destination): + valid_attractions.append(attraction) + else: + removed_count += 1 + logger.warning( + f"移除不在目标城市范围内的景点: {attraction.name} " + f"(位置: {lat}, {lng}, 目标城市: {destination})" + ) + else: + # 没有位置信息的景点也移除 + removed_count += 1 + logger.warning(f"移除没有位置信息的景点: {attraction.name}") + + # 验证同一天景点距离 + if len(valid_attractions) > 1: + for i in range(len(valid_attractions) - 1): + att1 = valid_attractions[i] + att2 = valid_attractions[i + 1] + if att1.location and att2.location: + distance = self._calculate_distance( + float(att1.location.lat), float(att1.location.lng), + float(att2.location.lat), float(att2.location.lng) + ) + if distance > 50: + logger.warning( + f"第{day.day}天的景点 {att1.name} 和 {att2.name} 距离较远: {distance:.2f}公里" + ) + + # 过滤餐饮 + valid_dinings = [] + for dining in day.dinings: + if dining.location: + lat = float(dining.location.lat) + lng = float(dining.location.lng) + if self._validate_location_in_city(lat, lng, destination): + valid_dinings.append(dining) + else: + logger.warning(f"移除不在目标城市范围内的餐饮: {dining.name}") + + # 验证酒店 + if day.recommended_hotel and day.recommended_hotel.location: + lat = float(day.recommended_hotel.location.lat) + lng = float(day.recommended_hotel.location.lng) + if not self._validate_location_in_city(lat, lng, destination): + logger.warning(f"第{day.day}天的推荐酒店不在目标城市范围内: {day.recommended_hotel.name}") + day.recommended_hotel = None + + # 更新过滤后的数据 + day.attractions = valid_attractions + day.dinings = valid_dinings + filtered_days.append(day) + + # 验证相邻天景点距离 + for i in range(len(filtered_days) - 1): + day1 = filtered_days[i] + day2 = filtered_days[i + 1] + + if day1.attractions and day2.attractions: + last_att_day1 = day1.attractions[-1] + first_att_day2 = day2.attractions[0] + + if last_att_day1.location and first_att_day2.location: + distance = self._calculate_distance( + float(last_att_day1.location.lat), float(last_att_day1.location.lng), + float(first_att_day2.location.lat), float(first_att_day2.location.lng) + ) + if distance > 100: + logger.warning( + f"第{day1.day}天和第{day2.day}天的景点距离较远: {distance:.2f}公里" + ) + + plan.days = filtered_days + + if removed_count > 0: + logger.info(f"已移除 {removed_count} 个不在目标城市范围内的景点") + + return plan + + def _construct_prompt(self, request: TripPlanRequest, attractions: str, hotels: str, weather: str) -> str: + start_date = datetime.strptime(request.start_date, "%Y-%m-%d") + end_date = datetime.strptime(request.end_date, "%Y-%m-%d") + duration = (end_date - start_date).days + 1 + + # attraction_details = [f"- {a.name} (评分: {a.rating}, 类型: {a.type})" for a in attractions] + # hotel_details = [f"- {h.name} (价格: {h.price}, 评分: {h.rating})" for h in hotels] + # weather_details = [f"- {w.date}: {w.day_weather}, {w.day_temp}°C" for w in weather] + + prompt = f""" + 请为我创建一个前往 {request.destination} 的旅行计划。 + + **基本信息:** + - 旅行天数: {duration} 天 (从 {request.start_date} 到 {request.end_date}) + - 预算水平: {request.budget} + - 个人偏好: {', '.join(request.preferences) if request.preferences else '无'} + - 酒店偏好: {', '.join(request.hotel_preferences) if request.hotel_preferences else '无'} + + **可用资源:** + - **推荐景点列表:**\n{(attractions)} + - **推荐酒店列表:**\n{(hotels)} + - **天气预报:**\n{(weather)} + + **输出要求:** + 1. 严格按照系统提示中给定的 JSON 结构和字段名生成行程计划。 + 2. 你的输出必须是一个完整的 JSON 对象,包含: + - trip_title + - total_budget(含 transport_cost / dining_cost / hotel_cost / attraction_ticket_cost / total) + - hotels + - days(其中包含 recommended_hotel / attractions / dinings / budget 等字段) + 3. 不要输出任何额外的解释或 Markdown,只输出 JSON。 + """ + return prompt + + def _build_attraction_query(self, request: TripPlanRequest) -> str: + """构建景点搜索查询 - 直接包含工具调用""" + keywords = [] + if request.preferences: + # 只取第一个偏好作为关键词 + keywords = request.preferences[0] + else: + keywords = "景点" + + # 直接返回工具调用格式 + query = f"请使用amap_maps_text_search工具搜索{request.destination}的{keywords}相关景点。\n[TOOL_CALL:amap_maps_text_search:keywords={keywords},city={request.destination}]" + return query + def _build_hotel_query(self, request: TripPlanRequest) -> str: + """构建酒店搜索查询 - 直接包含工具调用""" + + query = f"请使用amap_maps_text_search工具搜索{request.destination}的酒店。请确保返回的酒店信息详细且准确。\n[TOOL_CALL:amap_maps_text_search:keywords=酒店,city={request.destination}]" + return query + + def plan_trip( + self, + request: TripPlanRequest, + user_id: Optional[str] = None + ) -> TripPlanResponse | None: + """ + 规划行程(增强版) + + Args: + request: 行程规划请求 + user_id: 用户ID(用于记忆检索) + + Returns: + 行程规划响应 + """ + # 获取请求ID + request_id = get_request_id() or f"req_{datetime.now().timestamp()}" + + # 创建或获取上下文管理器 + context_manager = get_context_manager(request_id) + + # 在上下文中存储请求信息 + context_manager.share_data("request", { + "destination": request.destination, + "start_date": request.start_date, + "end_date": request.end_date, + "preferences": request.preferences, + "hotel_preferences": request.hotel_preferences, + "budget": request.budget + }) + + # 如果没有提供user_id,使用request_id作为临时user_id + if not user_id: + user_id = request_id + + # 检索用户记忆并添加到上下文(使用向量记忆服务) + # 构建查询文本 + query_text = f"{request.destination} {' '.join(request.preferences or [])} {request.budget}" + + # 检索用户记忆 + user_memories = self.memory_service.retrieve_user_memories( + user_id=user_id, + query=query_text, + limit=5, + memory_types=["preference", "trip"] + ) + if user_memories: + context_manager.add_memory_context("user_memories", user_memories) + logger.info(f"已加载 {len(user_memories)} 条用户记忆 - UserID: {user_id}") + + # 检索相关知识记忆 + knowledge_memories = self.memory_service.retrieve_knowledge_memories( + query=f"{request.destination} 旅行 景点 特色", + limit=3, + knowledge_types=["destination", "experience"] + ) + if knowledge_memories: + context_manager.add_memory_context("knowledge_memories", knowledge_memories) + logger.info(f"已加载 {len(knowledge_memories)} 条知识记忆") + + # 创建增强的智能体 + logger.info("创建增强智能体...") + + attraction_agent = AttractionSearchAgent( + llm=self.llm, + tool_registry=self.tool_registry, + context_manager=context_manager, + communication_hub=communication_hub, + user_id=user_id + ) + + hotel_agent = HotelRecommendationAgent( + llm=self.llm, + tool_registry=self.tool_registry, + context_manager=context_manager, + communication_hub=communication_hub, + user_id=user_id + ) + + weather_agent = WeatherQueryAgent( + llm=self.llm, + tool_registry=self.tool_registry, + context_manager=context_manager, + communication_hub=communication_hub, + user_id=user_id + ) + + planner_agent = EnhancedPlannerAgent( + llm=self.llm, + context_manager=context_manager, + communication_hub=communication_hub, + user_id=user_id + ) + + # 执行规划流程 + try: + # 性能优化:并行执行景点、酒店、天气查询 + logger.info("🚀 开始并行执行智能体查询(景点、酒店、天气)...") + + # 构建查询 + attraction_query = self._build_attraction_query(request) + hotel_query = self._build_hotel_query(request) + weather_query = f"请查询{request.destination}的天气信息,日期范围:{request.start_date} 到 {request.end_date}" + + # 使用线程池并行执行三个独立查询 + attractions = None + hotels = None + weather = None + + with ThreadPoolExecutor(max_workers=3, thread_name_prefix="agent_query") as executor: + # 提交三个任务 + future_attractions = executor.submit(attraction_agent.run, attraction_query) + future_hotels = executor.submit(hotel_agent.run, hotel_query) + future_weather = executor.submit(weather_agent.run, weather_query) + + # 等待并获取结果(带异常处理) + # 1. 获取景点搜索结果 + logger.info(" 等待景点搜索结果...") + try: + attractions = future_attractions.result(timeout=120) + logger.info(f"✅ 景点搜索完成: {attractions[:200] if attractions else '无结果'}...") + except Exception as e: + logger.error(f"❌ 景点搜索失败: {e},使用降级策略") + attractions = f"未找到{request.destination}相关景点信息,请参考通用旅游攻略" + + # 2. 获取酒店推荐结果 + logger.info(" 等待酒店推荐结果...") + try: + hotels = future_hotels.result(timeout=120) + logger.info(f"✅ 酒店推荐完成: {hotels[:200] if hotels else '无结果'}...") + except Exception as e: + logger.error(f"❌ 酒店推荐失败: {e},使用降级策略") + hotels = f"未找到{request.destination}相关酒店信息,请根据预算选择住宿" + + # 3. 获取天气查询结果 + logger.info(" 等待天气查询结果...") + try: + weather = future_weather.result(timeout=120) + logger.info(f"✅ 天气查询完成: {weather[:200] if weather else '无结果'}...") + except Exception as e: + logger.error(f"❌ 天气查询失败: {e},使用降级策略") + weather = f"未能获取{request.destination}天气信息,建议出行前查看实时天气预报" + + logger.info("🎯 所有并行查询完成!") + + # 4. 行程规划 + logger.info("开始行程规划...") + prompt = self._construct_prompt(request, attractions, hotels, weather) + json_plan_str = planner_agent.run(prompt) + + if not json_plan_str: + logger.error("LLM未能生成有效的行程计划JSON。") + return None + + # 5. 解析和验证 + if '```json' in json_plan_str: + json_plan_str = json_plan_str.split('```json')[1].split('```')[0].strip() + + plan_data = json.loads(json_plan_str) + validated_plan = TripPlanResponse.model_validate(plan_data) + + # 6. 验证和过滤地理位置 + validated_plan = self._validate_and_filter_plan(validated_plan, request.destination) + + # 7. 为景点添加图片(批量异步搜索 - 性能优化) + logger.info("🚀 开始批量异步搜索景点图片...") + + # 收集所有景点和对应的搜索关键词 + attractions_to_search = [] + for day in validated_plan.days: + for attraction in day.attractions: + # 构造搜索关键词:优先用"景点名 + 城市" + search_query = f"{attraction.name} {request.destination}" + attractions_to_search.append({ + 'attraction': attraction, + 'query': search_query + }) + + logger.info(f"📸 需要为 {len(attractions_to_search)} 个景点搜索图片") + + # 批量异步获取图片URL + if attractions_to_search: + # 提取所有查询关键词 + queries = [item['query'] for item in attractions_to_search] + + # 使用异步批量搜索 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + image_urls = loop.run_until_complete( + self.unsplash_service.fetch_images_batch( + queries=queries, + use_fallback=True, # 失败时使用占位图 + use_cache=True # 使用缓存 + ) + ) + + # 将结果分配给对应的景点 + success_count = 0 + fallback_count = 0 + for item, image_url in zip(attractions_to_search, image_urls): + attraction = item['attraction'] + if image_url: + attraction.image_urls = [image_url] + success_count += 1 + else: + attraction.image_urls = [] + logger.warning(f"❌ 景点 '{attraction.name}' 未找到图片") + + logger.info(f"✅ 图片搜索完成: 成功 {success_count}/{len(attractions_to_search)}") + + # 输出缓存统计 + cache_stats = self.unsplash_service.get_cache_stats() + logger.debug(f"📊 Unsplash缓存统计: {cache_stats}") + + except Exception as e: + logger.error(f"❌ 批量搜索图片失败: {e}") + # 降级:设置空列表 + for item in attractions_to_search: + item['attraction'].image_urls = [] + finally: + loop.close() + else: + logger.info("📸 没有需要搜索图片的景点") + + # 8. 存储用户偏好记忆 + self.memory_service.store_user_preference( + user_id, + "trip_request", + { + "destination": request.destination, + "preferences": request.preferences, + "hotel_preferences": request.hotel_preferences, + "budget": request.budget, + "trip_title": validated_plan.trip_title + } + ) + + logger.info(f"成功生成并验证了行程计划: {validated_plan.trip_title}") + + # 清理上下文管理器(可选,也可以保留用于后续查询) + # remove_context_manager(request_id) + + return validated_plan + + except (json.JSONDecodeError, Exception) as e: + logger.error( + f"解析或验证LLM返回的JSON时失败: {e}", + exc_info=True, + extra={ + "request_id": request_id, + "destination": request.destination + } + ) + return None diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/agents/specialized_agents.py b/Co-creation-projects/275145-TripPlanner/backend/app/agents/specialized_agents.py new file mode 100644 index 00000000..8a54ba92 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/agents/specialized_agents.py @@ -0,0 +1,462 @@ +""" +专业智能体实现 +基于EnhancedAgent创建各个专业领域的智能体 +""" +from typing import Optional, Dict, Any +from hello_agents import HelloAgentsLLM, ToolRegistry +from app.agents.enhanced_agent import EnhancedAgent +from app.services.context_manager import ContextManager +from app.agents.agent_communication import ( + AgentCommunicationHub, + AgentMessage, + MessageType +) +from app.observability.logger import default_logger as logger + + +# ============ Agent提示词 ============ + +ATTRACTION_AGENT_PROMPT = """你是景点搜索专家。你的任务是根据城市和用户偏好搜索合适的景点。 + +**重要提示:** +1. 你必须使用工具来搜索景点!不要自己编造景点信息! +2. 你应该参考用户的历史偏好和相似行程来优化搜索策略 +3. 如果从上下文信息中了解到用户喜欢特定类型的景点,优先搜索这些类型 +4. 搜索完成后,将结果共享给其他智能体 + +**工具调用格式:** +使用maps_text_search工具时,必须严格按照以下格式: +`[TOOL_CALL:amap_maps_text_search:keywords=景点关键词,city=城市名]` + +**示例:** +用户: "搜索北京的历史文化景点" +你的回复: [TOOL_CALL:amap_maps_text_search:keywords=历史文化,city=北京] + +用户: "搜索上海的公园" +你的回复: [TOOL_CALL:amap_maps_text_search:keywords=公园,city=上海] + +**注意:** +1. 必须使用工具,不要直接回答 +2. 格式必须完全正确,包括方括号和冒号 +3. 参数用逗号分隔 +4. 如果用户有历史偏好,优先使用这些偏好作为搜索关键词 +""" + +WEATHER_AGENT_PROMPT = """你是天气查询专家。你的任务是查询指定城市的天气信息。 + +**重要提示:** +1. 你必须使用工具来查询天气!不要自己编造天气信息! +2. 你应该查询整个行程期间的天气,而不仅仅是当前日期 +3. 查询完成后,将天气信息共享给规划智能体 + +**工具调用格式:** +使用maps_weather工具时,必须严格按照以下格式: +`[TOOL_CALL:amap_maps_weather:city=城市名]` + +**示例:** +用户: "查询北京天气" +你的回复: [TOOL_CALL:amap_maps_weather:city=北京] + +**注意:** +1. 必须使用工具,不要直接回答 +2. 格式必须完全正确,包括方括号和冒号 +""" + +HOTEL_AGENT_PROMPT = """你是酒店推荐专家。你的任务是根据城市和景点位置推荐合适的酒店。 + +**重要提示:** +1. 你必须使用工具来搜索酒店!不要自己编造酒店信息! +2. 你应该参考景点智能体提供的景点位置信息,优先推荐距离景点较近的酒店 +3. 你应该参考用户的酒店偏好和历史选择来优化推荐 +4. 推荐完成后,将结果共享给规划智能体 + +**工具调用格式:** +使用maps_text_search工具搜索酒店时,必须严格按照以下格式: +`[TOOL_CALL:amap_maps_text_search:keywords=酒店,city=城市名]` + +**示例:** +用户: "搜索北京的酒店" +你的回复: [TOOL_CALL:amap_maps_text_search:keywords=酒店,city=北京] + +**注意:** +1. 必须使用工具,不要直接回答 +2. 格式必须完全正确,包括方括号和冒号 +3. 关键词使用"酒店"或"宾馆" +4. 如果从上下文了解到景点位置,优先搜索附近的酒店 +""" + +PLANNER_AGENT_PROMPT = """你是行程规划专家。你的任务是根据景点信息、酒店信息和天气信息,生成详细的旅行计划。 + +**重要提示:** +1. 你应该参考用户的历史行程和反馈来优化规划策略 +2. 你应该考虑从其他智能体共享的信息(景点位置、酒店位置等) +3. 如果发现信息不足,可以请求其他智能体提供更多信息 +4. 生成计划后,可以与其他智能体协商优化方案 + +**地理位置和距离要求(非常重要):** +1. **所有景点必须在目标城市范围内**:严格验证每个景点的地理位置(经纬度),确保所有景点都在用户指定的目的地城市,绝对不要推荐其他城市的景点。 +2. **同一天景点距离控制**:同一天内的景点之间距离要合理,建议不超过50公里,优先安排距离较近的景点在同一天游览。 +3. **相邻天景点距离控制**:相邻两天的景点之间距离也要合理,避免第一天在城市的东边,第二天突然跳到城市的西边,建议相邻天的主要景点距离不超过100公里。 +4. **地理位置验证**:在生成行程前,必须验证所有景点的经纬度是否在目标城市的合理范围内。如果发现景点位置异常(如规划杭州之旅却出现福建的景点),必须排除该景点或重新搜索。 +5. **路线优化**:按照地理位置合理安排景点顺序,尽量形成一条合理的游览路线,减少往返路程。 + +请严格按照以下 **JSON 结构** 返回旅行计划。你的输出必须是有效的 JSON,不要添加任何额外的解释或注释。 + +**整体设计要求:** +1. **景点模型(Attraction)** 必须包含:景点名称、类型、评分、建议游玩时间、描述、地址、经纬度、景点图片 URL 列表、门票价格。 +2. **酒店模型(Hotel)** 在原有基础上,必须补充「距离景点的距离」字段。 +3. **单日行程(DailyPlan)** 必须包含: + - 推荐住宿(recommended_hotel) + - 景点列表(attractions) + - 餐饮列表(dinings) + - 单日预算拆分(budget),包括交通费用、餐饮费用、酒店费用、景点门票费用。 +4. **预算**:总预算字段需要拆分为交通费用、餐饮费用、酒店费用、景点门票费用四项,并给出总和。 +5. 所有的「图片」只能挂在 **景点(attractions)** 上,不能给酒店或餐饮生成图片 URL。 + +**响应格式(示例,仅作为结构参考,字段名和类型必须严格遵守):** +```json +{ + "trip_title": "一个吸引人的行程标题", + "total_budget": { + "transport_cost": 300.0, + "dining_cost": 800.0, + "hotel_cost": 1200.0, + "attraction_ticket_cost": 400.0, + "total": 2700.0 + }, + "hotels": [ + { + "name": "酒店名称", + "address": "酒店地址", + "location": {"lat": 39.915, "lng": 116.397}, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.2 + } + ], + "days": [ + { + "day": 1, + "theme": "古都历史探索", + "weather": { + "date": "YYYY-MM-DD", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "25", + "night_temp": "15", + "day_wind": "东风3级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "当日推荐酒店", + "address": "酒店地址", + "location": {"lat": 39.915, "lng": 116.397}, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 0.8 + }, + "attractions": [ + { + "name": "景点名称", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "景点简介和游览建议", + "address": "景点地址", + "location": {"lat": 39.915, "lng": 116.397}, + "image_urls": [ + "https://example.com/attraction-image-1.jpg" + ], + "ticket_price": "60" + } + ], + "dinings": [ + { + "name": "餐厅名称", + "address": "餐厅地址", + "location": {"lat": 39.910, "lng": 116.400}, + "cost_per_person": "80", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 120.0, + "total": 770.0 + } + } + ] +} +``` + +**关键要求:** +1. **trip_title**:创建一个吸引人且能体现行程特色的标题。 +2. **total_budget**:给出四类费用(交通、餐饮、酒店、景点门票),并计算 total 为它们的总和。 +3. **hotels / recommended_hotel**:酒店必须包含名称、地址、位置坐标、价格、评分和距离主要景点的距离。 +4. **days**:为每一天创建详细的行程计划。 +5. **theme**:每天的主题要体现该天的主要活动特色。 +6. **weather**:包含该天的天气信息,温度必须是纯数字(不要带 °C 等单位),并给出白天和夜间的风向与风力(day_wind, night_wind)。 +7. **attractions / dinings**: + - attractions:只包含"景点"信息,图片 URL 只能出现在 attractions.image_urls 中。 + - dinings:只包含餐饮信息,不能包含图片 URL 字段。 +8. **时间规划**:在描述中要体现出合理的时间安排(例如上午/下午/晚上安排哪些景点和餐饮)。 +9. **预算准确**:total_budget.total 必须等于四类费用之和;每天的 budget.total 也必须等于四项之和。 +10. **避免重复**:不要在多天中重复推荐同一个景点或餐厅。 +11. **地理位置验证(关键)**: + - 在生成JSON前,必须检查所有景点的location字段(经纬度)是否在目标城市范围内 + - 如果发现景点位置不在目标城市,必须排除该景点 + - 同一天的景点经纬度应该相对集中,距离不超过50公里 + - 相邻天的景点经纬度变化应该合理,避免突然跨越很大距离 +""" + + +class AttractionSearchAgent(EnhancedAgent): + """景点搜索智能体(增强版)""" + + def __init__( + self, + llm: HelloAgentsLLM, + tool_registry: ToolRegistry, + context_manager: Optional[ContextManager] = None, + communication_hub: Optional[AgentCommunicationHub] = None, + user_id: Optional[str] = None + ): + super().__init__( + name="景点搜索专家", + llm=llm, + system_prompt=ATTRACTION_AGENT_PROMPT, + tool_registry=tool_registry, + enable_tool_calling=True, + context_manager=context_manager, + communication_hub=communication_hub, + user_id=user_id + ) + + def handle_message(self, message: AgentMessage) -> Dict[str, Any]: + """处理接收到的消息""" + if message.message_type == MessageType.REQUEST: + # 处理其他智能体的请求 + content = message.content + if content.get("action") == "search_attractions": + query = content.get("query", "") + result = self.run(query) + return { + "status": "success", + "result": result + } + return super().handle_message(message) + + def run(self, input_text: str, max_tool_iterations: int = 3, **kwargs) -> str: + """运行景点搜索(增强版)""" + # 在运行前,检查是否有共享的上下文信息 + if self.context_manager: + request_context = self.context_manager.get_shared_data("request") + if request_context: + # 从上下文获取用户偏好,优化搜索 + preferences = request_context.get("preferences", []) + if preferences and "景点" not in input_text.lower(): + # 如果输入中没有明确提到景点类型,使用用户偏好 + pref_keywords = ", ".join(preferences[:2]) # 取前两个偏好 + input_text = f"{input_text},优先搜索{pref_keywords}相关的景点" + + result = super().run(input_text, max_tool_iterations, **kwargs) + + # 搜索完成后,将结果共享给酒店智能体 + if self.communication_hub and self.context_manager: + # 提取景点位置信息(简单示例,实际需要解析结果) + self.context_manager.share_data( + "attraction_locations", + result[:500], # 共享部分结果 + from_agent=self.name + ) + + # 通知酒店智能体 + self.send_message_to_agent( + "酒店推荐专家", + MessageType.SUGGESTION, + { + "message": "景点搜索完成", + "attraction_info": result[:500] + } + ) + + return result + + +class HotelRecommendationAgent(EnhancedAgent): + """酒店推荐智能体(增强版)""" + + def __init__( + self, + llm: HelloAgentsLLM, + tool_registry: ToolRegistry, + context_manager: Optional[ContextManager] = None, + communication_hub: Optional[AgentCommunicationHub] = None, + user_id: Optional[str] = None + ): + super().__init__( + name="酒店推荐专家", + llm=llm, + system_prompt=HOTEL_AGENT_PROMPT, + tool_registry=tool_registry, + enable_tool_calling=True, + context_manager=context_manager, + communication_hub=communication_hub, + user_id=user_id + ) + + def handle_message(self, message: AgentMessage) -> Dict[str, Any]: + """处理接收到的消息""" + if message.message_type == MessageType.SUGGESTION: + # 处理景点智能体的建议 + content = message.content + if "attraction_info" in content: + # 基于景点信息优化酒店搜索 + logger.info(f"{self.name} 收到景点信息,将优化酒店推荐") + return super().handle_message(message) + + def run(self, input_text: str, max_tool_iterations: int = 3, **kwargs) -> str: + """运行酒店推荐(增强版)""" + # 检查是否有景点位置信息 + if self.context_manager: + attraction_locations = self.context_manager.get_shared_data("attraction_locations") + if attraction_locations: + input_text = f"{input_text}。请注意景点位置信息:{attraction_locations[:200]}" + + result = super().run(input_text, max_tool_iterations, **kwargs) + + # 推荐完成后,将结果共享给规划智能体 + if self.context_manager: + self.context_manager.share_data( + "hotel_recommendations", + result[:500], + from_agent=self.name + ) + + return result + + +class WeatherQueryAgent(EnhancedAgent): + """天气查询智能体(增强版)""" + + def __init__( + self, + llm: HelloAgentsLLM, + tool_registry: ToolRegistry, + context_manager: Optional[ContextManager] = None, + communication_hub: Optional[AgentCommunicationHub] = None, + user_id: Optional[str] = None + ): + super().__init__( + name="天气查询专家", + llm=llm, + system_prompt=WEATHER_AGENT_PROMPT, + tool_registry=tool_registry, + enable_tool_calling=True, + context_manager=context_manager, + communication_hub=communication_hub, + user_id=user_id + ) + + def run(self, input_text: str, max_tool_iterations: int = 3, **kwargs) -> str: + """运行天气查询(增强版)""" + # 检查是否有日期信息 + if self.context_manager: + request_context = self.context_manager.get_shared_data("request") + if request_context: + start_date = request_context.get("start_date") + end_date = request_context.get("end_date") + if start_date and end_date: + input_text = f"{input_text},查询日期范围:{start_date} 到 {end_date}" + + result = super().run(input_text, max_tool_iterations, **kwargs) + + # 查询完成后,将结果共享给规划智能体 + if self.context_manager: + self.context_manager.share_data( + "weather_info", + result[:500], + from_agent=self.name + ) + + return result + + +class PlannerAgent(EnhancedAgent): + """行程规划智能体(增强版)""" + + def __init__( + self, + llm: HelloAgentsLLM, + context_manager: Optional[ContextManager] = None, + communication_hub: Optional[AgentCommunicationHub] = None, + user_id: Optional[str] = None + ): + super().__init__( + name="行程规划专家", + llm=llm, + system_prompt=PLANNER_AGENT_PROMPT, + tool_registry=None, # 规划智能体不需要工具 + enable_tool_calling=False, + context_manager=context_manager, + communication_hub=communication_hub, + user_id=user_id + ) + + def handle_message(self, message: AgentMessage) -> Dict[str, Any]: + """处理接收到的消息""" + if message.message_type == MessageType.NEGOTIATION: + # 处理协商请求 + content = message.content + proposal = content.get("proposal", {}) + # 简单的协商逻辑:评估提案并返回意见 + return { + "status": "agree", + "agreement": True, + "feedback": {} + } + return super().handle_message(message) + + def run(self, input_text: str, max_tool_iterations: int = 3, **kwargs) -> str: + """运行行程规划(增强版)""" + # 在规划前,检查是否有足够的信息 + if self.context_manager: + shared_data = self.context_manager.get_all_shared_data() + + # 如果信息不足,请求其他智能体提供 + if "attraction_locations" not in shared_data: + logger.warning("景点信息不足,请求景点智能体提供") + self.send_message_to_agent( + "景点搜索专家", + MessageType.REQUEST, + {"action": "provide_attractions"} + ) + + if "hotel_recommendations" not in shared_data: + logger.warning("酒店信息不足,请求酒店智能体提供") + self.send_message_to_agent( + "酒店推荐专家", + MessageType.REQUEST, + {"action": "provide_hotels"} + ) + + result = super().run(input_text, max_tool_iterations, **kwargs) + + # 规划完成后,存储记忆 + if self.user_id and self.context_manager: + request_context = self.context_manager.get_shared_data("request") + if request_context: + self.store_memory( + "preference", + { + "preference_type": "trip_planning", + "destination": request_context.get("destination"), + "preferences": request_context.get("preferences", []), + "trip_result": result[:200] # 存储部分结果作为参考 + } + ) + + return result + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/api/__init__.py b/Co-creation-projects/275145-TripPlanner/backend/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/api/v1/__init__.py b/Co-creation-projects/275145-TripPlanner/backend/app/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/api/v1/auth.py b/Co-creation-projects/275145-TripPlanner/backend/app/api/v1/auth.py new file mode 100644 index 00000000..ceeabeb6 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/api/v1/auth.py @@ -0,0 +1,396 @@ +""" +用户认证API接口 +提供登录、注册等功能 +""" +from fastapi import APIRouter, Request, HTTPException, status, Depends, UploadFile, File +from pydantic import BaseModel, validator +from typing import Optional +import os +import uuid +from pathlib import Path +from app.middleware.auth import AuthMiddleware, get_current_user, get_user_id +from app.observability.logger import default_logger as logger +from app.config import settings +from app.services.redis_service import redis_service + +router = APIRouter() + +# 头像上传目录 +UPLOAD_DIR = Path("uploads/avatars") +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +# 用户模型 +class UserLogin(BaseModel): + """用户登录请求""" + username: str + password: str + +class UserRegister(BaseModel): + """用户注册请求""" + username: str + password: str + +class UserUpdate(BaseModel): + """用户信息更新请求""" + username: Optional[str] = None + phone: Optional[str] = None + gender: Optional[str] = None + birthday: Optional[str] = None + bio: Optional[str] = None + travel_preferences: Optional[list] = None + avatar_url: Optional[str] = None + + @validator('gender') + def validate_gender(cls, v): + if v is not None and v not in ['male', 'female', 'other']: + raise ValueError('性别必须是 male、female 或 other') + return v + +class ChangePassword(BaseModel): + """修改密码请求""" + old_password: str + new_password: str + +class UserResponse(BaseModel): + """用户响应""" + user_id: str + username: str + user_type: str + phone: Optional[str] = None + gender: Optional[str] = None + birthday: Optional[str] = None + bio: Optional[str] = None + travel_preferences: Optional[list] = None + avatar_url: Optional[str] = None + +class AuthToken(BaseModel): + """认证令牌响应""" + access_token: str + token_type: str + user: UserResponse + +@router.post("/login", response_model=AuthToken) +def login(request: Request, login_data: UserLogin): + """ + 用户登录 + + Args: + request: FastAPI请求对象 + login_data: 登录数据 + + Returns: + JWT令牌和用户信息 + """ + logger.info(f"用户登录尝试 - 账号: {login_data.username}") + + # 使用Redis验证用户 + user = redis_service.verify_user(login_data.username, login_data.password) + + if not user: + logger.warning(f"登录失败 - 账号: {login_data.username}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="账号或密码错误" + ) + + # 生成JWT令牌 + access_token = AuthMiddleware.generate_jwt_token( + user_id=user["user_id"], + username=user["username"] + ) + + logger.info(f"用户登录成功 - UserID: {user['user_id']}") + + return AuthToken( + access_token=access_token, + token_type="Bearer", + user=UserResponse( + user_id=user["user_id"], + username=user["username"], + user_type="registered", + phone=user.get("phone"), + gender=user.get("gender"), + birthday=user.get("birthday"), + bio=user.get("bio"), + travel_preferences=user.get("travel_preferences"), + avatar_url=user.get("avatar_url") + ) + ) + +@router.post("/register", response_model=AuthToken) +def register(request: Request, register_data: UserRegister): + """ + 用户注册 + + Args: + request: FastAPI请求对象 + register_data: 注册数据 + + Returns: + JWT令牌和用户信息 + """ + logger.info(f"用户注册尝试 - 账号: {register_data.username}") + + # 使用Redis创建用户 + try: + user_id = str(uuid.uuid4()) + user = redis_service.create_user( + user_id=user_id, + username=register_data.username, + password=register_data.password, + phone=None, + gender="other", + birthday=None, + bio=None, + travel_preferences=[], + avatar_url=None + ) + except ValueError as e: + # 用户已存在 + logger.warning(f"注册失败 - {str(e)}") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e) + ) + except RuntimeError as e: + # 其他错误 + logger.error(f"注册失败 - {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="注册失败,请稍后重试" + ) + + # 生成JWT令牌 + access_token = AuthMiddleware.generate_jwt_token( + user_id=user_id, + username=register_data.username + ) + + logger.info(f"用户注册成功 - UserID: {user_id}") + + return AuthToken( + access_token=access_token, + token_type="Bearer", + user=UserResponse( + user_id=user_id, + username=register_data.username, + user_type="registered" + ) + ) + +@router.get("/me", response_model=UserResponse) +def get_current_user_info(request: Request, current_user: dict = Depends(get_current_user)): + """ + 获取当前用户信息 + + Args: + request: FastAPI请求对象 + current_user: 当前认证用户信息 + + Returns: + 用户信息 + """ + user_username = current_user.get("username", "") + + # 从Redis获取用户完整信息 + user = redis_service.get_user_by_username(user_username) + + if not user: + # 如果找不到用户,返回基本信息 + return UserResponse( + user_id=current_user["user_id"], + username=current_user.get("username", ""), + user_type="registered" + ) + + return UserResponse( + user_id=current_user["user_id"], + username=current_user.get("username", ""), + user_type=current_user.get("user_type", "registered"), + phone=user.get("phone"), + gender=user.get("gender"), + birthday=user.get("birthday"), + bio=user.get("bio"), + travel_preferences=user.get("travel_preferences", []), + avatar_url=user.get("avatar_url") + ) + +@router.put("/me", response_model=UserResponse) +def update_user_profile( + request: Request, + update_data: UserUpdate, + current_user: dict = Depends(get_current_user) +): + """ + 更新用户资料 + + Args: + request: FastAPI请求对象 + update_data: 更新数据 + current_user: 当前认证用户信息 + + Returns: + 更新后的用户信息 + """ + user_username = current_user.get("username", "") + + # 使用Redis更新用户信息 + try: + user = redis_service.update_user( + username=user_username, + phone=update_data.phone, + gender=update_data.gender, + birthday=update_data.birthday, + bio=update_data.bio, + travel_preferences=update_data.travel_preferences, + avatar_url=update_data.avatar_url + ) + except ValueError as e: + logger.warning(f"更新用户资料失败 - {str(e)}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + except RuntimeError as e: + logger.error(f"更新用户资料失败 - {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="更新失败,请稍后重试" + ) + + logger.info(f"用户资料更新成功 - UserID: {user['user_id']}") + + return UserResponse( + user_id=user["user_id"], + username=user["username"], + user_type="registered", + phone=user.get("phone"), + gender=user.get("gender"), + birthday=user.get("birthday"), + bio=user.get("bio"), + travel_preferences=user.get("travel_preferences", []), + avatar_url=user.get("avatar_url") + ) + +@router.post("/change-password") +def change_password( + request: Request, + password_data: ChangePassword, + current_user: dict = Depends(get_current_user) +): + """ + 修改密码 + + Args: + request: FastAPI请求对象 + password_data: 密码数据 + current_user: 当前认证用户信息 + + Returns: + 操作结果 + """ + user_username = current_user.get("username", "") + + # 使用Redis更新密码 + try: + redis_service.update_password( + username=user_username, + old_password=password_data.old_password, + new_password=password_data.new_password + ) + except ValueError as e: + logger.warning(f"密码修改失败 - {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + except RuntimeError as e: + logger.error(f"密码修改失败 - {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="密码修改失败,请稍后重试" + ) + + logger.info(f"用户密码修改成功 - Username: {user_username}") + + return {"message": "密码修改成功"} + +@router.post("/logout") +def logout(request: Request, current_user: dict = Depends(get_current_user)): + """ + 退出登录 + + Args: + request: FastAPI请求对象 + current_user: 当前认证用户信息 + + Returns: + 操作结果 + """ + user_id = current_user.get("user_id", "unknown") + user_type = current_user.get("user_type", "unknown") + + logger.info(f"用户退出登录 - UserID: {user_id}, UserType: {user_type}") + + # 生产环境可能需要: + # 1. 将JWT令牌加入黑名单 + # 2. 清除会话数据 + # 3. 记录退出时间等 + + return {"message": "退出登录成功"} + +@router.post("/upload-avatar") +async def upload_avatar( + request: Request, + file: UploadFile = File(...), + current_user: dict = Depends(get_current_user) +): + """ + 上传用户头像 + + Args: + request: FastAPI请求对象 + file: 上传的头像文件 + current_user: 当前认证用户信息 + + Returns: + 头像URL + """ + # 验证文件类型 + if not file.content_type or not file.content_type.startswith('image/'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="只能上传图片文件" + ) + + # 生成唯一文件名 + file_extension = os.path.splitext(file.filename)[1] if file.filename else '.jpg' + unique_filename = f"{current_user['user_id']}_{uuid.uuid4().hex[:8]}{file_extension}" + file_path = UPLOAD_DIR / unique_filename + + try: + # 保存文件 + contents = await file.read() + # 验证文件大小(2MB限制) + if len(contents) > 2 * 1024 * 1024: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="文件大小不能超过2MB" + ) + + with open(file_path, "wb") as f: + f.write(contents) + + # 生成文件URL + file_url = f"/uploads/avatars/{unique_filename}" + + logger.info(f"头像上传成功 - UserID: {current_user['user_id']}, URL: {file_url}") + + return {"url": file_url} + + except Exception as e: + logger.error(f"头像上传失败 - UserID: {current_user['user_id']}, Error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="头像上传失败" + ) \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/api/v1/trip.py b/Co-creation-projects/275145-TripPlanner/backend/app/api/v1/trip.py new file mode 100644 index 00000000..fd0e639c --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/api/v1/trip.py @@ -0,0 +1,234 @@ +from fastapi import APIRouter, Request, Depends, HTTPException +from app.models.trip_model import TripPlanRequest, TripPlanResponse +from app.observability.logger import default_logger as logger +from app.exceptions.custom_exceptions import ( + BusinessException, + LLMServiceException, + MapServiceException, + ImageServiceException +) +from app.exceptions.error_codes import ErrorCode +from app.middleware.auth import get_user_id +from app.agents.planner import CITY_BOUNDS +from datetime import datetime +from typing import List, Optional +import uuid + +# 导入新的Agent +from app.agents.planner import PlannerAgent + +# 导入共享的服务实例 +from app.services.llm_service import LLMService +from app.services.vector_memory_service import vector_memory_service +from app.services.redis_service import redis_service + +router = APIRouter() + +# 初始化所有Agent +# attraction_agent = AttractionSearchAgent() +# hotel_agent = HotelSearchAgent() +# weather_agent = WeatherQueryAgent() +planner_agent = PlannerAgent(llm_service=LLMService, memory_service=vector_memory_service) + + +@router.post("/plan", response_model=TripPlanResponse) +def plan_trip(request: TripPlanRequest, http_request: Request): + """ + 接收行程规划请求,通过多智能体协作完成规划。(增强版 - 支持记忆和上下文) + """ + # 获取用户ID(从认证中间件获取) + user_id = get_user_id(http_request) + + logger.info( + f"接收到新的行程规划请求", + extra={ + "destination": request.destination, + "start_date": request.start_date, + "end_date": request.end_date, + "budget": request.budget, + "preferences": request.preferences, + "hotel_preferences": request.hotel_preferences, + "user_id": user_id + } + ) + + try: + # 参数验证 + if not request.destination or not request.destination.strip(): + raise BusinessException( + ErrorCode.MISSING_PARAMETER, + details={"field": "destination", "message": "目的地不能为空"} + ) + + if not request.start_date or not request.end_date: + raise BusinessException( + ErrorCode.MISSING_PARAMETER, + details={"field": "date_range", "message": "日期范围不能为空"} + ) + + # 城市支持验证(警告但不阻止) + if request.destination not in CITY_BOUNDS: + logger.warning( + f"⚠️ 用户请求不支持的城市: {request.destination}。" + f"支持的城市包括:北京、上海、广州、深圳、成都、杭州、重庆、武汉、西安、苏州、天津、南京、长沙、郑州、" + f"厦门、青岛、大连、三亚、丽江、桂林、昆明、哈尔滨、沈阳、济南、黄山、张家界、敦煌、拉萨、乌鲁木齐、宁波等30个热门旅游城市。" + f"对于不支持的城市,系统将尝试进行基础规划,但可能存在准确度降低的情况。", + extra={ + "destination": request.destination, + "supported_cities_count": len(CITY_BOUNDS), + "is_supported_city": False + } + ) + else: + logger.info( + f"✅ 用户请求支持的城市: {request.destination}", + extra={"is_supported_city": True} + ) + + # 调用增强的PlannerAgent进行规划 + final_plan = planner_agent.plan_trip(request=request, user_id=user_id) + + # 保存向量记忆和完整行程 + if final_plan: + # 存储用户行程到向量数据库 + trip_data = { + "destination": request.destination, + "start_date": request.start_date, + "end_date": request.end_date, + "preferences": request.preferences, + "hotel_preferences": request.hotel_preferences, + "budget": request.budget, + "trip_title": final_plan.trip_title, + "days": [day.dict() for day in final_plan.days] + } + vector_memory_service.store_user_trip(user_id, trip_data) + + # 存储用户偏好 + vector_memory_service.store_user_preference(user_id, "trip_preferences", { + "destination": request.destination, + "preferences": request.preferences, + "hotel_preferences": request.hotel_preferences, + "budget": request.budget + }) + + # 保存向量索引 + vector_memory_service.save() + + # 保存完整行程到Redis(新增) + trip_id = str(uuid.uuid4()) + full_trip_data = final_plan.model_dump() + full_trip_data["id"] = trip_id + full_trip_data["created_at"] = datetime.now().isoformat() + redis_service.store_trip(user_id, trip_id, full_trip_data) + + # 返回时包含trip_id + final_plan_dict = final_plan.model_dump() + final_plan_dict["id"] = trip_id + final_plan_dict["created_at"] = datetime.now().isoformat() + final_plan = TripPlanResponse(**final_plan_dict) + + if not final_plan: + raise BusinessException( + ErrorCode.TRIP_PLAN_FAILED, + details={"message": "无法生成行程计划,请检查日志获取更多信息"} + ) + + logger.info( + f"行程规划成功", + extra={ + "destination": request.destination, + "trip_title": final_plan.trip_title, + "days": len(final_plan.days), + "user_id": user_id + } + ) + + return final_plan + + except BusinessException: + # 业务异常直接抛出,由全局异常处理器处理 + raise + except Exception as e: + # 其他异常包装为业务异常 + logger.error( + f"处理/plan请求时发生意外错误: {e}", + exc_info=True, + extra={ + "destination": request.destination, + "error_type": type(e).__name__, + "user_id": user_id + } + ) + raise BusinessException( + ErrorCode.TRIP_PLAN_FAILED, + message=f"行程规划失败: {str(e)}", + details={"error_type": type(e).__name__} + ) + + +@router.get("/list", response_model=List[TripPlanResponse]) +def get_trip_list(http_request: Request): + """ + 获取用户的所有行程列表 + """ + user_id = get_user_id(http_request) + + logger.info(f"获取用户行程列表 - UserID: {user_id}") + + try: + trips = redis_service.list_user_trips(user_id) + logger.info(f"成功获取行程列表 - UserID: {user_id}, Count: {len(trips)}") + return trips + except Exception as e: + logger.error(f"获取行程列表失败: {str(e)}") + raise BusinessException(ErrorCode.TRIP_PLAN_FAILED, message="获取行程列表失败") + + +@router.get("/{trip_id}", response_model=TripPlanResponse) +def get_trip(trip_id: str, http_request: Request): + """ + 获取指定行程的完整数据 + """ + user_id = get_user_id(http_request) + + logger.info(f"获取行程详情 - UserID: {user_id}, TripID: {trip_id}") + + try: + trip_data = redis_service.get_trip(trip_id) + + if not trip_data: + logger.warning(f"行程不存在 - TripID: {trip_id}") + raise HTTPException(status_code=404, detail="行程不存在") + + logger.info(f"成功获取行程详情 - TripID: {trip_id}") + return TripPlanResponse(**trip_data) + except HTTPException: + raise + except Exception as e: + logger.error(f"获取行程详情失败: {str(e)}") + raise BusinessException(ErrorCode.TRIP_PLAN_FAILED, message="获取行程详情失败") + + +@router.delete("/{trip_id}") +def delete_trip(trip_id: str, http_request: Request): + """ + 删除指定行程 + """ + user_id = get_user_id(http_request) + + logger.info(f"删除行程 - UserID: {user_id}, TripID: {trip_id}") + + try: + success = redis_service.delete_trip(user_id, trip_id) + + if not success: + logger.warning(f"行程不存在或删除失败 - TripID: {trip_id}") + raise HTTPException(status_code=404, detail="行程不存在或删除失败") + + logger.info(f"成功删除行程 - TripID: {trip_id}") + return {"message": "行程已删除"} + except HTTPException: + raise + except Exception as e: + logger.error(f"删除行程失败: {str(e)}") + raise BusinessException(ErrorCode.TRIP_PLAN_FAILED, message="删除行程失败") \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/config.py b/Co-creation-projects/275145-TripPlanner/backend/app/config.py new file mode 100644 index 00000000..747c19c9 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/config.py @@ -0,0 +1,76 @@ +import os +from typing import List, Optional +from pydantic_settings import BaseSettings, SettingsConfigDict + +class Settings(BaseSettings): + """ + 应用配置类,用于加载和管理环境变量。 + """ + model_config = SettingsConfigDict(env_file=".env", env_file_encoding='utf-8', extra='ignore') + + # LLM 配置 + LLM_MODEL_ID: Optional[str] = None + LLM_API_KEY: Optional[str] = None + LLM_BASE_URL: Optional[str] = None + LLM_TIMEOUT: int = 100 + + # 特定服务商的API Keys (用于自动检测) + OPENAI_API_KEY: Optional[str] = None + ZHIPU_API_KEY: Optional[str] = None + MODELSCOPE_API_KEY: Optional[str] = None + + # 服务器配置 + HOST: str = "0.0.0.0" + PORT: int = 8000 + + # CORS 配置 + CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000" + + # 日志级别 + LOG_LEVEL: str = "INFO" + + # Unsplash API + UNSPLASH_ACCESS_KEY: Optional[str] = None + UNSPLASH_SECRET_KEY: Optional[str] = None + + # 高德地图 API + AMAP_API_KEY: str + + # AMAP MCP Server + AMAP_MCP_SERVER_URL: str = "http://127.0.0.1:8000" + + # JWT 认证配置 + JWT_SECRET: str = "your-secret-key-change-in-production" + JWT_EXPIRY_HOURS: int = 24 + + # Redis 配置 + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + REDIS_DB: int = 0 + REDIS_PASSWORD: Optional[str] = None + REDIS_DECODE_RESPONSES: bool = True + + # 密码加密配置 + BCRYPT_ROUNDS: int = 12 + + # 向量数据库配置 + VECTOR_MEMORY_DIR: str = "vector_memory" + EMBEDDING_MODEL: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" + VECTOR_DIM: int = 384 + + # HuggingFace 配置 + HF_ENDPOINT: str = "https://hf-mirror.com" + HF_HUB_OFFLINE: bool = False + HF_HUB_CACHE_DIR: Optional[str] = None + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding='utf-8') + + def get_cors_origins_list(self) -> List[str]: + """获取CORS origins列表""" + return [origin.strip() for origin in self.CORS_ORIGINS.split(',')] + +# 创建一个全局可用的配置实例 +settings = Settings() + +# 注意:logger现在在observability.logger中统一管理 +# 这里不再创建logger,避免重复初始化 diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/__init__.py b/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/__init__.py new file mode 100644 index 00000000..6d6ffea3 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/__init__.py @@ -0,0 +1,5 @@ +""" +统一异常处理模块 +定义自定义异常类和错误处理框架 +""" + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/custom_exceptions.py b/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/custom_exceptions.py new file mode 100644 index 00000000..822a3231 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/custom_exceptions.py @@ -0,0 +1,78 @@ +""" +自定义异常类 +定义业务相关的异常类型 +""" +from typing import Optional, Dict, Any +from app.exceptions.error_codes import ErrorCode, get_error_message + + +class BaseAppException(Exception): + """应用基础异常类""" + + def __init__( + self, + error_code: ErrorCode, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + """ + 初始化异常 + + Args: + error_code: 错误码 + message: 错误消息,如果为None则使用错误码对应的默认消息 + details: 额外的错误详情 + """ + self.error_code = error_code + self.message = message or get_error_message(error_code) + self.details = details or {} + super().__init__(self.message) + + def to_dict(self) -> Dict[str, Any]: + """ + 将异常转换为字典格式 + + Returns: + 异常信息字典 + """ + return { + "error_code": self.error_code.value, + "error_message": self.message, + "details": self.details + } + + +class BusinessException(BaseAppException): + """业务异常""" + pass + + +class ServiceException(BaseAppException): + """服务异常""" + pass + + +class ValidationException(BaseAppException): + """参数验证异常""" + pass + + +class ExternalServiceException(ServiceException): + """外部服务异常""" + pass + + +class LLMServiceException(ExternalServiceException): + """LLM服务异常""" + pass + + +class MapServiceException(ExternalServiceException): + """地图服务异常""" + pass + + +class ImageServiceException(ExternalServiceException): + """图片服务异常""" + pass + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/error_codes.py b/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/error_codes.py new file mode 100644 index 00000000..5535a266 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/error_codes.py @@ -0,0 +1,92 @@ +""" +错误码定义 +统一管理所有错误码和错误消息 +""" + +from enum import IntEnum + + +class ErrorCode(IntEnum): + """错误码枚举""" + # 通用错误 (1000-1999) + SUCCESS = 0 + UNKNOWN_ERROR = 1000 + INVALID_REQUEST = 1001 + MISSING_PARAMETER = 1002 + INVALID_PARAMETER = 1003 + + # 业务错误 (2000-2999) + TRIP_PLAN_FAILED = 2000 + DESTINATION_NOT_FOUND = 2001 + INVALID_DATE_RANGE = 2002 + BUDGET_TOO_LOW = 2003 + NO_ATTRACTIONS_FOUND = 2004 + NO_HOTELS_FOUND = 2005 + WEATHER_QUERY_FAILED = 2006 + UNSUPPORTED_CITY = 2007 + + # 服务错误 (3000-3999) + LLM_SERVICE_ERROR = 3000 + LLM_TIMEOUT = 3001 + LLM_RATE_LIMIT = 3002 + MAP_SERVICE_ERROR = 3003 + IMAGE_SERVICE_ERROR = 3004 + + # 系统错误 (4000-4999) + DATABASE_ERROR = 4000 + EXTERNAL_API_ERROR = 4001 + CIRCUIT_BREAKER_OPEN = 4002 + RATE_LIMIT_EXCEEDED = 4003 + + # 认证授权错误 (5000-5999) + UNAUTHORIZED = 5000 + FORBIDDEN = 5001 + TOKEN_EXPIRED = 5002 + + +# 错误消息映射 +ERROR_MESSAGES = { + ErrorCode.SUCCESS: "操作成功", + ErrorCode.UNKNOWN_ERROR: "未知错误", + ErrorCode.INVALID_REQUEST: "无效的请求", + ErrorCode.MISSING_PARAMETER: "缺少必需参数", + ErrorCode.INVALID_PARAMETER: "参数无效", + + ErrorCode.TRIP_PLAN_FAILED: "行程规划失败", + ErrorCode.DESTINATION_NOT_FOUND: "未找到目的地信息", + ErrorCode.INVALID_DATE_RANGE: "日期范围无效", + ErrorCode.BUDGET_TOO_LOW: "预算过低,无法规划行程", + ErrorCode.NO_ATTRACTIONS_FOUND: "未找到相关景点", + ErrorCode.NO_HOTELS_FOUND: "未找到相关酒店", + ErrorCode.WEATHER_QUERY_FAILED: "天气查询失败", + ErrorCode.UNSUPPORTED_CITY: "该城市暂不支持精细规划", + + ErrorCode.LLM_SERVICE_ERROR: "LLM服务错误", + ErrorCode.LLM_TIMEOUT: "LLM服务超时", + ErrorCode.LLM_RATE_LIMIT: "LLM服务限流", + ErrorCode.MAP_SERVICE_ERROR: "地图服务错误", + ErrorCode.IMAGE_SERVICE_ERROR: "图片服务错误", + + ErrorCode.DATABASE_ERROR: "数据库错误", + ErrorCode.EXTERNAL_API_ERROR: "外部API调用失败", + ErrorCode.CIRCUIT_BREAKER_OPEN: "服务暂时不可用,请稍后重试", + ErrorCode.RATE_LIMIT_EXCEEDED: "请求过于频繁,请稍后再试", + + ErrorCode.UNAUTHORIZED: "未授权", + ErrorCode.FORBIDDEN: "禁止访问", + ErrorCode.TOKEN_EXPIRED: "令牌已过期", +} + + +def get_error_message(error_code: ErrorCode) -> str: + """ + 获取错误消息 + + Args: + error_code: 错误码 + + Returns: + 错误消息 + """ + return ERROR_MESSAGES.get(error_code, "未知错误") + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/exception_handler.py b/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/exception_handler.py new file mode 100644 index 00000000..5d6064e5 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/exceptions/exception_handler.py @@ -0,0 +1,135 @@ +""" +全局异常处理器 +统一处理所有异常并返回标准格式的错误响应 +""" +from fastapi import Request, status, HTTPException +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from app.exceptions.custom_exceptions import BaseAppException +from app.exceptions.error_codes import ErrorCode, get_error_message +from app.observability.logger import default_logger as logger, get_request_id + + +async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """ + 全局异常处理器 + + Args: + request: FastAPI请求对象 + exc: 异常对象 + + Returns: + JSON格式的错误响应 + """ + request_id = get_request_id() + + # 处理自定义异常 + if isinstance(exc, BaseAppException): + logger.error( + f"业务异常: {exc.message}", + exc_info=True, + extra={ + "request_id": request_id, + "error_code": exc.error_code.value, + "error_message": exc.message, + "details": exc.details, + "path": request.url.path, + "method": request.method + } + ) + + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "success": False, + "error_code": exc.error_code.value, + "error_message": exc.message, + "details": exc.details, + "request_id": request_id + } + ) + + # 处理FastAPI验证异常 + if isinstance(exc, RequestValidationError): + errors = exc.errors() + logger.warning( + f"请求验证失败: {errors}", + extra={ + "request_id": request_id, + "errors": errors, + "path": request.url.path, + "method": request.method + } + ) + + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "success": False, + "error_code": ErrorCode.INVALID_PARAMETER.value, + "error_message": "请求参数验证失败", + "details": {"validation_errors": errors}, + "request_id": request_id + } + ) + + # 处理HTTP异常 + if isinstance(exc, (StarletteHTTPException, HTTPException)): + status_code = exc.status_code if hasattr(exc, 'status_code') else 500 + detail = exc.detail if hasattr(exc, 'detail') else str(exc) + + # 429 Too Many Requests 特殊处理 + if status_code == 429: + error_code = ErrorCode.RATE_LIMIT_EXCEEDED.value + else: + error_code = ErrorCode.UNKNOWN_ERROR.value + + logger.warning( + f"HTTP异常: {detail}", + extra={ + "request_id": request_id, + "status_code": status_code, + "detail": detail, + "path": request.url.path, + "method": request.method + } + ) + + return JSONResponse( + status_code=status_code, + content={ + "success": False, + "error_code": error_code, + "error_message": detail, + "request_id": request_id + } + ) + + # 处理其他未知异常 + logger.error( + f"未处理的异常: {type(exc).__name__}: {str(exc)}", + exc_info=True, + extra={ + "request_id": request_id, + "exception_type": type(exc).__name__, + "exception_message": str(exc), + "path": request.url.path, + "method": request.method + } + ) + + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "success": False, + "error_code": ErrorCode.UNKNOWN_ERROR.value, + "error_message": "服务器内部错误", + "details": { + "exception_type": type(exc).__name__, + "exception_message": str(exc) + }, + "request_id": request_id + } + ) + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/main.py b/Co-creation-projects/275145-TripPlanner/backend/app/main.py new file mode 100644 index 00000000..0dd20fa4 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/main.py @@ -0,0 +1,80 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pathlib import Path +from .api.v1 import trip as trip_v1, auth as auth_v1 +from .config import settings +from .observability.logger import setup_logger, default_logger +from .middleware.request_id import RequestIDMiddleware +from .middleware.rate_limit import RateLimitMiddleware, RateLimiter +from .middleware.auth import AuthMiddleware +from .exceptions.exception_handler import global_exception_handler +from .services.vector_memory_service import vector_memory_service + +# 设置日志系统 +logger = setup_logger( + name="trip_planner", + log_level=settings.LOG_LEVEL, + enable_file_logging=True, + enable_console_logging=True +) + +# 创建FastAPI应用实例 +app = FastAPI( + title="智能旅行助手 API", + description="一个使用Agent和LLM进行智能行程规划的API服务。", + version="1.0.0" +) + +# 注册全局异常处理器 +app.add_exception_handler(Exception, global_exception_handler) + +# 配置中间件(注意顺序很重要) +# 1. 请求ID中间件(最外层,最先执行) +app.add_middleware(RequestIDMiddleware) + +# 2. 认证中间件(在限流之前,以便统计用户请求) +app.add_middleware(AuthMiddleware, + jwt_secret=settings.JWT_SECRET, + jwt_expiry_hours=settings.JWT_EXPIRY_HOURS) + +# 3. 限流中间件 +rate_limiter = RateLimiter( + global_rate=100, # 全局:100个请求/秒 + per_ip_rate=20, # 每个IP:20个请求/秒 + enabled=True +) +app.add_middleware(RateLimitMiddleware, rate_limiter=rate_limiter) + +# 4. CORS中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=settings.get_cors_origins_list(), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 挂载静态文件服务(用于头像等文件) +uploads_dir = Path("uploads") +uploads_dir.mkdir(parents=True, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads") + +# 包含v1版本的API路由 +app.include_router(trip_v1.router, prefix="/api/v1/trips", tags=["Trip Planning"]) +app.include_router(auth_v1.router, prefix="/api/v1/auth", tags=["Authentication"]) + +@app.on_event("startup") +def on_startup(): + """应用启动时执行""" + logger.info("智能旅行助手API已启动") + logger.info("已启用功能: 日志系统、请求ID追踪、认证、限流、熔断、降级、异常处理、向量记忆") + + # 输出向量记忆服务统计信息 + stats = vector_memory_service.get_stats() + logger.info(f"向量记忆服务统计: {stats}") + +@app.get("/health", tags=["Health Check"]) +def health_check(): + """健康检查端点,用于确认服务是否正常运行。""" + return {"status": "ok"} \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/middleware/__init__.py b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/__init__.py new file mode 100644 index 00000000..c75c4bfb --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/__init__.py @@ -0,0 +1,5 @@ +""" +中间件模块 +包含请求ID、限流、熔断、降级等中间件 +""" + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/middleware/auth.py b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/auth.py new file mode 100644 index 00000000..7805b6fc --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/auth.py @@ -0,0 +1,143 @@ +""" +用户认证中间件 +支持JWT令牌认证和游客模式 +""" +import jwt +import uuid +from typing import Optional, Dict, Any +from datetime import datetime, timedelta +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +from app.observability.logger import default_logger as logger +from app.config import settings + +class AuthMiddleware(BaseHTTPMiddleware): + """用户认证中间件""" + + def __init__(self, app, jwt_secret: str = None, jwt_expiry_hours: int = 24): + super().__init__(app) + self.jwt_secret = jwt_secret or settings.JWT_SECRET + self.jwt_expiry_hours = jwt_expiry_hours + self.guest_sessions = {} # 简单内存存储,生产环境应使用Redis + + async def dispatch(self, request: Request, call_next) -> Response: + """ + 认证处理流程 + 1. 检查Authorization头中的JWT令牌 + 2. 如果没有令牌,检查Cookie中的访客ID + 3. 如果都没有,创建新的访客ID + 4. 将用户信息添加到请求状态中 + """ + # 跳过健康检查和认证端点 + if request.url.path in ["/health", "/auth/login", "/auth/register"]: + return await call_next(request) + + user_info = None + + # 1. 尝试JWT认证 + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] + user_info = self._verify_jwt_token(token) + if user_info: + logger.info(f"JWT认证成功 - UserID: {user_info['user_id']}") + + # 2. 尝试访客认证 + if not user_info: + guest_id = request.cookies.get("guest_id") + if guest_id and guest_id in self.guest_sessions: + user_info = self.guest_sessions[guest_id] + logger.info(f"访客认证成功 - GuestID: {guest_id}") + else: + # 3. 创建新的访客会话 + guest_id = str(uuid.uuid4()) + user_info = { + "user_id": f"guest_{guest_id}", + "user_type": "guest", + "guest_id": guest_id, + "created_at": datetime.now().isoformat() + } + self.guest_sessions[guest_id] = user_info + logger.info(f"创建新访客会话 - GuestID: {guest_id}") + + # 在响应中设置Cookie + response = await call_next(request) + response.set_cookie( + key="guest_id", + value=guest_id, + max_age=timedelta(days=30).total_seconds(), + httponly=True, + secure=False, # 开发环境设为False,生产环境应为True + samesite="lax" + ) + # 将用户信息添加到请求状态 + request.state.user = user_info + return response + + # 将用户信息添加到请求状态 + request.state.user = user_info + + return await call_next(request) + + def _verify_jwt_token(self, token: str) -> Optional[Dict[str, Any]]: + """验证JWT令牌""" + try: + payload = jwt.decode( + token, + self.jwt_secret, + algorithms=["HS256"] + ) + + # 检查令牌是否过期 + exp = payload.get("exp") + if exp and datetime.fromtimestamp(exp) < datetime.now(): + return None + + return { + "user_id": payload.get("user_id"), + "user_type": "registered", + "username": payload.get("username") + } + except jwt.ExpiredSignatureError: + logger.warning("JWT令牌已过期") + return None + except jwt.InvalidTokenError as e: + logger.warning(f"无效的JWT令牌: {e}") + return None + except Exception as e: + logger.error(f"JWT验证过程中发生错误: {e}") + return None + + @staticmethod + def generate_jwt_token(user_id: str, username: str, + jwt_secret: str = None, expiry_hours: int = 24) -> str: + """生成JWT令牌""" + if not jwt_secret: + jwt_secret = settings.JWT_SECRET + + payload = { + "user_id": user_id, + "username": username, + "iat": int(datetime.now().timestamp()), + "exp": int((datetime.now() + timedelta(hours=expiry_hours)).timestamp()) + } + + return jwt.encode(payload, jwt_secret, algorithm="HS256") + + +def get_current_user(request: Request) -> Dict[str, Any]: + """从请求中获取当前用户信息""" + if hasattr(request.state, "user"): + return request.state.user + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未认证的用户" + ) + + +def get_user_id(request: Request) -> str: + """从请求中获取用户ID""" + user = get_current_user(request) + return user["user_id"] \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/middleware/circuit_breaker.py b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/circuit_breaker.py new file mode 100644 index 00000000..76fab736 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/circuit_breaker.py @@ -0,0 +1,117 @@ +""" +熔断器实现 - 简化版本 +简单的失败计数和快速失败机制 +""" +import time +from typing import Callable, Any +from threading import Lock +from app.observability.logger import default_logger as logger + + +class CircuitBreaker: + """ + 简化的熔断器 + 失败达到阈值后熔断,一段时间后自动恢复 + """ + + def __init__( + self, + failure_threshold: int = 5, # 失败次数阈值 + timeout: float = 60.0 # 熔断后等待时间(秒) + ): + """ + 初始化熔断器 + + Args: + failure_threshold: 失败次数阈值 + timeout: 熔断后等待时间(秒) + """ + self.failure_threshold = failure_threshold + self.timeout = timeout + self.failure_count = 0 + self.last_failure_time = 0.0 + self.is_open = False + self.lock = Lock() + + def call(self, func: Callable, *args, **kwargs) -> Any: + """ + 通过熔断器调用函数 + + Args: + func: 要调用的函数 + *args: 位置参数 + **kwargs: 关键字参数 + + Returns: + 函数返回值 + + Raises: + Exception: 当熔断器打开时抛出异常 + """ + with self.lock: + # 检查是否应该从熔断状态恢复 + if self.is_open: + if time.time() - self.last_failure_time >= self.timeout: + # 超过等待时间,尝试恢复 + self.is_open = False + self.failure_count = 0 + logger.info("熔断器已恢复,允许请求通过") + else: + raise Exception("熔断器已打开,服务暂时不可用") + + # 执行函数 + try: + result = func(*args, **kwargs) + + # 成功后重置计数 + with self.lock: + self.failure_count = 0 + + return result + + except Exception as e: + # 失败时计数 + with self.lock: + self.failure_count += 1 + self.last_failure_time = time.time() + + # 达到阈值,打开熔断器 + if self.failure_count >= self.failure_threshold and not self.is_open: + self.is_open = True + logger.error( + f"失败次数达到阈值({self.failure_threshold}),熔断器已打开", + extra={"failure_count": self.failure_count} + ) + + raise e + + def reset(self): + """重置熔断器""" + with self.lock: + self.is_open = False + self.failure_count = 0 + self.last_failure_time = 0.0 + + def get_state(self) -> str: + """获取当前状态""" + return "open" if self.is_open else "closed" + + +class CircuitBreakerManager: + """熔断器管理器""" + + def __init__(self): + self.breakers: dict[str, CircuitBreaker] = {} + self.lock = Lock() + + def get_breaker(self, name: str, **kwargs) -> CircuitBreaker: + """获取或创建熔断器""" + if name not in self.breakers: + with self.lock: + if name not in self.breakers: + self.breakers[name] = CircuitBreaker(**kwargs) + return self.breakers[name] + + +# 全局熔断器管理器 +circuit_breaker_manager = CircuitBreakerManager() diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/middleware/degradation.py b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/degradation.py new file mode 100644 index 00000000..950c110b --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/degradation.py @@ -0,0 +1,73 @@ +""" +降级策略实现 - 简化版本 +简单的异常捕获和默认值返回 +""" +from typing import Callable, Any +from functools import wraps +from app.observability.logger import default_logger as logger + + +def fallback_response(default_value: Any = None): + """ + 降级装饰器 + 当函数调用失败时,返回默认值 + + Args: + default_value: 降级时的默认返回值 + + Returns: + 装饰器函数 + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.warning( + f"函数 {func.__name__} 调用失败,使用降级方案: {e}" + ) + return default_value + return wrapper + return decorator + + +def circuit_breaker_with_fallback( + breaker_name: str, + fallback_value: Any = None, + failure_threshold: int = 5, + timeout: float = 60.0 +): + """ + 带降级的熔断器装饰器 + 当熔断器打开或调用失败时,返回降级值 + + Args: + breaker_name: 熔断器名称 + fallback_value: 降级时的返回值 + failure_threshold: 失败次数阈值 + timeout: 熔断后等待时间(秒) + + Returns: + 装饰器函数 + """ + from .circuit_breaker import circuit_breaker_manager + + def decorator(func: Callable) -> Callable: + breaker = circuit_breaker_manager.get_breaker( + breaker_name, + failure_threshold=failure_threshold, + timeout=timeout + ) + + @wraps(func) + def wrapper(*args, **kwargs): + try: + return breaker.call(func, *args, **kwargs) + except Exception as e: + logger.warning( + f"熔断器已打开或调用失败,使用降级方案 - 函数: {func.__name__}, 状态: {breaker.get_state()}" + ) + return fallback_value + return wrapper + return decorator diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/middleware/rate_limit.py b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/rate_limit.py new file mode 100644 index 00000000..dbde4b8c --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/rate_limit.py @@ -0,0 +1,127 @@ +""" +限流中间件 - 简化版本 +使用简单的滑动窗口算法进行请求限流 +""" +import time +from typing import Dict +from collections import defaultdict +from threading import Lock +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from app.observability.logger import default_logger as logger, get_request_id + + +class RateLimiter: + """ + 简化的限流器 + 使用滑动窗口算法记录请求时间戳 + """ + + def __init__( + self, + global_rate: int = 100, # 全局每秒请求数 + per_ip_rate: int = 20, # 每个IP每秒请求数 + enabled: bool = True + ): + """ + 初始化限流器 + + Args: + global_rate: 全局每秒最大请求数 + per_ip_rate: 每个IP每秒最大请求数 + enabled: 是否启用限流 + """ + self.enabled = enabled + self.global_rate = global_rate + self.per_ip_rate = per_ip_rate + + # 记录请求时间戳 + self.global_requests: list[float] = [] + self.ip_requests: Dict[str, list[float]] = defaultdict(list) + + self.lock = Lock() + + def _clean_old_requests(self, requests: list[float]) -> list[float]: + """清理超过1秒的旧请求""" + now = time.time() + return [t for t in requests if now - t < 1.0] + + def get_client_ip(self, request: Request) -> str: + """获取客户端IP地址""" + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + return forwarded_for.split(",")[0].strip() + + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip + + if request.client: + return request.client.host + + return "unknown" + + def is_allowed(self, request: Request) -> tuple[bool, str]: + """检查请求是否被允许""" + if not self.enabled: + return True, "" + + with self.lock: + now = time.time() + + # 清理旧的全局请求 + self.global_requests = self._clean_old_requests(self.global_requests) + + # 检查全局限流 + if len(self.global_requests) >= self.global_rate: + request_id = get_request_id() + logger.warning( + f"全局限流触发 - RequestID: {request_id}", + extra={"request_id": request_id, "path": request.url.path} + ) + return False, "请求过于频繁,请稍后再试" + + # 检查IP限流 + client_ip = self.get_client_ip(request) + ip_request_list = self.ip_requests[client_ip] + ip_request_list = self._clean_old_requests(ip_request_list) + + if len(ip_request_list) >= self.per_ip_rate: + request_id = get_request_id() + logger.warning( + f"IP限流触发 - IP: {client_ip}, RequestID: {request_id}", + extra={"request_id": request_id, "ip": client_ip, "path": request.url.path} + ) + return False, "您的请求过于频繁,请稍后再试" + + # 记录此次请求 + self.global_requests.append(now) + ip_request_list.append(now) + self.ip_requests[client_ip] = ip_request_list + + return True, "" + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """限流中间件""" + + def __init__(self, app, rate_limiter: RateLimiter): + super().__init__(app) + self.rate_limiter = rate_limiter + + async def dispatch(self, request: Request, call_next): + """处理请求""" + # 跳过健康检查端点的限流 + if request.url.path == "/health": + return await call_next(request) + + # 检查限流 + allowed, error_message = self.rate_limiter.is_allowed(request) + + if not allowed: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=error_message + ) + + return await call_next(request) diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/middleware/request_id.py b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/request_id.py new file mode 100644 index 00000000..5a506cdd --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/middleware/request_id.py @@ -0,0 +1,41 @@ +""" +请求ID中间件 +为每个请求生成唯一的请求ID,用于日志追踪 +""" +import uuid +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from app.observability.logger import set_request_id, get_request_id + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """ + 请求ID中间件 + 为每个请求生成唯一的请求ID,并添加到响应头中 + """ + + async def dispatch(self, request: Request, call_next): + """ + 处理请求,生成请求ID + + Args: + request: FastAPI请求对象 + call_next: 下一个中间件或路由处理函数 + + Returns: + 响应对象 + """ + # 生成请求ID + request_id = str(uuid.uuid4()) + + # 设置到上下文 + set_request_id(request_id) + + # 处理请求 + response = await call_next(request) + + # 将请求ID添加到响应头 + response.headers["X-Request-ID"] = request_id + + return response + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/models/__init__.py b/Co-creation-projects/275145-TripPlanner/backend/app/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/models/common.py b/Co-creation-projects/275145-TripPlanner/backend/app/models/common.py new file mode 100644 index 00000000..e69de29b diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/models/common_model.py b/Co-creation-projects/275145-TripPlanner/backend/app/models/common_model.py new file mode 100644 index 00000000..3ab51e6a --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/models/common_model.py @@ -0,0 +1,68 @@ +from pydantic import BaseModel, Field +from typing import List + +class Location(BaseModel): + """地理位置模型""" + lat: float = Field(..., description="纬度") + lng: float = Field(..., description="经度") + + +class Attraction(BaseModel): + """ + 景点信息模型(核心模型) + + - 必须字段:景点名称、类型、评分、建议游玩时间、描述、地址、经纬度、景点图片 URL 列表、门票价格 + """ + name: str = Field(..., description="景点名称") + type: str = Field("", description="景点类型,例如:历史文化、公园、博物馆等") + rating: float | str = Field("N/A", description="评分") + suggested_duration_hours: float | None = Field( + default=None, + description="建议游玩时长(单位:小时,例如 2.5 表示约 2.5 小时)", + ) + description: str = Field("", description="景点描述 / 简介") + address: str = Field("", description="地址") + location: Location | None = Field(default=None, description="地理位置坐标") + image_urls: List[str] = Field( + default_factory=list, + description="景点图片 URL 列表(只允许景点图片)", + ) + ticket_price: float | str = Field( + "N/A", + description="景点门票价格(数值或字符串,如“免费”、“100 元”)", + ) + + +class Hotel(BaseModel): + """酒店信息模型""" + name: str = Field(..., description="酒店名称") + address: str = Field("", description="地址") + location: Location | None = Field(default=None, description="地理位置坐标") + price: float | str = Field("N/A", description="价格") + rating: float | str = Field("N/A", description="评分") + distance_to_main_attraction_km: float | None = Field( + default=None, + description="距离主要景点的距离(单位:公里),用于推荐离景点更近的酒店", + ) + +class Dining(BaseModel): + """餐饮信息模型""" + name: str = Field(..., description="餐厅名称") + address: str = Field("", description="地址") + location: Location | None = None + cost_per_person: float | str = Field("N/A", description="人均消费") + rating: float | str = Field("N/A", description="评分") + +class Weather(BaseModel): + """天气信息模型""" + date: str = Field(..., description="日期") + day_weather: str = Field(..., description="白天天气现象") + night_weather: str = Field(..., description="夜间天气现象") + day_temp: str = Field(..., description="白天温度(数值字符串,例如 25)") + night_temp: str = Field(..., description="夜间温度(数值字符串,例如 15)") + day_wind: str | None = Field( + default=None, description="白天风向与风力描述,例如 东风3级" + ) + night_wind: str | None = Field( + default=None, description="夜间风向与风力描述,例如 西北风2级" + ) \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/models/trip.py b/Co-creation-projects/275145-TripPlanner/backend/app/models/trip.py new file mode 100644 index 00000000..e69de29b diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/models/trip_model.py b/Co-creation-projects/275145-TripPlanner/backend/app/models/trip_model.py new file mode 100644 index 00000000..94f51cec --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/models/trip_model.py @@ -0,0 +1,82 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from .common_model import Location, Hotel, Weather, Attraction, Dining + + +# --- API 请求模型 --- + + +class TripPlanRequest(BaseModel): + """行程规划的API请求体""" + destination: str = Field(..., description="目的地城市", example="北京") + start_date: str = Field(..., description="开始日期", example="2024-10-01") + end_date: str = Field(..., description="结束日期", example="2024-10-03") + preferences: List[str] = Field( + default_factory=list, description="旅行偏好", example=["历史", "美食"] + ) + hotel_preferences: List[str] = Field( + default_factory=list, description="酒店偏好", example=["经济型"] + ) + budget: str = Field("中等", description="预算水平(如:经济、适中、豪华)", example="中等") + + +# --- API 响应模型(结构化输出) --- + + +class BudgetBreakdown(BaseModel): + """整体预算拆分""" + transport_cost: float = Field(0.0, description="交通费用") + dining_cost: float = Field(0.0, description="餐饮费用") + hotel_cost: float = Field(0.0, description="酒店费用") + attraction_ticket_cost: float = Field(0.0, description="景点门票费用") + total: float = Field(0.0, description="总预算(四项之和)") + + +class DailyBudget(BaseModel): + """单日预算拆分""" + transport_cost: float = Field(0.0, description="当日交通费用") + dining_cost: float = Field(0.0, description="当日餐饮费用") + hotel_cost: float = Field(0.0, description="当日酒店费用") + attraction_ticket_cost: float = Field(0.0, description="当日景点门票费用") + total: float = Field(0.0, description="当日总预算(四项之和)") + + +class DailyPlan(BaseModel): + """ + 每日行程计划 + + 设计要点: + - **推荐住宿**:recommended_hotel + - **景点列表**:attractions(只在这里挂载景点图片) + - **餐饮列表**:dinings + - **预算**:按天拆分为交通 / 餐饮 / 酒店 / 景点门票费用 + """ + + day: int = Field(..., description="第几天") + theme: str = Field("", description="当日主题") + weather: Optional[Weather] = Field(default=None, description="当日天气信息") + recommended_hotel: Optional[Hotel] = Field( + default=None, description="当日推荐住宿(可为空)" + ) + attractions: List[Attraction] = Field( + default_factory=list, description="当日景点列表" + ) + dinings: List[Dining] = Field( + default_factory=list, description="当日餐饮列表" + ) + budget: DailyBudget = Field( + default_factory=DailyBudget, description="当日预算拆分" + ) + + +class TripPlanResponse(BaseModel): + """行程规划的API响应体""" + + id: Optional[str] = Field(None, description="行程ID") + created_at: Optional[str] = Field(None, description="创建时间") + trip_title: str = Field(..., description="行程标题") + total_budget: BudgetBreakdown = Field( + ..., description="整体预算(包含交通、餐饮、酒店、景点门票费用拆分)" + ) + hotels: List[Hotel] = Field(default_factory=list, description="推荐酒店列表") + days: List[DailyPlan] = Field(..., description="每日计划详情") \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/observability/__init__.py b/Co-creation-projects/275145-TripPlanner/backend/app/observability/__init__.py new file mode 100644 index 00000000..9dad6bcc --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/observability/__init__.py @@ -0,0 +1,5 @@ +""" +可观测性模块 +包含日志、监控、追踪等功能 +""" + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/observability/logger.py b/Co-creation-projects/275145-TripPlanner/backend/app/observability/logger.py new file mode 100644 index 00000000..3f39b080 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/observability/logger.py @@ -0,0 +1,224 @@ +""" +健壮的日志系统 +提供结构化日志、请求ID追踪、日志轮转等功能 +""" +import logging +import logging.handlers +import json +import os +import sys +from datetime import datetime +from typing import Any, Dict, Optional +from pathlib import Path +import traceback +from contextvars import ContextVar + +# 请求ID上下文变量 +request_id_var: ContextVar[Optional[str]] = ContextVar('request_id', default=None) + + +class StructuredFormatter(logging.Formatter): + """ + 结构化日志格式化器 + 将日志输出为JSON格式,便于日志收集和分析 + """ + + def format(self, record: logging.LogRecord) -> str: + """ + 格式化日志记录为JSON格式 + """ + # 获取请求ID + request_id = request_id_var.get() + + # 构建基础日志数据 + log_data: Dict[str, Any] = { + "timestamp": datetime.fromtimestamp(record.created).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "module": record.module, + "function": record.funcName, + "line": record.lineno, + } + + # 添加请求ID(如果存在) + if request_id: + log_data["request_id"] = request_id + + # 添加异常信息(如果存在) + if record.exc_info: + log_data["exception"] = { + "type": record.exc_info[0].__name__ if record.exc_info[0] else None, + "message": str(record.exc_info[1]) if record.exc_info[1] else None, + "traceback": traceback.format_exception(*record.exc_info) if record.exc_info else None + } + + # 添加额外的上下文信息 + if hasattr(record, 'extra_context'): + log_data["context"] = record.extra_context + + # 添加线程和进程信息 + log_data["thread"] = record.thread + log_data["process"] = record.process + + return json.dumps(log_data, ensure_ascii=False) + + +class HumanReadableFormatter(logging.Formatter): + """ + 人类可读的日志格式化器 + 用于控制台输出,格式更友好 + """ + + def format(self, record: logging.LogRecord) -> str: + """ + 格式化日志记录为人类可读格式 + """ + # 获取请求ID + request_id = request_id_var.get() + + # 基础格式 + timestamp = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S") + level = record.levelname.ljust(8) + logger_name = record.name + message = record.getMessage() + + # 构建日志行 + log_line = f"[{timestamp}] {level} [{logger_name}] {message}" + + # 添加请求ID + if request_id: + log_line = f"[{timestamp}] {level} [{logger_name}] [RequestID: {request_id}] {message}" + + # 添加位置信息 + log_line += f" | {record.module}.{record.funcName}:{record.lineno}" + + # 添加异常信息 + if record.exc_info: + log_line += f"\n{self.formatException(record.exc_info)}" + + # 添加额外上下文 + if hasattr(record, 'extra_context'): + context_str = json.dumps(record.extra_context, ensure_ascii=False, indent=2) + log_line += f"\n上下文信息:\n{context_str}" + + return log_line + + +def setup_logger( + name: str = "trip_planner", + log_level: str = "INFO", + log_dir: str = "logs", + enable_file_logging: bool = True, + enable_console_logging: bool = True, + max_bytes: int = 10 * 1024 * 1024, # 10MB + backup_count: int = 5 +) -> logging.Logger: + """ + 设置并配置日志记录器 + + Args: + name: 日志记录器名称 + log_level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL) + log_dir: 日志文件目录 + enable_file_logging: 是否启用文件日志 + enable_console_logging: 是否启用控制台日志 + max_bytes: 单个日志文件最大字节数 + backup_count: 保留的备份文件数量 + + Returns: + 配置好的日志记录器 + """ + logger = logging.getLogger(name) + logger.setLevel(getattr(logging, log_level.upper(), logging.INFO)) + + # 避免重复添加处理器 + if logger.handlers: + return logger + + # 创建日志目录 + if enable_file_logging: + log_path = Path(log_dir) + log_path.mkdir(parents=True, exist_ok=True) + + # 控制台处理器(人类可读格式) + if enable_console_logging: + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(logging.DEBUG) + console_formatter = HumanReadableFormatter() + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + + # 文件处理器 - 所有日志(JSON格式) + if enable_file_logging: + all_log_file = log_path / "app.log" + file_handler = logging.handlers.RotatingFileHandler( + all_log_file, + maxBytes=max_bytes, + backupCount=backup_count, + encoding='utf-8' + ) + file_handler.setLevel(logging.DEBUG) + file_formatter = StructuredFormatter() + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + + # 错误日志文件 - 只记录ERROR及以上级别 + if enable_file_logging: + error_log_file = log_path / "error.log" + error_handler = logging.handlers.RotatingFileHandler( + error_log_file, + maxBytes=max_bytes, + backupCount=backup_count, + encoding='utf-8' + ) + error_handler.setLevel(logging.ERROR) + error_formatter = StructuredFormatter() + error_handler.setFormatter(error_formatter) + logger.addHandler(error_handler) + + return logger + + +def set_request_id(request_id: str) -> None: + """ + 设置当前请求的ID + + Args: + request_id: 请求ID + """ + request_id_var.set(request_id) + + +def get_request_id() -> Optional[str]: + """ + 获取当前请求的ID + + Returns: + 请求ID,如果不存在则返回None + """ + return request_id_var.get() + + +def log_with_context( + logger: logging.Logger, + level: int, + message: str, + **context +) -> None: + """ + 记录带上下文的日志 + + Args: + logger: 日志记录器 + level: 日志级别 + message: 日志消息 + **context: 额外的上下文信息 + """ + extra = {'extra_context': context} + logger.log(level, message, extra=extra) + + +# 创建默认的日志记录器实例 +default_logger = setup_logger() + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/services/__init__.py b/Co-creation-projects/275145-TripPlanner/backend/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/services/context_manager.py b/Co-creation-projects/275145-TripPlanner/backend/app/services/context_manager.py new file mode 100644 index 00000000..bfe297b2 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/services/context_manager.py @@ -0,0 +1,228 @@ +""" +上下文管理器 +管理智能体之间的上下文共享和传递 +""" +from typing import Dict, List, Optional, Any +from datetime import datetime +from app.observability.logger import default_logger as logger + + +class ContextManager: + """ + 上下文管理器 + 管理请求的上下文信息,支持在智能体间共享和传递 + """ + + def __init__(self, request_id: str): + """ + 初始化上下文管理器 + + Args: + request_id: 请求ID + """ + self.request_id = request_id + self.context: Dict[str, Any] = { + "request_id": request_id, + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + "version": 1, + "history": [], + "agent_contexts": {}, + "shared_data": {} + } + logger.info(f"上下文管理器初始化 - RequestID: {request_id}") + + def update_context( + self, + agent_name: str, + context_data: Dict[str, Any], + context_type: str = "info" + ): + """ + 更新上下文 + + Args: + agent_name: 智能体名称 + context_data: 上下文数据 + context_type: 上下文类型(info, result, error, feedback等) + """ + if agent_name not in self.context["agent_contexts"]: + self.context["agent_contexts"][agent_name] = { + "created_at": datetime.now().isoformat(), + "updates": [] + } + + update_entry = { + "timestamp": datetime.now().isoformat(), + "type": context_type, + "data": context_data + } + + self.context["agent_contexts"][agent_name]["updates"].append(update_entry) + self.context["agent_contexts"][agent_name]["last_updated"] = datetime.now().isoformat() + self.context["updated_at"] = datetime.now().isoformat() + self.context["version"] += 1 + + # 记录历史 + self.context["history"].append({ + "timestamp": datetime.now().isoformat(), + "agent": agent_name, + "action": "update_context", + "type": context_type + }) + + logger.debug(f"上下文已更新 - Agent: {agent_name}, Type: {context_type}") + + def get_agent_context(self, agent_name: str) -> Dict[str, Any]: + """ + 获取指定智能体的上下文 + + Args: + agent_name: 智能体名称 + + Returns: + 智能体上下文数据 + """ + return self.context["agent_contexts"].get(agent_name, {}) + + def get_all_context(self) -> Dict[str, Any]: + """ + 获取所有上下文 + + Returns: + 完整上下文数据 + """ + return self.context + + def share_data( + self, + key: str, + data: Any, + from_agent: Optional[str] = None + ): + """ + 共享数据给其他智能体 + + Args: + key: 数据键 + data: 数据内容 + from_agent: 来源智能体名称 + """ + self.context["shared_data"][key] = { + "data": data, + "from_agent": from_agent, + "timestamp": datetime.now().isoformat() + } + + self.context["updated_at"] = datetime.now().isoformat() + + logger.debug(f"数据已共享 - Key: {key}, From: {from_agent}") + + def get_shared_data(self, key: str) -> Optional[Any]: + """ + 获取共享数据 + + Args: + key: 数据键 + + Returns: + 数据内容,如果不存在则返回None + """ + shared_item = self.context["shared_data"].get(key) + if shared_item: + return shared_item["data"] + return None + + def get_all_shared_data(self) -> Dict[str, Any]: + """ + 获取所有共享数据 + + Returns: + 所有共享数据 + """ + return {k: v["data"] for k, v in self.context["shared_data"].items()} + + def add_memory_context( + self, + memory_type: str, + memory_data: Dict[str, Any] + ): + """ + 添加上下文中的记忆信息 + + Args: + memory_type: 记忆类型 + memory_data: 记忆数据 + """ + if "memory_context" not in self.context: + self.context["memory_context"] = {} + + self.context["memory_context"][memory_type] = memory_data + self.context["updated_at"] = datetime.now().isoformat() + + def get_memory_context(self) -> Dict[str, Any]: + """ + 获取记忆上下文 + + Returns: + 记忆上下文数据 + """ + return self.context.get("memory_context", {}) + + def create_snapshot(self) -> Dict[str, Any]: + """ + 创建上下文快照(用于回溯) + + Returns: + 上下文快照 + """ + snapshot = { + "snapshot_id": f"{self.request_id}_snapshot_{self.context['version']}", + "timestamp": datetime.now().isoformat(), + "context": self.context.copy() + } + return snapshot + + def restore_from_snapshot(self, snapshot: Dict[str, Any]): + """ + 从快照恢复上下文 + + Args: + snapshot: 上下文快照 + """ + if "context" in snapshot: + self.context = snapshot["context"].copy() + self.context["updated_at"] = datetime.now().isoformat() + logger.info(f"上下文已从快照恢复 - SnapshotID: {snapshot.get('snapshot_id')}") + + +# 全局上下文管理器存储(按请求ID) +_context_managers: Dict[str, ContextManager] = {} + + +def get_context_manager(request_id: str) -> ContextManager: + """ + 获取或创建上下文管理器 + + Args: + request_id: 请求ID + + Returns: + 上下文管理器实例 + """ + if request_id not in _context_managers: + _context_managers[request_id] = ContextManager(request_id) + return _context_managers[request_id] + + +def remove_context_manager(request_id: str): + """ + 移除上下文管理器(请求完成后清理) + + Args: + request_id: 请求ID + """ + if request_id in _context_managers: + del _context_managers[request_id] + logger.debug(f"上下文管理器已移除 - RequestID: {request_id}") + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/services/llm_service.py b/Co-creation-projects/275145-TripPlanner/backend/app/services/llm_service.py new file mode 100644 index 00000000..142b60f5 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/services/llm_service.py @@ -0,0 +1,178 @@ +import os +from openai import OpenAI +from typing import Literal +from ..config import settings +from ..observability.logger import default_logger as logger +from typing import Literal, Optional, Iterator +Provider = Literal["openai", "zhipu", "modelscope", "ollama", "vllm", "custom"] + +class LLMService: + """ + 一个智能的、支持多服务商的LLM服务。 + 它能根据环境变量自动检测并配置LLM提供商。 + """ + def __init__(self, + temperature: float = 0.7, + max_tokens: int = 4096, + timeout: Optional[int] = None, + **kwargs): + """ + 初始化服务,自动检测并配置客户端。 + """ + self.provider: Provider = "custom" + self.api_key: str | None = None + self.base_url: str | None = None + self.model: str | None = None + + + self.temperature = temperature + self.max_tokens = max_tokens + self.timeout = timeout or int(os.getenv("LLM_TIMEOUT", "60")) + self.kwargs = kwargs + # 核心逻辑:自动检测和解析凭证 + self._auto_detect_provider() + self._resolve_credentials() + + if not self.api_key: + logger.warning("LLM API Key 未配置,LLM服务可能无法正常工作。") + + # 初始化OpenAI客户端(兼容多种服务) + self.client = OpenAI( + api_key=self.api_key, + base_url=self.base_url, + timeout=settings.LLM_TIMEOUT + ) + logger.info(f"LLM服务初始化完成。Provider: {self.provider}, Model: {self.model}, Base URL: {self.base_url}") + + def _auto_detect_provider(self): + """ + 根据环境变量自动推断LLM服务商。 + """ + # 最高优先级:根据 base_url 进行判断 + base_url = settings.LLM_BASE_URL + if base_url: + if "api.openai.com" in base_url: self.provider = "openai"; return + if "open.bigmodel.cn" in base_url: self.provider = "zhipu"; return + if "api-inference.modelscope.cn" in base_url: self.provider = "modelscope"; return + if ":11434" in base_url: self.provider = "ollama"; return + if ":8000" in base_url: self.provider = "vllm"; return # 假设vllm在8000端口 + + # 次高优先级:检查特定服务商的环境变量 + if settings.OPENAI_API_KEY: self.provider = "openai"; return + if settings.ZHIPU_API_KEY: self.provider = "zhipu"; return + if settings.MODELSCOPE_API_KEY: self.provider = "modelscope"; return + + # 辅助判断:分析通用API密钥格式 (示例) + api_key = settings.LLM_API_KEY + if api_key: + if api_key.startswith("sk-"): self.provider = "openai"; return + # Zhipu和ModelScope的key格式不如此独特,此处省略以避免误判 + + logger.info("未能自动检测到特定的LLM Provider,将使用 'custom' 模式。") + + def _resolve_credentials(self): + """ + 根据检测到的服务商,解析并设置最终的api_key, base_url, 和 model。 + """ + # 1. 解析API Key + provider_key = getattr(settings, f"{self.provider.upper()}_API_KEY", None) + self.api_key = provider_key or settings.LLM_API_KEY + + # 2. 解析Base URL + if self.provider == "openai" and not settings.LLM_BASE_URL: + self.base_url = "https://api.openai.com/v1" + elif self.provider == "zhipu" and not settings.LLM_BASE_URL: + self.base_url = "https://open.bigmodel.cn/api/paas/v4/" + elif self.provider == "modelscope" and not settings.LLM_BASE_URL: + self.base_url = "https://api-inference.modelscope.cn/v1" + else: + self.base_url = settings.LLM_BASE_URL + + # 3. 解析模型ID + self.model = settings.LLM_MODEL_ID or "gpt-4-turbo" # 提供一个默认值 + + def generate_json_plan(self, prompt: str) -> str: + """ + 调用LLM生成JSON格式的行程计划。此方法保持接口不变。 + """ + if not self.api_key: + logger.error("LLM API Key 未配置,无法发起请求。") + return "" + + logger.info(f"向LLM ({self.provider}) 发起行程规划请求...") + try: + response = self.client.chat.completions.create( + model=self.model, # 使用解析后的模型 + messages=[ + {"role": "system", "content": "You are a helpful travel planner. You will output a travel plan in JSON format based on user requirements."}, + {"role": "user", "content": prompt} + ], + response_format={"type": "json_object"} + ) + json_output = response.choices[0].message.content + logger.info("成功从LLM获取到行程规划JSON数据。") + return json_output or "" + except Exception as e: + logger.error(f"调用LLM API时发生错误: {e}", exc_info=True) + return "" + def think(self, messages: list[dict[str, str]], temperature: Optional[float] = None) -> Iterator[str]: + """ + 调用大语言模型进行思考,并返回流式响应。 + 这是主要的调用方法,默认使用流式响应以获得更好的用户体验。 + + Args: + messages: 消息列表 + temperature: 温度参数,如果未提供则使用初始化时的值 + + Yields: + str: 流式响应的文本片段 + """ + print(f"🧠 正在调用 {self.model} 模型...") + try: + response = self.client.chat.completions.create( + model=self.model, + messages=messages, + temperature=temperature if temperature is not None else self.temperature, + max_tokens=self.max_tokens, + stream=True, + ) + + # 处理流式响应 + print("✅ 大语言模型响应成功:") + for chunk in response: + content = chunk.choices[0].delta.content or "" + if content: + print(content, end="", flush=True) + yield content + print() # 在流式输出结束后换行 + + except Exception as e: + print(f"❌ 调用LLM API时发生错误: {e}") + raise Exception(f"LLM调用失败: {str(e)}") + + def invoke(self, messages: list[dict[str, str]], **kwargs) -> str: + """ + 非流式调用LLM,返回完整响应。 + 适用于不需要流式输出的场景。 + """ + try: + response = self.client.chat.completions.create( + model=self.model, + messages=messages, + temperature=kwargs.get('temperature', self.temperature), + max_tokens=kwargs.get('max_tokens', self.max_tokens), + **{k: v for k, v in kwargs.items() if k not in ['temperature', 'max_tokens']} + ) + return response.choices[0].message.content + except Exception as e: + raise Exception(f"LLM调用失败: {str(e)}") + + def stream_invoke(self, messages: list[dict[str, str]], **kwargs) -> Iterator[str]: + """ + 流式调用LLM的别名方法,与think方法功能相同。 + 保持向后兼容性。 + """ + temperature = kwargs.get('temperature') + yield from self.think(messages, temperature) +# 创建一个服务实例,FastAPI的依赖注入系统将使用它 +llm_service = LLMService() \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/services/memory_service.py b/Co-creation-projects/275145-TripPlanner/backend/app/services/memory_service.py new file mode 100644 index 00000000..dae66bc3 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/services/memory_service.py @@ -0,0 +1,460 @@ +""" +记忆服务模块 +提供用户记忆、知识记忆的存储、检索和更新功能 +""" +import json +import os +from typing import Dict, List, Optional, Any +from datetime import datetime, timedelta +from pathlib import Path +from app.observability.logger import default_logger as logger + + +class MemoryService: + """ + 记忆服务 + 管理用户记忆和知识记忆 + """ + + def __init__(self, memory_dir: str = "memory"): + """ + 初始化记忆服务 + + Args: + memory_dir: 记忆数据存储目录 + """ + self.memory_dir = Path(memory_dir) + self.memory_dir.mkdir(parents=True, exist_ok=True) + + # 用户记忆文件 + self.user_memory_file = self.memory_dir / "user_memory.json" + # 知识记忆文件 + self.knowledge_memory_file = self.memory_dir / "knowledge_memory.json" + + # 加载记忆 + self.user_memory = self._load_user_memory() + self.knowledge_memory = self._load_knowledge_memory() + + logger.info("记忆服务初始化完成") + + def _load_user_memory(self) -> Dict[str, Any]: + """加载用户记忆""" + if self.user_memory_file.exists(): + try: + with open(self.user_memory_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"加载用户记忆失败: {e}") + return {} + return {} + + def _load_knowledge_memory(self) -> Dict[str, Any]: + """加载知识记忆""" + if self.knowledge_memory_file.exists(): + try: + with open(self.knowledge_memory_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"加载知识记忆失败: {e}") + return {} + return {} + + def _save_user_memory(self): + """保存用户记忆""" + try: + with open(self.user_memory_file, 'w', encoding='utf-8') as f: + json.dump(self.user_memory, f, ensure_ascii=False, indent=2) + except Exception as e: + logger.error(f"保存用户记忆失败: {e}") + + def _save_knowledge_memory(self): + """保存知识记忆""" + try: + with open(self.knowledge_memory_file, 'w', encoding='utf-8') as f: + json.dump(self.knowledge_memory, f, ensure_ascii=False, indent=2) + except Exception as e: + logger.error(f"保存知识记忆失败: {e}") + + # ============ 用户记忆操作 ============ + + def store_user_preference( + self, + user_id: str, + preference_type: str, + preference_data: Dict[str, Any] + ): + """ + 存储用户偏好 + + Args: + user_id: 用户ID(可以是session_id或真实用户ID) + preference_type: 偏好类型(如:destination_preference, budget_preference等) + preference_data: 偏好数据 + """ + if user_id not in self.user_memory: + self.user_memory[user_id] = { + "long_term_memory": {}, + "short_term_memory": {}, + "meta_memory": { + "created_at": datetime.now().isoformat(), + "last_updated": datetime.now().isoformat() + } + } + + if "long_term_memory" not in self.user_memory[user_id]: + self.user_memory[user_id]["long_term_memory"] = {} + + if preference_type not in self.user_memory[user_id]["long_term_memory"]: + self.user_memory[user_id]["long_term_memory"][preference_type] = [] + + # 添加时间戳 + preference_data["timestamp"] = datetime.now().isoformat() + self.user_memory[user_id]["long_term_memory"][preference_type].append(preference_data) + + # 更新元记忆 + self.user_memory[user_id]["meta_memory"]["last_updated"] = datetime.now().isoformat() + + self._save_user_memory() + logger.info(f"已存储用户偏好 - UserID: {user_id}, Type: {preference_type}") + + def store_user_feedback( + self, + user_id: str, + trip_id: str, + feedback_data: Dict[str, Any] + ): + """ + 存储用户反馈 + + Args: + user_id: 用户ID + trip_id: 行程ID + feedback_data: 反馈数据(包含rating, comments, modifications等) + """ + if user_id not in self.user_memory: + self.user_memory[user_id] = { + "long_term_memory": {}, + "short_term_memory": {}, + "meta_memory": {} + } + + if "feedback_history" not in self.user_memory[user_id]["long_term_memory"]: + self.user_memory[user_id]["long_term_memory"]["feedback_history"] = [] + + feedback_entry = { + "trip_id": trip_id, + "timestamp": datetime.now().isoformat(), + **feedback_data + } + self.user_memory[user_id]["long_term_memory"]["feedback_history"].append(feedback_entry) + + self._save_user_memory() + logger.info(f"已存储用户反馈 - UserID: {user_id}, TripID: {trip_id}") + + def store_short_term_context( + self, + user_id: str, + context_key: str, + context_data: Any + ): + """ + 存储短期上下文 + + Args: + user_id: 用户ID + context_key: 上下文键 + context_data: 上下文数据 + """ + if user_id not in self.user_memory: + self.user_memory[user_id] = { + "long_term_memory": {}, + "short_term_memory": {}, + "meta_memory": {} + } + + self.user_memory[user_id]["short_term_memory"][context_key] = { + "data": context_data, + "timestamp": datetime.now().isoformat() + } + + self._save_user_memory() + + def retrieve_user_preferences( + self, + user_id: str, + preference_type: Optional[str] = None + ) -> Dict[str, Any]: + """ + 检索用户偏好 + + Args: + user_id: 用户ID + preference_type: 偏好类型,如果为None则返回所有偏好 + + Returns: + 用户偏好数据 + """ + if user_id not in self.user_memory: + return {} + + long_term = self.user_memory[user_id].get("long_term_memory", {}) + + if preference_type: + return long_term.get(preference_type, []) + else: + return long_term + + def retrieve_user_feedback( + self, + user_id: str, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + 检索用户反馈历史 + + Args: + user_id: 用户ID + limit: 返回数量限制 + + Returns: + 反馈历史列表 + """ + if user_id not in self.user_memory: + return [] + + feedback_history = self.user_memory[user_id].get("long_term_memory", {}).get("feedback_history", []) + return feedback_history[-limit:] + + def retrieve_short_term_context( + self, + user_id: str, + context_key: Optional[str] = None + ) -> Dict[str, Any]: + """ + 检索短期上下文 + + Args: + user_id: 用户ID + context_key: 上下文键,如果为None则返回所有上下文 + + Returns: + 上下文数据 + """ + if user_id not in self.user_memory: + return {} + + short_term = self.user_memory[user_id].get("short_term_memory", {}) + + if context_key: + return short_term.get(context_key, {}) + else: + return short_term + + def retrieve_similar_trips( + self, + destination: str, + preferences: List[str], + limit: int = 5 + ) -> List[Dict[str, Any]]: + """ + 检索相似的历史行程 + + Args: + destination: 目的地 + preferences: 偏好列表 + limit: 返回数量限制 + + Returns: + 相似行程列表 + """ + similar_trips = [] + + for user_id, user_data in self.user_memory.items(): + feedback_history = user_data.get("long_term_memory", {}).get("feedback_history", []) + + for feedback in feedback_history: + trip_data = feedback.get("trip_data", {}) + if not trip_data: + continue + + # 简单的相似度计算 + trip_destination = trip_data.get("destination", "") + trip_preferences = trip_data.get("preferences", []) + + # 目的地匹配 + if destination.lower() not in trip_destination.lower(): + continue + + # 偏好匹配度 + preference_match = len(set(preferences) & set(trip_preferences)) + + if preference_match > 0: + similar_trips.append({ + "trip_data": trip_data, + "feedback": feedback, + "match_score": preference_match, + "user_id": user_id + }) + + # 按匹配度排序 + similar_trips.sort(key=lambda x: x["match_score"], reverse=True) + return similar_trips[:limit] + + # ============ 知识记忆操作 ============ + + def store_destination_knowledge( + self, + destination: str, + knowledge_data: Dict[str, Any] + ): + """ + 存储目的地知识 + + Args: + destination: 目的地名称 + knowledge_data: 知识数据(包含特色、季节特点、文化背景等) + """ + if "destinations" not in self.knowledge_memory: + self.knowledge_memory["destinations"] = {} + + if destination not in self.knowledge_memory["destinations"]: + self.knowledge_memory["destinations"][destination] = { + "created_at": datetime.now().isoformat(), + "knowledge": [] + } + + knowledge_entry = { + "timestamp": datetime.now().isoformat(), + **knowledge_data + } + self.knowledge_memory["destinations"][destination]["knowledge"].append(knowledge_entry) + self.knowledge_memory["destinations"][destination]["last_updated"] = datetime.now().isoformat() + + self._save_knowledge_memory() + logger.info(f"已存储目的地知识 - Destination: {destination}") + + def retrieve_destination_knowledge( + self, + destination: str + ) -> Dict[str, Any]: + """ + 检索目的地知识 + + Args: + destination: 目的地名称 + + Returns: + 目的地知识数据 + """ + destinations = self.knowledge_memory.get("destinations", {}) + return destinations.get(destination, {}) + + def store_experience( + self, + experience_type: str, + experience_data: Dict[str, Any] + ): + """ + 存储经验(成功案例、失败教训等) + + Args: + experience_type: 经验类型(success_case, failure_lesson, optimization_strategy等) + experience_data: 经验数据 + """ + if "experiences" not in self.knowledge_memory: + self.knowledge_memory["experiences"] = {} + + if experience_type not in self.knowledge_memory["experiences"]: + self.knowledge_memory["experiences"][experience_type] = [] + + experience_entry = { + "timestamp": datetime.now().isoformat(), + **experience_data + } + self.knowledge_memory["experiences"][experience_type].append(experience_entry) + + self._save_knowledge_memory() + logger.info(f"已存储经验 - Type: {experience_type}") + + def retrieve_experiences( + self, + experience_type: Optional[str] = None, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + 检索经验 + + Args: + experience_type: 经验类型,如果为None则返回所有类型 + limit: 返回数量限制 + + Returns: + 经验列表 + """ + experiences = self.knowledge_memory.get("experiences", {}) + + if experience_type: + return experiences.get(experience_type, [])[-limit:] + else: + all_experiences = [] + for exp_type, exp_list in experiences.items(): + all_experiences.extend(exp_list) + all_experiences.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + return all_experiences[:limit] + + # ============ 综合检索 ============ + + def search_memory( + self, + query: str, + user_id: Optional[str] = None, + memory_types: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + 综合搜索记忆 + + Args: + query: 搜索查询 + user_id: 用户ID(可选) + memory_types: 记忆类型列表(可选) + + Returns: + 搜索结果 + """ + results = { + "user_memory": {}, + "knowledge_memory": {} + } + + # 搜索用户记忆 + if user_id and user_id in self.user_memory: + user_data = self.user_memory[user_id] + + # 搜索长期记忆 + long_term = user_data.get("long_term_memory", {}) + for key, value in long_term.items(): + if isinstance(value, list): + for item in value: + if query.lower() in str(item).lower(): + if key not in results["user_memory"]: + results["user_memory"][key] = [] + results["user_memory"][key].append(item) + + # 搜索短期记忆 + short_term = user_data.get("short_term_memory", {}) + for key, value in short_term.items(): + if query.lower() in str(value).lower(): + results["user_memory"][f"short_term_{key}"] = value + + # 搜索知识记忆 + destinations = self.knowledge_memory.get("destinations", {}) + for dest, data in destinations.items(): + if query.lower() in dest.lower(): + results["knowledge_memory"][dest] = data + + return results + + +# 创建全局记忆服务实例 +memory_service = MemoryService() + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/services/redis_service.py b/Co-creation-projects/275145-TripPlanner/backend/app/services/redis_service.py new file mode 100644 index 00000000..3fdd6c3a --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/services/redis_service.py @@ -0,0 +1,579 @@ +""" +Redis服务模块 +提供用户数据的持久化存储 +""" +import json +import hashlib +from typing import Dict, Optional, Any, List +from contextlib import contextmanager +import redis +import bcrypt +from app.observability.logger import default_logger as logger +from app.config import settings +import datetime + + +# 密码加密轮数 +BCRYPT_ROUNDS = 12 + + +class RedisService: + """ + Redis服务类,负责用户数据的持久化存储 + """ + + def __init__(self): + """初始化Redis连接""" + self._redis_client: Optional[redis.Redis] = None + self._initialize_redis() + + def _initialize_redis(self): + """初始化Redis连接""" + try: + self._redis_client = redis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DB, + password=settings.REDIS_PASSWORD, + decode_responses=settings.REDIS_DECODE_RESPONSES, + socket_connect_timeout=5, + socket_timeout=5, + retry_on_timeout=True + ) + # 测试连接 + self._redis_client.ping() + logger.info(f"Redis连接成功 - {settings.REDIS_HOST}:{settings.REDIS_PORT}") + except Exception as e: + logger.error(f"Redis连接失败: {str(e)}") + raise RuntimeError(f"无法连接到Redis服务器: {str(e)}") + + @property + def redis(self) -> redis.Redis: + """获取Redis客户端实例""" + if self._redis_client is None: + raise RuntimeError("Redis客户端未初始化") + return self._redis_client + + def _generate_user_key(self, username: str) -> str: + """生成用户数据的Redis键""" + return f"user:{username}" + + def _generate_username_index_key(self, user_id: str) -> str: + """生成用户名索引的Redis键""" + return f"user_index:{user_id}" + + def _generate_trip_key(self, trip_id: str) -> str: + """生成行程数据的Redis键""" + return f"trip:{trip_id}" + + def _generate_user_trips_list_key(self, user_id: str) -> str: + """生成用户行程列表的Redis键""" + return f"user_trips:{user_id}" + + def _hash_password(self, password: str) -> str: + """ + 使用bcrypt加密密码 + + 注意:bcrypt算法有72字节的密码长度限制 + 对于长密码,先使用SHA256哈希再进行bcrypt加密 + + Args: + password: 明文密码 + + Returns: + 加密后的密码哈希 + """ + # 处理长密码:如果超过72字节,先使用SHA256哈希 + password_bytes = password.encode('utf-8') + + if len(password_bytes) > 72: + logger.info(f"密码长度超过72字节,使用SHA256预处理") + # 对于长密码,使用SHA256哈希后再加密 + sha256_hash = hashlib.sha256(password_bytes).digest() + # 取前72字节 + password_bytes = sha256_hash[:72] + + # 使用bcrypt直接加密(避免passlib的兼容性问题) + salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS) + hashed = bcrypt.hashpw(password_bytes, salt) + + return hashed.decode('utf-8') + + def _verify_password(self, plain_password: str, hashed_password: str) -> bool: + """ + 验证密码 + + Args: + plain_password: 明文密码 + hashed_password: 加密后的密码哈希 + + Returns: + 密码是否匹配 + """ + try: + # 处理密码编码 + plain_password_bytes = plain_password.encode('utf-8') + hashed_password_bytes = hashed_password.encode('utf-8') + + # 使用bcrypt直接验证 + result = bcrypt.checkpw(plain_password_bytes, hashed_password_bytes) + return result + except Exception as e: + logger.error(f"密码验证失败: {str(e)}") + return False + + def create_user( + self, + user_id: str, + username: str, + password: str, + phone: Optional[str] = None, + gender: str = "other", + birthday: Optional[str] = None, + bio: Optional[str] = None, + travel_preferences: list = None, + avatar_url: Optional[str] = None + ) -> Dict[str, Any]: + """ + 创建新用户 + + Args: + user_id: 用户ID + username: 用户名 + password: 明文密码 + phone: 手机号 + gender: 性别 + birthday: 生日 + bio: 个人简介 + travel_preferences: 旅行偏好 + avatar_url: 头像URL + + Returns: + 创建的用户数据 + + Raises: + ValueError: 用户名已存在 + """ + user_key = self._generate_user_key(username) + + # 检查用户名是否已存在 + if self.redis.exists(user_key): + raise ValueError(f"用户名 '{username}' 已存在") + + # 加密密码 + hashed_password = self._hash_password(password) + + # 构建用户数据 + # 注意:Redis的hset只支持str, int, float, bytes类型 + # 所有字段必须是这些类型之一 + user_data = { + "user_id": str(user_id), + "username": str(username), + "password": str(hashed_password), + "phone": str(phone) if phone is not None else "", + "gender": str(gender), + "birthday": str(birthday) if birthday is not None else "", + "bio": str(bio) if bio is not None else "", + "travel_preferences": json.dumps(travel_preferences or []), # 列表转为JSON字符串 + "avatar_url": str(avatar_url) if avatar_url is not None else "", + "created_at": "" + } + + # 保存用户数据 + try: + self.redis.hset(user_key, mapping=user_data) + # 创建用户名到ID的索引 + self.redis.set(self._generate_username_index_key(user_id), username) + logger.info(f"用户创建成功 - Username: {username}, UserID: {user_id}") + return user_data + except Exception as e: + logger.error(f"用户创建失败: {str(e)}") + raise RuntimeError(f"用户创建失败: {str(e)}") + + def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]: + """ + 根据用户名获取用户数据 + + Args: + username: 用户名 + + Returns: + 用户数据,如果不存在则返回None + """ + try: + user_key = self._generate_user_key(username) + user_data = self.redis.hgetall(user_key) + + if not user_data: + return None + + # 处理travel_preferences字段(JSON数组) + if "travel_preferences" in user_data: + try: + user_data["travel_preferences"] = json.loads(user_data["travel_preferences"]) + except (json.JSONDecodeError, TypeError): + user_data["travel_preferences"] = [] + + return user_data + except Exception as e: + logger.error(f"获取用户数据失败: {str(e)}") + return None + + def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]: + """ + 根据用户ID获取用户数据 + + Args: + user_id: 用户ID + + Returns: + 用户数据,如果不存在则返回None + """ + try: + # 通过索引查找用户名 + username = self.redis.get(self._generate_username_index_key(user_id)) + if not username: + return None + + return self.get_user_by_username(username) + except Exception as e: + logger.error(f"通过ID获取用户数据失败: {str(e)}") + return None + + def verify_user(self, username: str, password: str) -> Optional[Dict[str, Any]]: + """ + 验证用户登录凭证 + + Args: + username: 用户名 + password: 明文密码 + + Returns: + 验证成功返回用户数据,失败返回None + """ + user_data = self.get_user_by_username(username) + + if not user_data: + logger.warning(f"登录失败 - 用户不存在: {username}") + return None + + hashed_password = user_data.get("password") + if not hashed_password: + logger.error(f"用户数据异常 - 没有密码哈希: {username}") + return None + + if not self._verify_password(password, hashed_password): + logger.warning(f"登录失败 - 密码错误: {username}") + return None + + logger.info(f"用户验证成功 - Username: {username}") + return user_data + + def update_user( + self, + username: str, + **updates + ) -> Optional[Dict[str, Any]]: + """ + 更新用户数据(密码除外) + + Args: + username: 用户名 + **updates: 要更新的字段 + + Returns: + 更新后的用户数据 + + Raises: + ValueError: 用户不存在 + """ + user_key = self._generate_user_key(username) + + # 检查用户是否存在 + if not self.redis.exists(user_key): + raise ValueError(f"用户 '{username}' 不存在") + + # 过滤不允许更新的字段 + updates.pop("user_id", None) + updates.pop("username", None) + updates.pop("password", None) + updates.pop("created_at", None) + + # 处理travel_preferences字段 + if "travel_preferences" in updates: + updates["travel_preferences"] = json.dumps(updates["travel_preferences"]) + + # 确保所有值都是字符串类型(Redis hset只支持str, int, float, bytes) + clean_updates = {} + for key, value in updates.items(): + if value is not None: + clean_updates[key] = str(value) + else: + clean_updates[key] = "" + + try: + # 更新用户数据 + if clean_updates: + self.redis.hset(user_key, mapping=clean_updates) + + # 返回更新后的用户数据 + updated_user = self.get_user_by_username(username) + logger.info(f"用户数据更新成功 - Username: {username}") + return updated_user + except Exception as e: + logger.error(f"用户数据更新失败: {str(e)}") + raise RuntimeError(f"用户数据更新失败: {str(e)}") + + def update_password(self, username: str, old_password: str, new_password: str) -> bool: + """ + 更新用户密码 + + Args: + username: 用户名 + old_password: 原密码 + new_password: 新密码 + + Returns: + 是否更新成功 + + Raises: + ValueError: 用户不存在或原密码错误 + """ + # 验证原密码 + user_data = self.verify_user(username, old_password) + if not user_data: + raise ValueError("用户不存在或原密码错误") + + # 加密新密码 + hashed_password = self._hash_password(new_password) + + try: + user_key = self._generate_user_key(username) + self.redis.hset(user_key, "password", hashed_password) + logger.info(f"用户密码更新成功 - Username: {username}") + return True + except Exception as e: + logger.error(f"用户密码更新失败: {str(e)}") + raise RuntimeError(f"用户密码更新失败: {str(e)}") + + def delete_user(self, username: str) -> bool: + """ + 删除用户 + + Args: + username: 用户名 + + Returns: + 是否删除成功 + """ + try: + user_data = self.get_user_by_username(username) + if not user_data: + return False + + user_id = user_data["user_id"] + user_key = self._generate_user_key(username) + + # 删除用户数据和索引 + self.redis.delete(user_key) + self.redis.delete(self._generate_username_index_key(user_id)) + + logger.info(f"用户删除成功 - Username: {username}") + return True + except Exception as e: + logger.error(f"用户删除失败: {str(e)}") + return False + + def check_username_exists(self, username: str) -> bool: + """ + 检查用户名是否存在 + + Args: + username: 用户名 + + Returns: + 用户名是否存在 + """ + try: + user_key = self._generate_user_key(username) + return self.redis.exists(user_key) > 0 + except Exception as e: + logger.error(f"检查用户名存在性失败: {str(e)}") + return False + + def get_all_usernames(self) -> list: + """ + 获取所有用户名列表 + + Returns: + 用户名列表 + """ + try: + # 使用SCAN遍历所有用户键 + keys = [] + for key in self.redis.scan_iter(match="user:*"): + keys.append(key) + + usernames = [key.replace("user:", "") for key in keys] + return usernames + except Exception as e: + logger.error(f"获取用户名列表失败: {str(e)}") + return [] + + # ============ 行程相关方法 ============ + + def store_trip( + self, + user_id: str, + trip_id: str, + trip_data: Dict[str, Any] + ) -> bool: + """ + 存储完整行程数据 + + Args: + user_id: 用户ID + trip_id: 行程ID + trip_data: 行程数据(完整行程详情) + + Returns: + 是否存储成功 + """ + try: + trip_key = self._generate_trip_key(trip_id) + user_trips_list_key = self._generate_user_trips_list_key(user_id) + + # 存储完整行程数据为JSON字符串 + self.redis.set( + trip_key, + json.dumps(trip_data, ensure_ascii=False), + ex=365 * 24 * 60 * 60 # 1年过期 + ) + + # 将行程ID添加到用户的行程列表中(使用有序集合,按创建时间排序) + created_at = trip_data.get('created_at', datetime.datetime.now().isoformat()) + timestamp = int(datetime.datetime.fromisoformat(created_at).timestamp()) + self.redis.zadd(user_trips_list_key, {trip_id: timestamp}) + + logger.info(f"行程存储成功 - UserID: {user_id}, TripID: {trip_id}") + return True + except Exception as e: + logger.error(f"行程存储失败: {str(e)}") + return False + + def get_trip(self, trip_id: str) -> Optional[Dict[str, Any]]: + """ + 获取指定行程的完整数据 + + Args: + trip_id: 行程ID + + Returns: + 行程数据,如果不存在则返回None + """ + try: + trip_key = self._generate_trip_key(trip_id) + trip_data_str = self.redis.get(trip_key) + + if not trip_data_str: + return None + + return json.loads(trip_data_str) + except Exception as e: + logger.error(f"获取行程失败: {str(e)}") + return None + + def list_user_trips(self, user_id: str, limit: int = 100) -> List[Dict[str, Any]]: + """ + 获取用户的所有行程列表(按创建时间倒序) + + Args: + user_id: 用户ID + limit: 返回数量限制 + + Returns: + 行程列表 + """ + try: + user_trips_list_key = self._generate_user_trips_list_key(user_id) + + # 从有序集合中获取行程ID列表(倒序,最新的在前) + trip_ids = self.redis.zrevrange(user_trips_list_key, 0, limit - 1) + + trips = [] + for trip_id in trip_ids: + trip_data = self.get_trip(trip_id) + if trip_data: + trips.append(trip_data) + + logger.info(f"获取用户行程列表 - UserID: {user_id}, Count: {len(trips)}") + return trips + except Exception as e: + logger.error(f"获取用户行程列表失败: {str(e)}") + return [] + + def delete_trip(self, user_id: str, trip_id: str) -> bool: + """ + 删除指定行程 + + Args: + user_id: 用户ID + trip_id: 行程ID + + Returns: + 是否删除成功 + """ + try: + trip_key = self._generate_trip_key(trip_id) + user_trips_list_key = self._generate_user_trips_list_key(user_id) + + # 验证行程是否存在 + if not self.redis.exists(trip_key): + logger.warning(f"行程不存在 - TripID: {trip_id}") + return False + + # 验证行程是否属于当前用户 + is_member = self.redis.zscore(user_trips_list_key, trip_id) + if is_member is None: + logger.warning(f"行程不属于当前用户 - UserID: {user_id}, TripID: {trip_id}") + return False + + # 使用Redis管道确保原子性操作 + pipe = self.redis.pipeline() + try: + # 删除行程数据 + pipe.delete(trip_key) + # 从用户行程列表中移除 + pipe.zrem(user_trips_list_key, trip_id) + # 执行管道中的所有命令 + pipe.execute() + + logger.info(f"行程删除成功 - UserID: {user_id}, TripID: {trip_id}") + return True + except Exception as e: + logger.error(f"Redis管道执行失败: {str(e)}") + return False + except Exception as e: + logger.error(f"行程删除失败: {str(e)}") + return False + + def close(self): + """关闭Redis连接""" + if self._redis_client: + try: + self._redis_client.close() + logger.info("Redis连接已关闭") + except Exception as e: + logger.error(f"关闭Redis连接失败: {str(e)}") + + def __enter__(self): + """上下文管理器入口""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """上下文管理器出口""" + self.close() + + +# 创建全局Redis服务实例 +redis_service = RedisService() \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/services/unsplash_service.py b/Co-creation-projects/275145-TripPlanner/backend/app/services/unsplash_service.py new file mode 100644 index 00000000..3e7a343e --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/services/unsplash_service.py @@ -0,0 +1,263 @@ +# backend/app/services/unsplash_service.py +import requests +from typing import Optional, List, Dict +from functools import lru_cache +import logging +import asyncio +from concurrent.futures import ThreadPoolExecutor + +logger = logging.getLogger(__name__) + +# 占位图片URL(当无法获取图片时使用) +DEFAULT_PLACEHOLDER_IMAGES = [ + "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=800&h=600&fit=crop", + "https://images.unsplash.com/photo-1493976040374-85c8e12f0c0e?w=800&h=600&fit=crop", + "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?w=800&h=600&fit=crop", + "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&h=600&fit=crop", + "https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?w=800&h=600&fit=crop" +] + + +class UnsplashService: + """ + Unsplash图片服务(优化版) + - 添加缓存机制提高性能 + - 支持异步批量搜索 + - 提供降级策略(占位图) + """ + + def __init__(self, access_key: str, cache_size: int = 1000): + """ + 初始化Unsplash服务 + + Args: + access_key: Unsplash访问密钥 + cache_size: LRU缓存大小 + """ + self.access_key = access_key + self.base_url = "https://api.unsplash.com" + self.cache_size = cache_size + self.executor = ThreadPoolExecutor(max_workers=5) # 线程池用于异步请求 + logger.info(f"✅ Unsplash服务初始化完成,缓存大小: {cache_size}") + + def search_photos(self, query: str, per_page: int = 10, use_cache: bool = True) -> List[Dict]: + """ + 搜索图片 + + Args: + query: 搜索关键词 + per_page: 每页数量 + use_cache: 是否使用缓存 + + Returns: + 图片列表 + """ + # 如果启用缓存,先尝试使用缓存方法 + if use_cache: + return self._search_photos_cached(query, per_page) + + return self._search_photos_internal(query, per_page) + + @lru_cache(maxsize=1000) + def _search_photos_cached(self, query: str, per_page: int = 10) -> List[Dict]: + """ + 带缓存的图片搜索方法 + + Args: + query: 搜索关键词 + per_page: 每页数量 + + Returns: + 图片列表 + """ + logger.debug(f"💾 缓存搜索图片: '{query}'") + return self._search_photos_internal(query, per_page) + + def _search_photos_internal(self, query: str, per_page: int = 10) -> List[Dict]: + """ + 内部图片搜索方法(不使用缓存) + + Args: + query: 搜索关键词 + per_page: 每页数量 + + Returns: + 图片列表 + """ + try: + url = f"{self.base_url}/search/photos" + params = { + "query": query, + "per_page": per_page, + "client_id": self.access_key + } + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } + + logger.info(f"🔍 搜索图片: '{query}'") + response = requests.get(url, params=params, headers=headers, timeout=10) + response.raise_for_status() + + data = response.json() + results = data.get("results", []) + + photos = [] + for result in results: + photos.append({ + "url": result["urls"]["regular"], + "description": result.get("description", ""), + "photographer": result["user"]["name"] + }) + + logger.info(f"✅ 找到 {len(photos)} 张图片") + return photos + + except Exception as e: + logger.error(f"❌ 搜索图片失败: '{query}', 错误: {e}") + return [] + + @lru_cache(maxsize=2000) + def get_photo_url(self, query: str, use_fallback: bool = True) -> Optional[str]: + """ + 获取单张图片URL(带缓存,支持两级降级策略) + + 降级策略: + 1. 第一级:搜索完整查询(景点名+城市) + 2. 第二级:如果第一级失败,提取城市名单独搜索 + 3. 第三级:如果第二级失败,使用占位图 + + Args: + query: 搜索关键词(格式:景点名 城市) + use_fallback: 是否在失败时使用占位图 + + Returns: + 图片URL或占位图URL + """ + logger.debug(f"🔍 获取图片URL(缓存): '{query}'") + + # 第一级:搜索完整查询(景点名+城市) + photos = self._search_photos_internal(query, per_page=1) + + if photos: + url = photos[0].get("url") + logger.info(f"✅ 第一级搜索成功: {url}") + return url + + # 第二级:如果完整查询失败,尝试只搜索城市名 + # 从查询中提取可能的 cityName(通常是最后一个词) + query_parts = query.split() + if len(query_parts) > 1: + city_query = query_parts[-1] # 假设最后一个词是城市名 + logger.warning(f"⚠️ 第一级搜索失败,尝试城市搜索: '{city_query}'") + city_photos = self._search_photos_internal(city_query, per_page=1) + + if city_photos: + url = city_photos[0].get("url") + logger.info(f"✅ 第二级搜索成功(城市图片): {url}") + return url + + # 第三级:使用占位图 + if use_fallback: + logger.warning(f"⚠️ 城市搜索也失败,使用占位图: '{query}'") + return self._get_placeholder_image(query) + else: + logger.warning(f"⚠️ 所有搜索失败: '{query}'") + return None + + def get_photo_url_async(self, query: str, use_fallback: bool = True) -> str: + """ + 异步获取单张图片URL + + Args: + query: 搜索关键词 + use_fallback: 是否在失败时使用占位图 + + Returns: + 图片URL + """ + return self.get_photo_url(query, use_fallback) + + async def fetch_images_batch( + self, + queries: List[str], + use_fallback: bool = True, + use_cache: bool = True + ) -> List[Optional[str]]: + """ + 批量异步获取图片URL + + Args: + queries: 查询关键词列表 + use_fallback: 是否使用占位图 + use_cache: 是否使用缓存 + + Returns: + 图片URL列表 + """ + # 获取事件循环 + loop = asyncio.get_event_loop() + + # 创建异步任务 + tasks = [ + loop.run_in_executor( + self.executor, + self.get_photo_url_async, + query, + use_fallback + ) + for query in queries + ] + + # 并行执行 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 处理异常 + final_results = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + logger.error(f"❌ 获取图片失败: '{queries[i]}', 错误: {result}") + if use_fallback: + final_results.append(self._get_placeholder_image(queries[i])) + else: + final_results.append(None) + else: + final_results.append(result) + + logger.info(f"✅ 批量获取图片完成: {len([r for r in final_results if r])}/{len(queries)}") + return final_results + + def _get_placeholder_image(self, seed: str) -> str: + """ + 根据种子获取占位图URL + + Args: + seed: 用于选择占位图的种子(如景点名称) + + Returns: + 占位图URL + """ + # 使用字符串哈希来选择占位图 + hash_val = hash(seed) + index = abs(hash_val) % len(DEFAULT_PLACEHOLDER_IMAGES) + placeholder_url = DEFAULT_PLACEHOLDER_IMAGES[index] + logger.debug(f"💾 使用占位图[{index}]: {placeholder_url}") + return placeholder_url + + def clear_cache(self): + """清空缓存""" + self._search_photos_cached.cache_clear() + self.get_photo_url.cache_clear() + logger.info("🗑️ Unsplash缓存已清空") + + def get_cache_stats(self) -> Dict[str, int]: + """ + 获取缓存统计信息 + + Returns: + 缓存统计字典 + """ + return { + "search_photos_cache_info": self._search_photos_cached.cache_info()._asdict(), + "get_photo_url_cache_info": self.get_photo_url.cache_info()._asdict() + } diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/services/vector_memory_service.py b/Co-creation-projects/275145-TripPlanner/backend/app/services/vector_memory_service.py new file mode 100644 index 00000000..fabaf622 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/services/vector_memory_service.py @@ -0,0 +1,754 @@ +""" +基于向量数据库的记忆服务 +支持用户记忆、知识记忆的向量存储和语义检索 +""" +import json +import os +import numpy as np +import threading +from typing import Dict, List, Optional, Any, Tuple +from datetime import datetime +from pathlib import Path +from sentence_transformers import SentenceTransformer +import faiss +from app.observability.logger import default_logger as logger +from app.config import settings + + +class VectorMemoryService: + """ + 基于向量数据库的记忆服务(单例模式) + 使用FAISS进行向量存储,Sentence-BERT进行文本嵌入 + 实现线程安全的单例模式,避免重复初始化模型 + """ + + # 单例实例 + _instance = None + _lock = threading.Lock() + _initialized = False + + def __new__(cls, *args, **kwargs): + """实现单例模式的 __new__ 方法""" + if cls._instance is None: + with cls._lock: + # 双重检查锁定,确保线程安全 + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__( + self, + model_name: Optional[str] = None, + vector_dim: int = 384, + memory_dir: str = "vector_memory" + ): + """ + 初始化向量记忆服务 + + Args: + model_name: 句子嵌入模型名称(如果为None,使用配置中的模型) + vector_dim: 向量维度 + memory_dir: 记忆存储目录 + + 注意:由于是单例模式,初始化只会执行一次 + """ + # 避免重复初始化 + if hasattr(self, '_initialized') and self._initialized: + logger.debug("向量记忆服务已初始化,跳过重复初始化") + return + + logger.info("🚀 初始化向量记忆服务(单例模式)...") + + self.memory_dir = Path(memory_dir) + self.memory_dir.mkdir(parents=True, exist_ok=True) + self.vector_dim = vector_dim + + # 使用配置中的模型名称(如果没有提供) + if model_name is None: + model_name = settings.EMBEDDING_MODEL + logger.info(f"使用配置中的嵌入模型: {model_name}") + + # 设置HuggingFace镜像和缓存配置 + self._setup_huggingface_config() + + # 初始化嵌入模型 + self.embedding_model = self._load_embedding_model(model_name) + + # 初始化FAISS索引 + self.user_memory_index = None + self.knowledge_memory_index = None + self.user_metadata = {} # 存储用户记忆的元数据 + self.knowledge_metadata = {} # 存储知识记忆的元数据 + + # 加载或创建索引 + self._load_or_create_indexes() + + # 初始化FAISS索引 + self.user_memory_index = None + self.knowledge_memory_index = None + self.user_metadata = {} # 存储用户记忆的元数据 + self.knowledge_metadata = {} # 存储知识记忆的元数据 + + # 加载或创建索引 + self._load_or_create_indexes() + + # 标记为已初始化 + self._initialized = True + logger.info("✅ 向量记忆服务初始化完成(单例模式)") + + def _setup_huggingface_config(self): + """设置HuggingFace镜像和缓存配置""" + # 设置HuggingFace镜像(如果配置了) + if hasattr(settings, 'HF_ENDPOINT') and settings.HF_ENDPOINT: + os.environ['HF_ENDPOINT'] = settings.HF_ENDPOINT + logger.info(f"🌐 已设置HuggingFace镜像: {settings.HF_ENDPOINT}") + + # 设置离线模式(如果配置了) + if hasattr(settings, 'HF_HUB_OFFLINE') and settings.HF_HUB_OFFLINE: + os.environ['HF_HUB_OFFLINE'] = str(settings.HF_HUB_OFFLINE) + logger.info(f"🔒 HuggingFace离线模式: {settings.HF_HUB_OFFLINE}") + + # 设置缓存目录(如果配置了) + if hasattr(settings, 'HF_HUB_CACHE_DIR') and settings.HF_HUB_CACHE_DIR: + os.environ['HF_HUB_CACHE_DIR'] = settings.HF_HUB_CACHE_DIR + logger.info(f"💾 HuggingFace缓存目录: {settings.HF_HUB_CACHE_DIR}") + + def _check_model_cache(self, model_name: str) -> bool: + """检查模型是否已缓存在本地""" + try: + from huggingface_hub import snapshot_download + cache_dir = os.environ.get('HF_HUB_CACHE_DIR', None) + model_path = snapshot_download( + repo_id=model_name, + cache_dir=cache_dir, + local_files_only=True # 仅检查本地,不下载 + ) + logger.info(f"✅ 检测到本地缓存模型: {model_path}") + return True + except Exception as e: + logger.debug(f"模型未在本地缓存: {model_name}, 原因: {e}") + return False + + def _load_embedding_model(self, model_name: str): + """ + 加载嵌入模型,优先使用本地缓存 + + Args: + model_name: 模型名称 + + Returns: + SentenceTransformer模型实例 + """ + # 首先检查本地缓存 + logger.info(f"🔍 检查本地模型缓存: {model_name}") + if self._check_model_cache(model_name): + logger.info(f"⏳ 从本地缓存加载模型: {model_name}") + try: + model = SentenceTransformer(model_name, local_files_only=True) + logger.info(f"✅ 成功从本地缓存加载模型: {model_name}") + return model + except Exception as e: + logger.warning(f"从本地缓存加载失败,尝试在线下载: {e}") + + # 本地缓存不存在或加载失败,尝试在线下载 + logger.info(f"⏳ 从在线源加载模型: {model_name}") + mirror_info = "" + if 'HF_ENDPOINT' in os.environ: + mirror_info = f" (使用镜像: {os.environ['HF_ENDPOINT']})" + + try: + model = SentenceTransformer(model_name) + logger.info(f"✅ 成功加载嵌入模型: {model_name}{mirror_info}") + return model + except Exception as e: + logger.error(f"❌ 在线加载模型失败: {model_name}, 错误: {e}") + + # 尝试使用备用模型 + backup_model = "all-MiniLM-L6-v2" + logger.info(f"⏳ 尝试加载备用模型: {backup_model}") + + try: + # 先检查备用模型的本地缓存 + if self._check_model_cache(backup_model): + model = SentenceTransformer(backup_model, local_files_only=True) + logger.info(f"✅ 成功从本地缓存加载备用模型: {backup_model}") + else: + model = SentenceTransformer(backup_model) + logger.info(f"✅ 成功在线加载备用模型: {backup_model}{mirror_info}") + return model + except Exception as backup_error: + logger.error(f"❌ 加载备用模型也失败: {backup_error}") + raise RuntimeError( + f"无法加载任何嵌入模型。主模型 '{model_name}' 和备用模型 '{backup_model}' 都加载失败。\n" + f"主模型错误: {e}\n" + f"备用模型错误: {backup_error}\n" + f"建议: 1) 检查网络连接 2) 配置HF_ENDPOINT镜像 3) 手动下载模型到本地" + ) + + def _load_or_create_indexes(self): + """加载或创建FAISS索引""" + user_index_path = self.memory_dir / "user_memory.index" + knowledge_index_path = self.memory_dir / "knowledge_memory.index" + user_metadata_path = self.memory_dir / "user_metadata.json" + knowledge_metadata_path = self.memory_dir / "knowledge_metadata.json" + + # 尝试加载用户记忆索引 + if user_index_path.exists(): + self.user_memory_index = faiss.read_index(str(user_index_path)) + if user_metadata_path.exists(): + with open(user_metadata_path, 'r', encoding='utf-8') as f: + self.user_metadata = json.load(f) + logger.info(f"已加载用户记忆索引,包含 {self.user_memory_index.ntotal} 条记录") + else: + self.user_memory_index = faiss.IndexFlatIP(self.vector_dim) # 内积相似度 + self.user_metadata = {} + logger.info("创建了新的用户记忆索引") + + # 尝试加载知识记忆索引 + if knowledge_index_path.exists(): + self.knowledge_memory_index = faiss.read_index(str(knowledge_index_path)) + if knowledge_metadata_path.exists(): + with open(knowledge_metadata_path, 'r', encoding='utf-8') as f: + self.knowledge_metadata = json.load(f) + logger.info(f"已加载知识记忆索引,包含 {self.knowledge_memory_index.ntotal} 条记录") + else: + self.knowledge_memory_index = faiss.IndexFlatIP(self.vector_dim) # 内积相似度 + self.knowledge_metadata = {} + logger.info("创建了新的知识记忆索引") + + def _save_indexes(self): + """保存FAISS索引和元数据""" + try: + # 保存用户记忆索引 + user_index_path = self.memory_dir / "user_memory.index" + user_metadata_path = self.memory_dir / "user_metadata.json" + faiss.write_index(self.user_memory_index, str(user_index_path)) + with open(user_metadata_path, 'w', encoding='utf-8') as f: + json.dump(self.user_metadata, f, ensure_ascii=False, indent=2) + + # 保存知识记忆索引 + knowledge_index_path = self.memory_dir / "knowledge_memory.index" + knowledge_metadata_path = self.memory_dir / "knowledge_metadata.json" + faiss.write_index(self.knowledge_memory_index, str(knowledge_index_path)) + with open(knowledge_metadata_path, 'w', encoding='utf-8') as f: + json.dump(self.knowledge_metadata, f, ensure_ascii=False, indent=2) + + logger.info("向量索引保存成功") + except Exception as e: + logger.error(f"保存向量索引失败: {e}") + + def _text_to_vector(self, text: str) -> np.ndarray: + """将文本转换为向量""" + try: + vector = self.embedding_model.encode(text, convert_to_numpy=True) + # 归一化向量,用于内积相似度计算 + vector = vector / np.linalg.norm(vector) + return vector.astype('float32') + except Exception as e: + logger.error(f"文本向量化失败: {e}") + # 返回零向量 + return np.zeros(self.vector_dim, dtype='float32') + + def _vector_to_text(self, vector: np.ndarray) -> str: + """将向量转换为文本表示(用于调试)""" + return f"Vector(dim={len(vector)}, norm={np.linalg.norm(vector):.4f})" + + # ============ 用户记忆操作 ============ + + def store_user_preference( + self, + user_id: str, + preference_type: str, + preference_data: Dict[str, Any] + ): + """ + 存储用户偏好到向量数据库 + + Args: + user_id: 用户ID + preference_type: 偏好类型 + preference_data: 偏好数据 + """ + try: + # 构建文本表示 + text_representation = self._preference_to_text(preference_type, preference_data) + + # 转换为向量 + vector = self._text_to_vector(text_representation) + + # 添加到索引 + index_id = self.user_memory_index.ntotal + self.user_memory_index.add(np.array([vector])) + + # 存储元数据 + self.user_metadata[str(index_id)] = { + "user_id": user_id, + "type": "preference", + "preference_type": preference_type, + "data": preference_data, + "text_representation": text_representation, + "timestamp": datetime.now().isoformat() + } + + logger.info(f"用户偏好已存储到向量数据库 - UserID: {user_id}, Type: {preference_type}") + except Exception as e: + logger.error(f"存储用户偏好失败: {e}") + + def store_user_trip( + self, + user_id: str, + trip_data: Dict[str, Any] + ): + """ + 存储用户行程到向量数据库 + + Args: + user_id: 用户ID + trip_data: 行程数据 + """ + try: + # 构建文本表示 + text_representation = self._trip_to_text(trip_data) + + # 转换为向量 + vector = self._text_to_vector(text_representation) + + # 添加到索引 + index_id = self.user_memory_index.ntotal + self.user_memory_index.add(np.array([vector])) + + # 存储元数据 + self.user_metadata[str(index_id)] = { + "user_id": user_id, + "type": "trip", + "data": trip_data, + "text_representation": text_representation, + "timestamp": datetime.now().isoformat() + } + + logger.info(f"用户行程已存储到向量数据库 - UserID: {user_id}") + except Exception as e: + logger.error(f"存储用户行程失败: {e}") + + def store_user_feedback( + self, + user_id: str, + trip_id: str, + feedback_data: Dict[str, Any] + ): + """ + 存储用户反馈到向量数据库 + + Args: + user_id: 用户ID + trip_id: 行程ID + feedback_data: 反馈数据 + """ + try: + # 构建文本表示 + text_representation = self._feedback_to_text(feedback_data) + + # 转换为向量 + vector = self._text_to_vector(text_representation) + + # 添加到索引 + index_id = self.user_memory_index.ntotal + self.user_memory_index.add(np.array([vector])) + + # 存储元数据 + self.user_metadata[str(index_id)] = { + "user_id": user_id, + "type": "feedback", + "trip_id": trip_id, + "data": feedback_data, + "text_representation": text_representation, + "timestamp": datetime.now().isoformat() + } + + logger.info(f"用户反馈已存储到向量数据库 - UserID: {user_id}, TripID: {trip_id}") + except Exception as e: + logger.error(f"存储用户反馈失败: {e}") + + def retrieve_user_memories( + self, + user_id: str, + query: str = "", + limit: int = 10, + memory_types: Optional[List[str]] = None + ) -> List[Dict[str, Any]]: + """ + 基于语义相似度检索用户记忆 + + Args: + user_id: 用户ID + query: 查询文本 + limit: 返回数量限制 + memory_types: 记忆类型过滤,如 ["preference", "trip", "feedback"] + + Returns: + 相似的用户记忆列表 + """ + try: + # 如果没有查询文本,返回用户最近的记忆 + if not query: + return self._get_recent_user_memories(user_id, limit, memory_types) + + # 转换查询为向量 + query_vector = self._text_to_vector(query) + + # 在用户记忆中搜索 + distances, indices = self.user_memory_index.search( + np.array([query_vector]), + min(self.user_memory_index.ntotal, limit * 2) # 搜索更多结果进行过滤 + ) + + # 过滤结果 + results = [] + for i, idx in enumerate(indices[0]): + if idx == -1: # FAISS返回-1表示无效结果 + continue + + metadata = self.user_metadata.get(str(idx)) + if not metadata: + continue + + # 过滤用户ID和记忆类型 + if metadata.get("user_id") != user_id: + continue + + if memory_types and metadata.get("type") not in memory_types: + continue + + # 添加相似度分数 + metadata["similarity_score"] = float(distances[0][i]) + results.append(metadata) + + if len(results) >= limit: + break + + logger.info(f"检索到 {len(results)} 条用户记忆 - UserID: {user_id}, Query: {query}") + return results + except Exception as e: + logger.error(f"检索用户记忆失败: {e}") + return [] + + def _get_recent_user_memories( + self, + user_id: str, + limit: int, + memory_types: Optional[List[str]] + ) -> List[Dict[str, Any]]: + """获取用户最近的记忆""" + user_memories = [] + for metadata in self.user_metadata.values(): + if metadata.get("user_id") != user_id: + continue + + if memory_types and metadata.get("type") not in memory_types: + continue + + user_memories.append(metadata) + + # 按时间戳排序 + user_memories.sort( + key=lambda x: x.get("timestamp", ""), + reverse=True + ) + + return user_memories[:limit] + + # ============ 知识记忆操作 ============ + + def store_destination_knowledge( + self, + destination: str, + knowledge_data: Dict[str, Any] + ): + """ + 存储目的地知识到向量数据库 + + Args: + destination: 目的地名称 + knowledge_data: 知识数据 + """ + try: + # 构建文本表示 + text_representation = self._destination_knowledge_to_text(destination, knowledge_data) + + # 转换为向量 + vector = self._text_to_vector(text_representation) + + # 添加到索引 + index_id = self.knowledge_memory_index.ntotal + self.knowledge_memory_index.add(np.array([vector])) + + # 存储元数据 + self.knowledge_metadata[str(index_id)] = { + "type": "destination", + "destination": destination, + "data": knowledge_data, + "text_representation": text_representation, + "timestamp": datetime.now().isoformat() + } + + logger.info(f"目的地知识已存储到向量数据库 - Destination: {destination}") + except Exception as e: + logger.error(f"存储目的地知识失败: {e}") + + def store_travel_experience( + self, + experience_type: str, + experience_data: Dict[str, Any] + ): + """ + 存储旅行经验到向量数据库 + + Args: + experience_type: 经验类型 + experience_data: 经验数据 + """ + try: + # 构建文本表示 + text_representation = self._experience_to_text(experience_type, experience_data) + + # 转换为向量 + vector = self._text_to_vector(text_representation) + + # 添加到索引 + index_id = self.knowledge_memory_index.ntotal + self.knowledge_memory_index.add(np.array([vector])) + + # 存储元数据 + self.knowledge_metadata[str(index_id)] = { + "type": "experience", + "experience_type": experience_type, + "data": experience_data, + "text_representation": text_representation, + "timestamp": datetime.now().isoformat() + } + + logger.info(f"旅行经验已存储到向量数据库 - Type: {experience_type}") + except Exception as e: + logger.error(f"存储旅行经验失败: {e}") + + def retrieve_knowledge_memories( + self, + query: str, + limit: int = 10, + knowledge_types: Optional[List[str]] = None + ) -> List[Dict[str, Any]]: + """ + 基于语义相似度检索知识记忆 + + Args: + query: 查询文本 + limit: 返回数量限制 + knowledge_types: 知识类型过滤,如 ["destination", "experience"] + + Returns: + 相似的知识记忆列表 + """ + try: + # 检查索引是否为空 + if self.knowledge_memory_index.ntotal == 0: + logger.info(f"知识记忆索引为空,返回空结果 - Query: {query}") + return [] + + # 转换查询为向量 + query_vector = self._text_to_vector(query) + + # 在知识记忆中搜索 + # 确保k值至少为1,避免FAISS在k=0时报错 + k = min(self.knowledge_memory_index.ntotal, limit * 2) + distances, indices = self.knowledge_memory_index.search( + np.array([query_vector]), + k + ) + + # 过滤结果 + results = [] + for i, idx in enumerate(indices[0]): + if idx == -1: # FAISS返回-1表示无效结果 + continue + + metadata = self.knowledge_metadata.get(str(idx)) + if not metadata: + continue + + # 过滤知识类型 + if knowledge_types and metadata.get("type") not in knowledge_types: + continue + + # 添加相似度分数 + metadata["similarity_score"] = float(distances[0][i]) + results.append(metadata) + + if len(results) >= limit: + break + + logger.info(f"检索到 {len(results)} 条知识记忆 - Query: {query}") + return results + except Exception as e: + logger.error(f"检索知识记忆失败: {e}") + return [] + + # ============ 混合检索 ============ + + def hybrid_search( + self, + user_id: str, + query: str, + user_limit: int = 5, + knowledge_limit: int = 5, + include_user_memories: bool = True, + include_knowledge_memories: bool = True + ) -> Dict[str, List[Dict[str, Any]]]: + """ + 混合检索用户记忆和知识记忆 + + Args: + user_id: 用户ID + query: 查询文本 + user_limit: 用户记忆数量限制 + knowledge_limit: 知识记忆数量限制 + include_user_memories: 是否包含用户记忆 + include_knowledge_memories: 是否包含知识记忆 + + Returns: + 包含用户记忆和知识记忆的字典 + """ + results = { + "user_memories": [], + "knowledge_memories": [] + } + + # 检索用户记忆 + if include_user_memories: + results["user_memories"] = self.retrieve_user_memories( + user_id, query, user_limit + ) + + # 检索知识记忆 + if include_knowledge_memories: + results["knowledge_memories"] = self.retrieve_knowledge_memories( + query, knowledge_limit + ) + + return results + + # ============ 文本转换辅助方法 ============ + + def _preference_to_text(self, preference_type: str, preference_data: Dict[str, Any]) -> str: + """将偏好数据转换为文本表示""" + text_parts = [f"偏好类型: {preference_type}"] + + if "destination" in preference_data: + text_parts.append(f"目的地: {preference_data['destination']}") + + if "preferences" in preference_data: + text_parts.append(f"旅行偏好: {', '.join(preference_data['preferences'])}") + + if "hotel_preferences" in preference_data: + text_parts.append(f"酒店偏好: {', '.join(preference_data['hotel_preferences'])}") + + if "budget" in preference_data: + text_parts.append(f"预算水平: {preference_data['budget']}") + + return " ".join(text_parts) + + def _trip_to_text(self, trip_data: Dict[str, Any]) -> str: + """将行程数据转换为文本表示""" + text_parts = ["旅行行程"] + + if "destination" in trip_data: + text_parts.append(f"目的地: {trip_data['destination']}") + + if "start_date" in trip_data and "end_date" in trip_data: + text_parts.append(f"时间: {trip_data['start_date']} 到 {trip_data['end_date']}") + + if "preferences" in trip_data: + text_parts.append(f"偏好: {', '.join(trip_data['preferences'])}") + + if "trip_title" in trip_data: + text_parts.append(f"行程标题: {trip_data['trip_title']}") + + # 添加景点信息 + if "days" in trip_data: + attractions = [] + for day in trip_data["days"]: + for attraction in day.get("attractions", []): + attractions.append(attraction.get("name", "")) + if attractions: + text_parts.append(f"景点: {', '.join(attractions)}") + + return " ".join(text_parts) + + def _feedback_to_text(self, feedback_data: Dict[str, Any]) -> str: + """将反馈数据转换为文本表示""" + text_parts = ["用户反馈"] + + if "rating" in feedback_data: + text_parts.append(f"评分: {feedback_data['rating']}") + + if "comments" in feedback_data: + text_parts.append(f"评论: {feedback_data['comments']}") + + if "modifications" in feedback_data: + text_parts.append(f"修改建议: {feedback_data['modifications']}") + + return " ".join(text_parts) + + def _destination_knowledge_to_text(self, destination: str, knowledge_data: Dict[str, Any]) -> str: + """将目的地知识转换为文本表示""" + text_parts = [f"目的地: {destination}"] + + if "description" in knowledge_data: + text_parts.append(f"描述: {knowledge_data['description']}") + + if "highlights" in knowledge_data: + text_parts.append(f"特色: {', '.join(knowledge_data['highlights'])}") + + if "best_season" in knowledge_data: + text_parts.append(f"最佳季节: {knowledge_data['best_season']}") + + if "culture" in knowledge_data: + text_parts.append(f"文化背景: {knowledge_data['culture']}") + + return " ".join(text_parts) + + def _experience_to_text(self, experience_type: str, experience_data: Dict[str, Any]) -> str: + """将经验数据转换为文本表示""" + text_parts = [f"旅行经验: {experience_type}"] + + if "title" in experience_data: + text_parts.append(f"标题: {experience_data['title']}") + + if "description" in experience_data: + text_parts.append(f"描述: {experience_data['description']}") + + if "tags" in experience_data: + text_parts.append(f"标签: {', '.join(experience_data['tags'])}") + + if "destination" in experience_data: + text_parts.append(f"目的地: {experience_data['destination']}") + + return " ".join(text_parts) + + # ============ 维护方法 ============ + + def save(self): + """保存索引和元数据""" + self._save_indexes() + + def get_stats(self) -> Dict[str, Any]: + """获取记忆服务统计信息""" + return { + "user_memory_count": self.user_memory_index.ntotal, + "knowledge_memory_count": self.knowledge_memory_index.ntotal, + "vector_dimension": self.vector_dim, + "memory_directory": str(self.memory_dir) + } + + +# 创建全局向量记忆服务实例 +vector_memory_service = VectorMemoryService() \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/tools/base.py b/Co-creation-projects/275145-TripPlanner/backend/app/tools/base.py new file mode 100644 index 00000000..4699a29b --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/tools/base.py @@ -0,0 +1,49 @@ +"""工具基类""" + +from abc import ABC, abstractmethod +from typing import Dict, Any, List +from pydantic import BaseModel + +class ToolParameter(BaseModel): + """工具参数定义""" + name: str + type: str + description: str + required: bool = True + default: Any = None + +class Tool(ABC): + """工具基类""" + + def __init__(self, name: str, description: str): + self.name = name + self.description = description + + @abstractmethod + def run(self, parameters: Dict[str, Any]) -> str: + """执行工具""" + pass + + @abstractmethod + def get_parameters(self) -> List[ToolParameter]: + """获取工具参数定义""" + pass + + def validate_parameters(self, parameters: Dict[str, Any]) -> bool: + """验证参数""" + required_params = [p.name for p in self.get_parameters() if p.required] + return all(param in parameters for param in required_params) + + def to_dict(self) -> Dict[str, Any]: + """转换为字典格式""" + return { + "name": self.name, + "description": self.description, + "parameters": [param.dict() for param in self.get_parameters()] + } + + def __str__(self) -> str: + return f"Tool(name={self.name})" + + def __repr__(self) -> str: + return self.__str__() \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/tools/client.py b/Co-creation-projects/275145-TripPlanner/backend/app/tools/client.py new file mode 100644 index 00000000..8f8f0fc8 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/tools/client.py @@ -0,0 +1,353 @@ +""" +增强的 MCP 客户端实现 + +支持多种传输方式的 MCP 客户端,用于教学和实际应用。 +这个实现展示了如何使用不同的传输方式连接到 MCP 服务器。 + +支持的传输方式: +1. Memory: 内存传输(用于测试,直接传递 FastMCP 实例) +2. Stdio: 标准输入输出传输(本地进程,Python/Node.js 脚本) +3. HTTP: HTTP 传输(远程服务器) +4. SSE: Server-Sent Events 传输(实时通信) + +使用示例: +```python +# 1. 内存传输(测试) +from fastmcp import FastMCP +server = FastMCP("TestServer") +client = MCPClient(server) + +# 2. Stdio 传输(本地脚本) +client = MCPClient("server.py") +client = MCPClient(["python", "server.py"]) + +# 3. HTTP 传输(远程服务器) +client = MCPClient("https://api.example.com/mcp") + +# 4. SSE 传输(实时通信) +client = MCPClient("https://api.example.com/mcp", transport_type="sse") + +# 5. 配置传输(高级用法) +config = { + "transport": "stdio", + "command": "python", + "args": ["server.py"], + "env": {"DEBUG": "1"} +} +client = MCPClient(config) +``` +""" + +from typing import Dict, Any, List, Optional, Union +import asyncio +import os + +try: + from fastmcp import Client, FastMCP + from fastmcp.client.transports import PythonStdioTransport, SSETransport, StreamableHttpTransport + FASTMCP_AVAILABLE = True +except ImportError: + FASTMCP_AVAILABLE = False + Client = None + FastMCP = None + PythonStdioTransport = None + SSETransport = None + StreamableHttpTransport = None + + +class MCPClient: + """MCP 客户端,支持多种传输方式""" + + def __init__(self, + server_source: Union[str, List[str], FastMCP, Dict[str, Any]], + server_args: Optional[List[str]] = None, + transport_type: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + **transport_kwargs): + """ + 初始化MCP 客户端 + + Args: + server_source: 服务器源,支持多种格式: + - FastMCP 实例: 内存传输(用于测试) + - 字符串路径: Python 脚本路径(如 "server.py") + - HTTP URL: 远程服务器(如 "https://api.example.com/mcp") + - 命令列表: 完整命令(如 ["python", "server.py"]) + - 配置字典: 传输配置 + server_args: 服务器参数列表(可选) + transport_type: 强制指定传输类型 ("stdio", "http", "sse", "memory") + env: 环境变量字典(传递给MCP服务器进程) + **transport_kwargs: 传输特定的额外参数 + + Raises: + ImportError: 如果 fastmcp 库未安装 + """ + if not FASTMCP_AVAILABLE: + raise ImportError( + "Enhanced MCP client requires the 'fastmcp' library (version 2.0+). " + "Install it with: pip install fastmcp>=2.0.0" + ) + + self.server_args = server_args or [] + self.transport_type = transport_type + self.env = env or {} + self.transport_kwargs = transport_kwargs + self.server_source = self._prepare_server_source(server_source) + self.client: Optional[Client] = None + self._context_manager = None + + def _prepare_server_source(self, server_source: Union[str, List[str], FastMCP, Dict[str, Any]]): + """准备服务器源,根据类型创建合适的传输配置""" + + # 1. FastMCP 实例 - 内存传输 + if isinstance(server_source, FastMCP): + print(f"🧠 使用内存传输: {server_source.name}") + return server_source + + # 2. 配置字典 - 根据配置创建传输 + if isinstance(server_source, dict): + print(f"⚙️ 使用配置传输: {server_source.get('transport', 'stdio')}") + return self._create_transport_from_config(server_source) + + # 3. HTTP URL - HTTP/SSE 传输 + if isinstance(server_source, str) and (server_source.startswith("http://") or server_source.startswith("https://")): + transport_type = self.transport_type or "http" + print(f"🌐 使用 {transport_type.upper()} 传输: {server_source}") + if transport_type == "sse": + return SSETransport(url=server_source, **self.transport_kwargs) + else: + return StreamableHttpTransport(url=server_source, **self.transport_kwargs) + + # 4. Python 脚本路径 - Stdio 传输 + if isinstance(server_source, str) and server_source.endswith(".py"): + print(f"🐍 使用 Stdio 传输 (Python): {server_source}") + return PythonStdioTransport( + script_path=server_source, + args=self.server_args, + env=self.env if self.env else None, + **self.transport_kwargs + ) + + # 5. 命令列表 - Stdio 传输 + if isinstance(server_source, list) and len(server_source) >= 1: + print(f"📝 使用 Stdio 传输 (命令): {' '.join(server_source)}") + if server_source[0] == "python" and len(server_source) > 1 and server_source[1].endswith(".py"): + # Python 脚本 + return PythonStdioTransport( + script_path=server_source[1], + args=server_source[2:] + self.server_args, + env=self.env if self.env else None, + **self.transport_kwargs + ) + else: + # 其他命令,使用通用 Stdio 传输 + from fastmcp.client.transports import StdioTransport + return StdioTransport( + command=server_source[0], + args=server_source[1:] + self.server_args, + env=self.env if self.env else None, + **self.transport_kwargs + ) + + # 6. 其他情况 - 直接返回,让 FastMCP 自动推断 + print(f"🔍 自动推断传输: {server_source}") + return server_source + + def _create_transport_from_config(self, config: Dict[str, Any]): + """从配置字典创建传输""" + transport_type = config.get("transport", "stdio") + + if transport_type == "stdio": + # 检查是否是 Python 脚本 + args = config.get("args", []) + if args and args[0].endswith(".py"): + return PythonStdioTransport( + script_path=args[0], + args=args[1:] + self.server_args, + env=config.get("env"), + cwd=config.get("cwd"), + **self.transport_kwargs + ) + else: + # 使用通用 Stdio 传输 + from fastmcp.client.transports import StdioTransport + return StdioTransport( + command=config.get("command", "python"), + args=args + self.server_args, + env=config.get("env"), + cwd=config.get("cwd"), + **self.transport_kwargs + ) + elif transport_type == "sse": + return SSETransport( + url=config["url"], + headers=config.get("headers"), + auth=config.get("auth"), + **self.transport_kwargs + ) + elif transport_type == "http": + return StreamableHttpTransport( + url=config["url"], + headers=config.get("headers"), + auth=config.get("auth"), + **self.transport_kwargs + ) + else: + raise ValueError(f"Unsupported transport type: {transport_type}") + + async def __aenter__(self): + """异步上下文管理器入口""" + print("🔗 连接到 MCP 服务器...") + self.client = Client(self.server_source) + self._context_manager = self.client + await self._context_manager.__aenter__() + print("✅ 连接成功!") + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """异步上下文管理器出口""" + if self._context_manager: + await self._context_manager.__aexit__(exc_type, exc_val, exc_tb) + self.client = None + self._context_manager = None + print("🔌 连接已断开") + + async def list_tools(self) -> List[Dict[str, Any]]: + """列出所有可用的工具""" + if not self.client: + raise RuntimeError("Client not connected. Use 'async with client:' context manager.") + + result = await self.client.list_tools() + + # 处理不同的返回格式 + if hasattr(result, 'tools'): + tools = result.tools + elif isinstance(result, list): + tools = result + else: + tools = [] + + return [ + { + "name": tool.name, + "description": tool.description or "", + "input_schema": tool.inputSchema if hasattr(tool, 'inputSchema') else {} + } + for tool in tools + ] + + async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any: + """调用 MCP 工具""" + if not self.client: + raise RuntimeError("Client not connected. Use 'async with client:' context manager.") + + result = await self.client.call_tool(tool_name, arguments) + + # 解析结果 - FastMCP 返回 ToolResult 对象 + if hasattr(result, 'content') and result.content: + if len(result.content) == 1: + content = result.content[0] + if hasattr(content, 'text'): + return content.text + elif hasattr(content, 'data'): + return content.data + return [ + getattr(c, 'text', getattr(c, 'data', str(c))) + for c in result.content + ] + return None + + async def list_resources(self) -> List[Dict[str, Any]]: + """列出所有可用的资源""" + if not self.client: + raise RuntimeError("Client not connected. Use 'async with client:' context manager.") + + result = await self.client.list_resources() + return [ + { + "uri": resource.uri, + "name": resource.name or "", + "description": resource.description or "", + "mime_type": getattr(resource, 'mimeType', None) + } + for resource in result.resources + ] + + async def read_resource(self, uri: str) -> Any: + """读取资源内容""" + if not self.client: + raise RuntimeError("Client not connected. Use 'async with client:' context manager.") + + result = await self.client.read_resource(uri) + + # 解析资源内容 + if hasattr(result, 'contents') and result.contents: + if len(result.contents) == 1: + content = result.contents[0] + if hasattr(content, 'text'): + return content.text + elif hasattr(content, 'blob'): + return content.blob + return [ + getattr(c, 'text', getattr(c, 'blob', str(c))) + for c in result.contents + ] + return None + + async def list_prompts(self) -> List[Dict[str, Any]]: + """列出所有可用的提示词模板""" + if not self.client: + raise RuntimeError("Client not connected. Use 'async with client:' context manager.") + + result = await self.client.list_prompts() + return [ + { + "name": prompt.name, + "description": prompt.description or "", + "arguments": getattr(prompt, 'arguments', []) + } + for prompt in result.prompts + ] + + async def get_prompt(self, prompt_name: str, arguments: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]: + """获取提示词内容""" + if not self.client: + raise RuntimeError("Client not connected. Use 'async with client:' context manager.") + + result = await self.client.get_prompt(prompt_name, arguments or {}) + + # 解析提示词消息 + if hasattr(result, 'messages') and result.messages: + return [ + { + "role": msg.role, + "content": getattr(msg.content, 'text', str(msg.content)) if hasattr(msg.content, 'text') else str(msg.content) + } + for msg in result.messages + ] + return [] + + async def ping(self) -> bool: + """测试服务器连接""" + if not self.client: + raise RuntimeError("Client not connected. Use 'async with client:' context manager.") + + try: + await self.client.ping() + return True + except Exception: + return False + + def get_transport_info(self) -> Dict[str, Any]: + """获取传输信息""" + if not self.client: + return {"status": "not_connected"} + + transport = getattr(self.client, 'transport', None) + if transport: + return { + "status": "connected", + "transport_type": type(transport).__name__, + "transport_info": str(transport) + } + return {"status": "unknown"} diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/tools/mcp_tool.py b/Co-creation-projects/275145-TripPlanner/backend/app/tools/mcp_tool.py new file mode 100644 index 00000000..e1088620 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/tools/mcp_tool.py @@ -0,0 +1,517 @@ +from typing import Dict, Any, List, Optional +from .base import Tool, ToolParameter +import os + + +# MCP服务器环境变量映射表 +# 用于自动检测常见MCP服务器需要的环境变量 +MCP_SERVER_ENV_MAP = { + "server-github": ["GITHUB_PERSONAL_ACCESS_TOKEN"], + "server-slack": ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"], + "server-google-drive": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REFRESH_TOKEN"], + "server-postgres": ["POSTGRES_CONNECTION_STRING"], + "server-sqlite": [], # 不需要环境变量 + "server-filesystem": [], # 不需要环境变量 +} + + +class MCPTool(Tool): + """MCP (Model Context Protocol) 工具 + + 连接到 MCP 服务器并调用其提供的工具、资源和提示词。 + + 功能: + - 列出服务器提供的工具 + - 调用服务器工具 + - 读取服务器资源 + - 获取提示词模板 + + 使用示例: + >>> from hello_agents.tools.builtin import MCPTool + >>> + >>> # 方式1: 使用内置演示服务器 + >>> tool = MCPTool() # 自动创建内置服务器 + >>> result = tool.run({"action": "list_tools"}) + >>> + >>> # 方式2: 连接到外部 MCP 服务器 + >>> tool = MCPTool(server_command=["python", "examples/mcp_example.py"]) + >>> result = tool.run({"action": "list_tools"}) + >>> + >>> # 方式3: 使用自定义 FastMCP 服务器 + >>> from fastmcp import FastMCP + >>> server = FastMCP("MyServer") + >>> tool = MCPTool(server=server) + + 注意:使用 fastmcp 库,已包含在依赖中 + """ + + def __init__(self, + name: str = "mcp", + description: Optional[str] = None, + server_command: Optional[List[str]] = None, + server_args: Optional[List[str]] = None, + server: Optional[Any] = None, + auto_expand: bool = True, + env: Optional[Dict[str, str]] = None, + env_keys: Optional[List[str]] = None): + """ + 初始化 MCP 工具 + + Args: + name: 工具名称(默认为"mcp",建议为不同服务器指定不同名称) + description: 工具描述(可选,默认为通用描述) + server_command: 服务器启动命令(如 ["python", "server.py"]) + server_args: 服务器参数列表 + server: FastMCP 服务器实例(可选,用于内存传输) + auto_expand: 是否自动展开为独立工具(默认True) + env: 环境变量字典(优先级最高,直接传递给MCP服务器) + env_keys: 要从系统环境变量加载的key列表(优先级中等) + + 环境变量优先级(从高到低): + 1. 直接传递的env参数 + 2. env_keys指定的环境变量 + 3. 自动检测的环境变量(根据server_command) + + 注意:如果所有参数都为空,将创建内置演示服务器 + + 示例: + >>> # 方式1:直接传递环境变量(优先级最高) + >>> github_tool = MCPTool( + ... name="github", + ... server_command=["npx", "-y", "@modelcontextprotocol/server-github"], + ... env={"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxx"} + ... ) + >>> + >>> # 方式2:从.env文件加载指定的环境变量 + >>> github_tool = MCPTool( + ... name="github", + ... server_command=["npx", "-y", "@modelcontextprotocol/server-github"], + ... env_keys=["GITHUB_PERSONAL_ACCESS_TOKEN"] + ... ) + >>> + >>> # 方式3:自动检测(最简单,推荐) + >>> github_tool = MCPTool( + ... name="github", + ... server_command=["npx", "-y", "@modelcontextprotocol/server-github"] + ... # 自动从环境变量加载GITHUB_PERSONAL_ACCESS_TOKEN + ... ) + """ + self.server_command = server_command + self.server_args = server_args or [] + self.server = server + self._client = None + self._available_tools = [] + self.auto_expand = auto_expand + self.prefix = f"{name}_" if auto_expand else "" + + # 环境变量处理(优先级:env > env_keys > 自动检测) + self.env = self._prepare_env(env, env_keys, server_command) + + # 如果没有指定任何服务器,创建内置演示服务器 + if not server_command and not server: + self.server = self._create_builtin_server() + + # 自动发现工具 + self._discover_tools() + + # 设置默认描述或自动生成 + if description is None: + description = self._generate_description() + print("description:", description) + super().__init__( + name=name, + description=description + ) + + def _prepare_env(self, + env: Optional[Dict[str, str]], + env_keys: Optional[List[str]], + server_command: Optional[List[str]]) -> Dict[str, str]: + """ + 准备环境变量 + + 优先级:env > env_keys > 自动检测 + + Args: + env: 直接传递的环境变量字典 + env_keys: 要从系统环境变量加载的key列表 + server_command: 服务器命令(用于自动检测) + + Returns: + 合并后的环境变量字典 + """ + result_env = {} + + # 1. 自动检测(优先级最低) + if server_command: + # 从命令中提取服务器名称 + server_name = None + for part in server_command: + if "server-" in part: + # 提取类似 "@modelcontextprotocol/server-github" 中的 "server-github" + server_name = part.split("/")[-1] if "/" in part else part + break + + # 查找映射表 + if server_name and server_name in MCP_SERVER_ENV_MAP: + auto_keys = MCP_SERVER_ENV_MAP[server_name] + for key in auto_keys: + value = os.getenv(key) + if value: + result_env[key] = value + print(f"🔑 自动加载环境变量: {key}") + + # 2. env_keys指定的环境变量(优先级中等) + if env_keys: + for key in env_keys: + value = os.getenv(key) + if value: + result_env[key] = value + print(f"🔑 从env_keys加载环境变量: {key}") + else: + print(f"⚠️ 警告: 环境变量 {key} 未设置") + + # 3. 直接传递的env(优先级最高) + if env: + result_env.update(env) + for key in env.keys(): + print(f"🔑 使用直接传递的环境变量: {key}") + + return result_env + # === 新增以下方法以支持 Agent 的合规调用 === + async def execute_tool(self, tool_name: str, arguments: Dict[str, Any] = None) -> Any: + """ + 执行工具并返回原始数据结构 (Dict/List),而非字符串描述。 + 使用 async with 确保连接在使用后立即关闭,解决 'Event loop is closed' 问题。 + """ + from .client import MCPClient + + arguments = arguments or {} + + # 确定连接源 (优先使用 server 实例,其次是命令) + client_source = self.server if self.server else self.server_command + if not client_source: + # 如果没有配置,这里需要根据您的实际情况处理,或者抛出异常 + raise ValueError("MCPTool 未配置 server_command 或 server 实例") + + # 使用上下文管理器,确保 Transport 在 block 结束时正确关闭 + async with MCPClient(client_source, self.server_args, env=self.env) as client: + return await client.call_tool(tool_name, arguments) + def _create_builtin_server(self): + """创建内置演示服务器""" + try: + from fastmcp import FastMCP + + server = FastMCP("HelloAgents-BuiltinServer") + + @server.tool() + def add(a: float, b: float) -> float: + """加法计算器""" + return a + b + + @server.tool() + def subtract(a: float, b: float) -> float: + """减法计算器""" + return a - b + + @server.tool() + def multiply(a: float, b: float) -> float: + """乘法计算器""" + return a * b + + @server.tool() + def divide(a: float, b: float) -> float: + """除法计算器""" + if b == 0: + raise ValueError("除数不能为零") + return a / b + + @server.tool() + def greet(name: str = "World") -> str: + """友好问候""" + return f"Hello, {name}! 欢迎使用 HelloAgents MCP 工具!" + + @server.tool() + def get_system_info() -> dict: + """获取系统信息""" + import platform + import sys + return { + "platform": platform.system(), + "python_version": sys.version, + "server_name": "HelloAgents-BuiltinServer", + "tools_count": 6 + } + + return server + + except ImportError: + raise ImportError( + "创建内置 MCP 服务器需要 fastmcp 库。请安装: pip install fastmcp" + ) + + def _discover_tools(self): + """发现MCP服务器提供的所有工具""" + try: + from .client import MCPClient + import asyncio + + async def discover(): + client_source = self.server if self.server else self.server_command + async with MCPClient(client_source, self.server_args, env=self.env) as client: + tools = await client.list_tools() + return tools + + # 运行异步发现 + try: + loop = asyncio.get_running_loop() + # 如果已有循环,在新线程中运行 + import concurrent.futures + def run_in_thread(): + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + try: + return new_loop.run_until_complete(discover()) + finally: + new_loop.close() + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(run_in_thread) + self._available_tools = future.result() + except RuntimeError: + # 没有运行中的循环 + self._available_tools = asyncio.run(discover()) + + except Exception as e: + # 工具发现失败不影响初始化 + self._available_tools = [] + + def _generate_description(self) -> str: + """生成增强的工具描述""" + if not self._available_tools: + return "连接到 MCP 服务器,调用工具、读取资源和获取提示词。支持内置服务器和外部服务器。" + + if self.auto_expand: + # 展开模式:简单描述 + return f"MCP工具服务器,包含{len(self._available_tools)}个工具。这些工具会自动展开为独立的工具供Agent使用。" + else: + # 非展开模式:详细描述 + desc_parts = [ + f"MCP工具服务器,提供{len(self._available_tools)}个工具:" + ] + + # 列出所有工具 + for tool in self._available_tools: + tool_name = tool.get('name', 'unknown') + tool_desc = tool.get('description', '无描述') + # 简化描述,只取第一句 + short_desc = tool_desc.split('.')[0] if tool_desc else '无描述' + desc_parts.append(f" • {tool_name}: {short_desc}") + + # 添加调用格式说明 + desc_parts.append("\n调用格式:返回JSON格式的参数") + desc_parts.append('{"action": "call_tool", "tool_name": "工具名", "arguments": {...}}') + + # 添加示例 + if self._available_tools: + first_tool = self._available_tools[0] + tool_name = first_tool.get('name', 'example') + desc_parts.append(f'\n示例:{{"action": "call_tool", "tool_name": "{tool_name}", "arguments": {{...}}}}') + + return "\n".join(desc_parts) + + def get_expanded_tools(self) -> List['Tool']: # type: ignore + """ + 获取展开的工具列表 + + 将MCP服务器的每个工具包装成独立的Tool对象 + + Returns: + Tool对象列表 + """ + if not self.auto_expand: + return [] + + + from .mcp_wrapper_tool import MCPWrappedTool + + expanded_tools = [] + for tool_info in self._available_tools: + wrapped_tool = MCPWrappedTool( + mcp_tool=self, + tool_info=tool_info, + prefix=self.prefix + ) + expanded_tools.append(wrapped_tool) + + return expanded_tools + + def run(self, parameters: Dict[str, Any]) -> str: + """ + 执行 MCP 操作 + + Args: + parameters: 包含以下参数的字典 + - action: 操作类型 (list_tools, call_tool, list_resources, read_resource, list_prompts, get_prompt) + 如果不指定action但指定了tool_name,会自动推断为call_tool + - tool_name: 工具名称(call_tool 需要) + - arguments: 工具参数(call_tool 需要) + - uri: 资源 URI(read_resource 需要) + - prompt_name: 提示词名称(get_prompt 需要) + - prompt_arguments: 提示词参数(get_prompt 可选) + + Returns: + 操作结果 + """ + from .client import MCPClient + + # 智能推断action:如果没有action但有tool_name,自动设置为call_tool + action = parameters.get("action", "").lower() + if not action and "tool_name" in parameters: + action = "call_tool" + parameters["action"] = action + + if not action: + return "错误:必须指定 action 参数或 tool_name 参数" + + try: + # 使用增强的异步客户端 + import asyncio + from .client import MCPClient + + async def run_mcp_operation(): + # 根据配置选择客户端创建方式 + if self.server: + # 使用内置服务器(内存传输) + client_source = self.server + else: + # 使用外部服务器命令 + client_source = self.server_command + + async with MCPClient(client_source, self.server_args, env=self.env) as client: + if action == "list_tools": + tools = await client.list_tools() + if not tools: + return "没有找到可用的工具" + result = f"找到 {len(tools)} 个工具:\n" + for tool in tools: + result += f"- {tool['name']}: {tool['description']}\n" + return result + + elif action == "call_tool": + tool_name = parameters.get("tool_name") + arguments = parameters.get("arguments", {}) + if not tool_name: + return "错误:必须指定 tool_name 参数" + result = await client.call_tool(tool_name, arguments) + return f"工具 '{tool_name}' 执行结果:\n{result}" + + elif action == "list_resources": + resources = await client.list_resources() + if not resources: + return "没有找到可用的资源" + result = f"找到 {len(resources)} 个资源:\n" + for resource in resources: + result += f"- {resource['uri']}: {resource['name']}\n" + return result + + elif action == "read_resource": + uri = parameters.get("uri") + if not uri: + return "错误:必须指定 uri 参数" + content = await client.read_resource(uri) + return f"资源 '{uri}' 内容:\n{content}" + + elif action == "list_prompts": + prompts = await client.list_prompts() + if not prompts: + return "没有找到可用的提示词" + result = f"找到 {len(prompts)} 个提示词:\n" + for prompt in prompts: + result += f"- {prompt['name']}: {prompt['description']}\n" + return result + + elif action == "get_prompt": + prompt_name = parameters.get("prompt_name") + prompt_arguments = parameters.get("prompt_arguments", {}) + if not prompt_name: + return "错误:必须指定 prompt_name 参数" + messages = await client.get_prompt(prompt_name, prompt_arguments) + result = f"提示词 '{prompt_name}':\n" + for msg in messages: + result += f"[{msg['role']}] {msg['content']}\n" + return result + + else: + return f"错误:不支持的操作 '{action}'" + + # 运行异步操作 + try: + # 检查是否已有运行中的事件循环 + try: + loop = asyncio.get_running_loop() + # 如果有运行中的循环,在新线程中运行新的事件循环 + import concurrent.futures + import threading + + def run_in_thread(): + # 在新线程中创建新的事件循环 + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + try: + return new_loop.run_until_complete(run_mcp_operation()) + finally: + new_loop.close() + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(run_in_thread) + return future.result() + except RuntimeError: + # 没有运行中的循环,直接运行 + return asyncio.run(run_mcp_operation()) + except Exception as e: + return f"异步操作失败: {str(e)}" + + except Exception as e: + return f"MCP 操作失败: {str(e)}" + + def get_parameters(self) -> List[ToolParameter]: + """获取工具参数定义""" + return [ + ToolParameter( + name="action", + type="string", + description="操作类型: list_tools, call_tool, list_resources, read_resource, list_prompts, get_prompt", + required=True + ), + ToolParameter( + name="tool_name", + type="string", + description="工具名称(call_tool 操作需要)", + required=False + ), + ToolParameter( + name="arguments", + type="object", + description="工具参数(call_tool 操作需要)", + required=False + ), + ToolParameter( + name="uri", + type="string", + description="资源 URI(read_resource 操作需要)", + required=False + ), + ToolParameter( + name="prompt_name", + type="string", + description="提示词名称(get_prompt 操作需要)", + required=False + ), + ToolParameter( + name="prompt_arguments", + type="object", + description="提示词参数(get_prompt 操作可选)", + required=False + ) + ] diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/tools/mcp_wrapper_tool.py b/Co-creation-projects/275145-TripPlanner/backend/app/tools/mcp_wrapper_tool.py new file mode 100644 index 00000000..f049d821 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/tools/mcp_wrapper_tool.py @@ -0,0 +1,119 @@ +""" +MCP工具包装器 - 将单个MCP工具包装成HelloAgents Tool + +这个模块将MCP服务器的每个工具展开为独立的HelloAgents Tool对象, +使得Agent可以像调用普通工具一样调用MCP工具。 +""" + +from typing import Dict, Any, Optional, List +from .base import Tool, ToolParameter + + +class MCPWrappedTool(Tool): + """ + MCP工具包装器 - 将单个MCP工具包装成HelloAgents Tool + + 这个类将MCP服务器的一个工具(如 read_file)包装成一个独立的Tool对象。 + Agent调用时只需提供参数,无需了解MCP的内部结构。 + + 示例: + >>> # 内部使用,由MCPTool自动创建 + >>> wrapped_tool = MCPWrappedTool( + ... mcp_tool=mcp_tool_instance, + ... tool_info={ + ... "name": "read_file", + ... "description": "Read a file...", + ... "input_schema": {...} + ... } + ... ) + """ + + def __init__(self, + mcp_tool: 'MCPTool', # type: ignore + tool_info: Dict[str, Any], + prefix: str = ""): + """ + 初始化MCP包装工具 + + Args: + mcp_tool: 父MCP工具实例 + tool_info: MCP工具信息(包含name, description, input_schema) + prefix: 工具名前缀(如 "filesystem_") + """ + self.mcp_tool = mcp_tool + self.tool_info = tool_info + self.mcp_tool_name = tool_info.get('name', 'unknown') + + # 构建工具名:prefix + mcp_tool_name + tool_name = f"{prefix}{self.mcp_tool_name}" if prefix else self.mcp_tool_name + + # 获取描述 + description = tool_info.get('description', f'MCP工具: {self.mcp_tool_name}') + + # 解析参数schema + self._parameters = self._parse_input_schema(tool_info.get('input_schema', {})) + + # 初始化父类 + super().__init__( + name=tool_name, + description=description + ) + + def _parse_input_schema(self, input_schema: Dict[str, Any]) -> List[ToolParameter]: + """ + 将MCP的input_schema转换为HelloAgents的ToolParameter列表 + + Args: + input_schema: MCP工具的input_schema(JSON Schema格式) + + Returns: + ToolParameter列表 + """ + parameters = [] + + properties = input_schema.get('properties', {}) + required_fields = input_schema.get('required', []) + + for param_name, param_info in properties.items(): + param_type = param_info.get('type', 'string') + param_desc = param_info.get('description', '') + is_required = param_name in required_fields + + parameters.append(ToolParameter( + name=param_name, + type=param_type, # 直接使用JSON Schema的类型字符串 + description=param_desc, + required=is_required + )) + + return parameters + + def get_parameters(self) -> List[ToolParameter]: + """ + 获取工具参数定义 + + Returns: + ToolParameter列表 + """ + return self._parameters + + def run(self, params: Dict[str, Any]) -> str: + """ + 执行MCP工具 + + Args: + params: 工具参数(直接传递给MCP工具) + + Returns: + 执行结果 + """ + # 构建MCP调用参数 + mcp_params = { + "action": "call_tool", + "tool_name": self.mcp_tool_name, + "arguments": params + } + + # 调用父MCP工具 + return self.mcp_tool.run(mcp_params) + diff --git a/Co-creation-projects/275145-TripPlanner/backend/app/tools/utils.py b/Co-creation-projects/275145-TripPlanner/backend/app/tools/utils.py new file mode 100644 index 00000000..3fc5738e --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/app/tools/utils.py @@ -0,0 +1,144 @@ +""" +MCP 协议工具函数 + +提供上下文管理、消息解析等辅助功能。 +这些函数主要用于处理 MCP 协议的数据结构。 +""" + +from typing import Dict, Any, List, Optional, Union +import json + + +def create_context( + messages: Optional[List[Dict[str, Any]]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + resources: Optional[List[Dict[str, Any]]] = None, + metadata: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """ + 创建 MCP 上下文对象 + + Args: + messages: 消息列表 + tools: 工具列表 + resources: 资源列表 + metadata: 元数据 + + Returns: + 上下文字典 + + Example: + >>> context = create_context( + ... messages=[{"role": "user", "content": "Hello"}], + ... tools=[{"name": "calculator", "description": "计算器"}] + ... ) + """ + return { + "messages": messages or [], + "tools": tools or [], + "resources": resources or [], + "metadata": metadata or {} + } + + +def parse_context(context: Union[str, Dict[str, Any]]) -> Dict[str, Any]: + """ + 解析 MCP 上下文 + + Args: + context: 上下文字符串或字典 + + Returns: + 解析后的上下文字典 + + Raises: + ValueError: 如果上下文格式无效 + + Example: + >>> context_str = '{"messages": [], "tools": []}' + >>> parsed = parse_context(context_str) + """ + if isinstance(context, str): + try: + context = json.loads(context) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON context: {e}") + + if not isinstance(context, dict): + raise ValueError("Context must be a dictionary or JSON string") + + # 确保必需字段存在 + for field in ["messages", "tools", "resources"]: + context.setdefault(field, []) + context.setdefault("metadata", {}) + + return context + + +def create_error_response( + error_message: str, + error_code: Optional[str] = None, + details: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """ + 创建错误响应 + + Args: + error_message: 错误消息 + error_code: 错误代码 + details: 错误详情 + + Returns: + 错误响应字典 + + Example: + >>> error = create_error_response("Tool not found", "TOOL_NOT_FOUND") + """ + response = { + "error": { + "message": error_message, + "code": error_code or "UNKNOWN_ERROR" + } + } + + if details: + response["error"]["details"] = details + + return response + + +def create_success_response( + data: Any, + metadata: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """ + 创建成功响应 + + Args: + data: 响应数据 + metadata: 元数据 + + Returns: + 成功响应字典 + + Example: + >>> response = create_success_response({"result": 42}) + """ + response = { + "success": True, + "data": data + } + + if metadata: + response["metadata"] = metadata + + return response + + +__all__ = [ + "create_context", + "parse_context", + "create_error_response", + "create_success_response", +] + diff --git a/Co-creation-projects/275145-TripPlanner/backend/requirements.txt b/Co-creation-projects/275145-TripPlanner/backend/requirements.txt new file mode 100644 index 00000000..d068653a --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/requirements.txt @@ -0,0 +1,21 @@ + +fastapi +uvicorn[standard] +pydantic +pydantic-settings +python-dotenv +requests +openai +helloagents +fastmcp +pyjwt +bcrypt +email-validator +redis +sentence-transformers +faiss-cpu +numpy +aiofiles +python-multipart +starlette +huggingface-hub \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/run.py b/Co-creation-projects/275145-TripPlanner/backend/run.py new file mode 100644 index 00000000..bbcdf556 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/run.py @@ -0,0 +1,10 @@ +import uvicorn +from app.services.unsplash_service import UnsplashService +if __name__ == "__main__": + # 启动Uvicorn服务器 + # --reload: 代码变更时自动重启,方便开发 + # --host 0.0.0.0: 监听所有网络接口,允许局域网访问 + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) + # service = UnsplashService(access_key="qoRz2cAQwJD5kPyrN7_2dqdn_1Kfp-DXq4TQKK_dgI8") + # photos = service.search_photos("北京故宫", per_page=1) + # print(photos) \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/tests/test.py b/Co-creation-projects/275145-TripPlanner/backend/tests/test.py new file mode 100644 index 00000000..60d58e97 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/tests/test.py @@ -0,0 +1,7 @@ + +from app.services.unsplash_service import UnsplashService + +if __name__ == "__main__": + service = UnsplashService(access_key="qoRz2cAQwJD5kPyrN7_2dqdn_1Kfp-DXq4TQKK_dgI8") + photos = service.search_photos("beijing", per_page=1) + print(photos) \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/tests/test_city_support.py b/Co-creation-projects/275145-TripPlanner/backend/tests/test_city_support.py new file mode 100644 index 00000000..fc00ec4b --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/tests/test_city_support.py @@ -0,0 +1,203 @@ +""" +测试城市支持逻辑 +验证30个支持城市的验证机制以及不支持城市的警告机制 +""" +import sys +import requests + +# API基础URL +BASE_URL = "http://localhost:8000" + +def test_supported_cities(): + """测试支持的城市""" + print("=" * 80) + print("测试1: 支持的城市(应该成功规划)") + print("=" * 80) + + supported_cities = ["北京", "上海", "杭州", "成都", "三亚", "宁波"] + + for city in supported_cities: + print(f"\n📍 测试城市: {city}") + + trip_request = { + "destination": city, + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": ["历史", "文化"], + "hotel_preferences": ["经济型"], + "budget": "中等" + } + + try: + response = requests.post(f"{BASE_URL}/api/v1/trips/plan", json=trip_request, timeout=180) + + if response.status_code == 200: + print(f" ✅ 规划成功 - 行程标题: {response.json()['trip_title']}") + elif response.status_code == 400: + print(f" ❌ 请求被拒绝 - {response.json()}") + else: + print(f" ⚠️ 状态码: {response.status_code} - {response.text[:100]}") + + except requests.exceptions.ConnectionError: + print(f" ⚠️ 无法连接到服务器,请先启动服务器") + return False + except Exception as e: + print(f" ❌ 发生错误: {e}") + + return True + +def test_unsupported_cities(): + """测试不支持的城市(应该给出警告但仍处理)""" + print("\n" + "=" * 80) + print("测试2: 不支持的城市(应该给出警告但仍尝试规划)") + print("=" * 80) + + unsupported_cities = [ + ("石家庄", "非热门旅游城市"), + ("温州", "小城市"), + ("未知城市123", "无效城市名") + ] + + for city, description in unsupported_cities: + print(f"\n📍 测试城市: {city} ({description})") + + trip_request = { + "destination": city, + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": ["历史"], + "hotel_preferences": ["经济型"], + "budget": "中等" + } + + try: + response = requests.post(f"{BASE_URL}/api/v1/trips/plan", json=trip_request, timeout=180) + + if response.status_code == 200: + print(f" ✅ 规划成功(带有警告)- {response.json()['trip_title']}") + elif response.status_code == 400: + # 检查是否是城市不支持错误 + error_data = response.json() + if "UNSUPPORTED_CITY" in str(error_data): + print(f" ❌ 被拒绝 - 系统拒绝处理不支持的城市") + print(f" 错误信息: {error_data}") + else: + print(f" ⚠️ 其他错误 - {error_data}") + else: + print(f" ⚠️ 状态码: {response.status_code}") + + except requests.exceptions.ConnectionError: + print(f" ⚠️ 无法连接到服务器") + return False + except Exception as e: + print(f" ❌ 发生错误: {e}") + + return True + +def test_edge_cases(): + """测试边界情况""" + print("\n" + "=" * 80) + print("测试3: 边界情况") + print("=" * 80) + + # 测试空城市 + print(f"\n📍 测试: 空城市") + trip_request = { + "destination": "", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": ["历史"], + "hotel_preferences": ["经济型"], + "budget": "中等" + } + + try: + response = requests.post(f"{BASE_URL}/api/v1/trips/plan", json=trip_request, timeout=180) + if response.status_code == 400: + print(f" ✅ 正确拒绝 - {response.json()}") + else: + print(f" ⚠️ 意外响应 - 状态码: {response.status_code}") + except Exception as e: + print(f" ❌ 发生错误: {e}") + + # 测试特殊字符 + print(f"\n📍 测试: 特殊字符城市") + trip_request = { + "destination": "北京@#$%", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": ["历史"], + "hotel_preferences": ["经济型"], + "budget": "中等" + } + + try: + response = requests.post(f"{BASE_URL}/api/v1/trips/plan", json=trip_request, timeout=180) + if response.status_code == 200: + print(f" ✅ 系统尝试处理(可能给出警告)") + elif response.status_code == 400: + print(f" ✅ 正确拒绝 - 无效输入") + else: + print(f" ⚠️ 状态码: {response.status_code}") + except Exception as e: + print(f" ❌ 发生错误: {e}") + + return True + +def display_supported_cities(): + """显示所有支持的城市""" + print("\n" + "=" * 80) + print("当前系统支持的30个热门旅游城市:") + print("=" * 80) + + from app.agents.planner import CITY_BOUNDS + + cities = list(CITY_BOUNDS.keys()) + print(f"\n总计: {len(cities)} 个城市\n") + + for i, city in enumerate(cities, 1): + print(f"{i:2d}. {city}") + + return True + +if __name__ == "__main__": + print("\n" + "=" * 80) + print("🧪 城市支持逻辑测试") + print("=" * 80) + + # 检查服务器是否运行 + try: + response = requests.get(f"{BASE_URL}/health", timeout=5) + if response.status_code != 200: + print("\n⚠️ 服务器未正常运行,请先启动服务器:") + print(" cd trip_planner/backend && python run.py") + sys.exit(1) + except requests.exceptions.ConnectionError: + print("\n⚠️ 无法连接到服务器,请先启动服务器:") + print(" cd trip_planner/backend && python run.py") + sys.exit(1) + + print("\n✅ 服务器运行正常\n") + + # 显示支持的城市列表 + display_supported_cities() + + # 运行测试 + success1 = test_supported_cities() + success2 = test_unsupported_cities() + success3 = test_edge_cases() + + # 总结 + print("\n" + "=" * 80) + print("📊 测试总结") + print("=" * 80) + print(f"支持城市测试: {'✅ 通过' if success1 else '❌ 失败'}") + print(f"不支持城市测试: {'✅ 通过' if success2 else '❌ 失败'}") + print(f"边界情况测试: {'✅ 通过' if success3 else '❌ 失败'}") + + if success1 and success2 and success3: + print("\n🎉 所有测试通过!") + sys.exit(0) + else: + print("\n⚠️ 部分测试失败,请检查日志") + sys.exit(1) \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/tests/test_enhanced_system.py b/Co-creation-projects/275145-TripPlanner/backend/tests/test_enhanced_system.py new file mode 100644 index 00000000..94b8ed58 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/tests/test_enhanced_system.py @@ -0,0 +1,328 @@ +""" +测试增强系统功能 +测试JWT认证和向量记忆服务 +""" +import asyncio +import json +import sys +import requests +from typing import Dict, Any + +# API基础URL +BASE_URL = "http://localhost:8000" + +def test_auth_system(): + """测试认证系统""" + print("=== 测试认证系统 ===") + + # 检查服务器是否运行 + try: + response = requests.get(f"{BASE_URL}/health", timeout=5) + if response.status_code != 200: + print(" 服务器未正常运行,请先启动服务器:") + print(" python run.py") + return None + except requests.exceptions.ConnectionError: + print(" 服务器未运行,请先启动服务器:") + print(" python run.py") + print(" 然后重新运行测试脚本") + return None + + # 1. 测试用户注册 + register_data = { + "username": "testuser", + "email": "test@example.com", + "password": "testpassword123" + } + + print("1. 测试用户注册...") + response = requests.post(f"{BASE_URL}/api/v1/auth/register", json=register_data) + if response.status_code == 200: + auth_data = response.json() + print(f" 注册成功,获得令牌: {auth_data['access_token'][:20]}...") + access_token = auth_data['access_token'] + else: + print(f" 注册失败: {response.status_code} - {response.text}") + return None + + # 2. 测试用户登录 + login_data = { + "email": "test@example.com", + "password": "testpassword123" + } + + print("2. 测试用户登录...") + response = requests.post(f"{BASE_URL}/api/v1/auth/login", json=login_data) + if response.status_code == 200: + auth_data = response.json() + print(f" 登录成功,获得令牌: {auth_data['access_token'][:20]}...") + access_token = auth_data['access_token'] + else: + print(f" 登录失败: {response.status_code} - {response.text}") + return None + + # 3. 测试获取用户信息 + headers = {"Authorization": f"Bearer {access_token}"} + print("3. 测试获取用户信息...") + response = requests.get(f"{BASE_URL}/api/v1/auth/me", headers=headers) + if response.status_code == 200: + user_data = response.json() + print(f" 用户信息: {user_data}") + else: + print(f" 获取用户信息失败: {response.status_code} - {response.text}") + + # 4. 测试访客会话 + print("4. 测试访客会话...") + response = requests.post(f"{BASE_URL}/api/v1/auth/guest") + if response.status_code == 200: + guest_data = response.json() + print(f" 访客会话: {guest_data}") + return access_token + else: + print(f" 创建访客会话失败: {response.status_code} - {response.text}") + return access_token + +def test_trip_planning_with_auth(access_token: str): + """测试带认证的行程规划""" + print("\n=== 测试带认证的行程规划 ===") + + # 检查服务器是否运行 + try: + response = requests.get(f"{BASE_URL}/health", timeout=5) + if response.status_code != 200: + print(" 服务器未正常运行") + return + except requests.exceptions.ConnectionError: + print(" 服务器未运行") + return + + headers = {"Authorization": f"Bearer {access_token}"} + + # 第一次行程规划 + trip_request = { + "destination": "北京", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": ["历史", "文化"], + "hotel_preferences": ["经济型"], + "budget": "中等" + } + + print("1. 第一次行程规划(北京历史文化)...") + response = requests.post(f"{BASE_URL}/api/v1/trips/plan", json=trip_request, headers=headers) + if response.status_code == 200: + trip_data = response.json() + print(f" 行程规划成功: {trip_data['trip_title']}") + print(f" 行程天数: {len(trip_data['days'])}") + else: + print(f" 行程规划失败: {response.status_code} - {response.text}") + return + + # 第二次行程规划(应该利用记忆) + trip_request2 = { + "destination": "北京", + "start_date": "2024-11-01", + "end_date": "2024-11-02", + "preferences": ["历史"], + "hotel_preferences": ["经济型"], + "budget": "中等" + } + + print("2. 第二次行程规划(应该利用记忆)...") + response = requests.post(f"{BASE_URL}/api/v1/trips/plan", json=trip_request2, headers=headers) + if response.status_code == 200: + trip_data = response.json() + print(f" 行程规划成功: {trip_data['trip_title']}") + print(f" 行程天数: {len(trip_data['days'])}") + else: + print(f" 行程规划失败: {response.status_code} - {response.text}") + +def test_vector_memory_service(): + """测试向量记忆服务""" + print("\n=== 测试向量记忆服务 ===") + + try: + from app.services.vector_memory_service import VectorMemoryService + + # 创建向量记忆服务实例 + memory_service = VectorMemoryService() + + # 1. 存储用户偏好 + print("1. 存储用户偏好...") + memory_service.store_user_preference( + user_id="test_user_123", + preference_type="trip_preferences", + preference_data={ + "destination": "北京", + "preferences": ["历史", "文化"], + "budget": "中等" + } + ) + + # 2. 存储用户行程 + print("2. 存储用户行程...") + memory_service.store_user_trip( + user_id="test_user_123", + trip_data={ + "destination": "北京", + "trip_title": "北京历史文化3日游", + "preferences": ["历史", "文化"], + "days": [ + { + "day": 1, + "theme": "皇家建筑探索", + "attractions": [ + {"name": "故宫博物院", "type": "历史文化"}, + {"name": "天坛公园", "type": "历史文化"} + ] + } + ] + } + ) + + # 3. 存储目的地知识 + print("3. 存储目的地知识...") + memory_service.store_destination_knowledge( + destination="北京", + knowledge_data={ + "description": "中国的首都,历史文化名城", + "highlights": ["故宫", "长城", "天坛"], + "best_season": "春秋两季", + "culture": "传统文化与现代文明交融" + } + ) + + # 4. 检索用户记忆 + print("4. 检索用户记忆...") + user_memories = memory_service.retrieve_user_memories( + user_id="test_user_123", + query="北京历史景点", + limit=5 + ) + print(f" 找到 {len(user_memories)} 条用户记忆") + for i, memory in enumerate(user_memories): + print(f" {i+1}. {memory['type']}: {memory.get('text_representation', '')[:50]}...") + + # 5. 检索知识记忆 + print("5. 检索知识记忆...") + knowledge_memories = memory_service.retrieve_knowledge_memories( + query="北京特色景点", + limit=3 + ) + print(f" 找到 {len(knowledge_memories)} 条知识记忆") + for i, memory in enumerate(knowledge_memories): + print(f" {i+1}. {memory['type']}: {memory.get('text_representation', '')[:50]}...") + + # 6. 混合检索 + print("6. 混合检索...") + hybrid_results = memory_service.hybrid_search( + user_id="test_user_123", + query="北京历史景点推荐", + user_limit=3, + knowledge_limit=2 + ) + print(f" 用户记忆: {len(hybrid_results['user_memories'])} 条") + print(f" 知识记忆: {len(hybrid_results['knowledge_memories'])} 条") + + # 7. 获取统计信息 + print("7. 获取统计信息...") + stats = memory_service.get_stats() + print(f" 用户记忆数: {stats['user_memory_count']}") + print(f" 知识记忆数: {stats['knowledge_memory_count']}") + print(f" 向量维度: {stats['vector_dimension']}") + + # 8. 保存索引 + print("8. 保存索引...") + memory_service.save() + print(" 索引保存成功") + + print("\n向量记忆服务测试完成!") + + except Exception as e: + print(f"向量记忆服务测试失败: {e}") + import traceback + traceback.print_exc() + +def test_integration(): + """测试系统集成""" + print("\n=== 测试系统集成 ===") + + try: + from app.agents.planner import PlannerAgent + from app.services.llm_service import LLMService + from app.services.vector_memory_service import VectorMemoryService + from app.models.trip_model import TripPlanRequest + + # 创建服务实例 + llm_service = LLMService() + memory_service = VectorMemoryService() + + # 创建规划器 + planner = PlannerAgent(llm_service=llm_service, memory_service=memory_service) + + # 创建行程请求 + trip_request = TripPlanRequest( + destination="北京", + start_date="2024-10-01", + end_date="2024-10-02", + preferences=["历史", "文化"], + hotel_preferences=["经济型"], + budget="中等" + ) + + # 执行行程规划 + print("执行行程规划...") + plan = planner.plan_trip(request=trip_request, user_id="test_user_123") + + if plan: + print(f"行程规划成功: {plan.trip_title}") + print(f"行程天数: {len(plan.days)}") + print(f"总预算: {plan.total_budget.total}") + else: + print("行程规划失败") + + except Exception as e: + print(f"系统集成测试失败: {e}") + print(" 这可能是由于缺少必要的API密钥或服务配置问题") + print(" 请检查 .env 文件中的配置是否正确") + import traceback + traceback.print_exc() + +def main(): + """主函数""" + print("开始测试增强系统功能...\n") + + # 检查命令行参数 + if len(sys.argv) > 1: + if sys.argv[1] == "--memory-only": + print("仅测试向量记忆服务...") + test_vector_memory_service() + print("\n向量记忆服务测试完成!") + return + elif sys.argv[1] == "--help": + print("测试脚本使用说明:") + print(" python test_enhanced_system.py # 运行所有测试") + print(" python test_enhanced_system.py --memory-only # 仅测试向量记忆服务") + print("\n注意:测试认证和API功能需要先启动服务器:") + print(" python run.py") + return + + # 测试向量记忆服务 + test_vector_memory_service() + + # 测试认证系统 + access_token = test_auth_system() + + # 测试行程规划 + if access_token: + test_trip_planning_with_auth(access_token) + + # 测试系统集成 + test_integration() + + print("\n所有测试完成!") + print("\n如需单独测试向量记忆服务,可以使用:") + print(" python test_enhanced_system.py --memory-only") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/tests/test_model_loading.py b/Co-creation-projects/275145-TripPlanner/backend/tests/test_model_loading.py new file mode 100644 index 00000000..30ee1992 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/tests/test_model_loading.py @@ -0,0 +1,155 @@ +""" +测试向量记忆服务的模型加载优化 +验证HuggingFace镜像和本地缓存功能 +""" +import sys +import io + +# 设置标准输出编码为UTF-8 +if sys.platform == 'win32': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +import os +import sys +from pathlib import Path + +# 将项目根目录添加到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +def test_model_loading(): + """测试模型加载功能""" + print("="*80) + print("测试向量记忆服务 - 模型加载优化") + print("="*80) + + try: + # 1. 检查环境变量配置 + print("\n1. 检查环境变量配置...") + hf_endpoint = os.getenv('HF_ENDPOINT', '未设置') + hf_offline = os.getenv('HF_HUB_OFFLINE', 'false') + hf_cache = os.getenv('HF_HUB_CACHE_DIR', '使用系统默认') + + print(f" HF_ENDPOINT: {hf_endpoint}") + print(f" HF_HUB_OFFLINE: {hf_offline}") + print(f" HF_HUB_CACHE_DIR: {hf_cache}") + + # 2. 初始化向量记忆服务 + print("\n2. 初始化向量记忆服务...") + from app.services.vector_memory_service import VectorMemoryService + from app.observability.logger import default_logger as logger + + # 创建服务实例(会触发模型加载) + memory_service = VectorMemoryService() + + print("\n[OK] 向量记忆服务初始化成功!") + + # 3. 测试模型编码功能 + print("\n3. 测试文本嵌入功能...") + test_texts = [ + "北京故宫是明清两代的皇宫", + "上海外滩是著名的观光景点", + "杭州西湖是中国著名的风景名胜" + ] + + for i, text in enumerate(test_texts, 1): + try: + vector = memory_service._text_to_vector(text) + print(f" 测试文本 {i}: {text[:30]}...") + print(f" 向量维度: {len(vector)}, 范围: [{vector.min():.4f}, {vector.max():.4f}]") + except Exception as e: + print(f" [ERROR] 文本嵌入失败: {e}") + return False + + print("\n[OK] 文本嵌入功能正常!") + + # 4. 测试向量存储和检索 + print("\n4. 测试向量存储和检索...") + memory_service.store_user_preference( + user_id="test_user", + preference_type="test", + preference_data={"test": "data"} + ) + + results = memory_service.retrieve_user_memories( + user_id="test_user", + query="测试", + limit=1 + ) + + if len(results) > 0: + print(f" [OK] 成功存储并检索到 {len(results)} 条记录") + else: + print(f" [WARNING] 未检索到记录") + + # 5. 检查模型信息 + print("\n5. 检查模型信息...") + try: + model_name = memory_service.embedding_model.get_sentence_embedding_dimension() + print(f" 模型输出维度: {model_name}") + print(f" 配置的向量维度: {memory_service.vector_dim}") + except Exception as e: + print(f" [WARNING] 无法获取模型维度信息: {e}") + + # 6. 获取统计信息 + print("\n6. 获取向量索引统计...") + stats = memory_service.get_stats() + print(f" 用户记忆数: {stats['user_memory_count']}") + print(f" 知识记忆数: {stats['knowledge_memory_count']}") + print(f" 向量维度: {stats['vector_dimension']}") + + print("\n" + "="*80) + print("[OK] 所有测试通过!") + print("="*80) + + # 7. 使用建议 + print("\n[TIP] 使用建议:") + print(" • 首次启动会从镜像站点下载模型,请耐心等待") + print(" • 后续启动会使用本地缓存,速度会很快") + print(" • 如果网络问题,可设置 HF_HUB_OFFLINE=true 使用离线模式") + print(" • 可以手动下载模型到指定目录,然后设置 HF_HUB_CACHE_DIR") + + return True + + except Exception as e: + print(f"\n❌ 测试失败: {e}") + import traceback + traceback.print_exc() + return False + +def test_with_different_configs(): + """测试不同配置下的模型加载""" + print("\n\n" + "="*80) + print("测试不同配置场景") + print("="*80) + + # 测试场景1: 使用镜像 + print("\n[SCENARIO] 场景1: 使用HuggingFace镜像") + os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com' + os.environ['HF_HUB_OFFLINE'] = 'false' + print(" 配置: HF_ENDPOINT=https://hf-mirror.com, HF_HUB_OFFLINE=false") + print(" 预期: 从镜像站点下载模型(如果本地无缓存)") + + # 测试场景2: 离线模式 + print("\n[SCENARIO] 场景2: 离线模式") + os.environ['HF_HUB_OFFLINE'] = 'true' + print(" 配置: HF_HUB_OFFLINE=true") + print(" 预期: 仅使用本地缓存,如果缓存不存在则失败") + + # 测试场景3: 自定义缓存目录 + print("\n[SCENARIO] 场景3: 自定义缓存目录") + custom_cache = str(Path.home() / ".cache" / "custom_hf") + os.environ['HF_HUB_CACHE_DIR'] = custom_cache + print(f" 配置: HF_HUB_CACHE_DIR={custom_cache}") + print(" 预期: 模型缓存到指定目录") + +if __name__ == "__main__": + print("\n[START] 开始测试...") + + # 运行主测试 + success = test_model_loading() + + # 显示不同配置场景 + test_with_different_configs() + + # 退出 + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/tests/test_parallel_optimization.py b/Co-creation-projects/275145-TripPlanner/backend/tests/test_parallel_optimization.py new file mode 100644 index 00000000..2c77284b --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/tests/test_parallel_optimization.py @@ -0,0 +1,175 @@ +""" +性能优化测试 - 并行执行对比串行执行 +测试景点、酒店、天气查询的并行化性能提升 +""" +import time +import json +from app.models.trip_model import TripPlanRequest +from app.agents.planner import PlannerAgent +from app.services.llm_service import LLMService +from app.services.vector_memory_service import VectorMemoryService +from app.observability.logger import default_logger as logger +from app.config import settings +import sys +import traceback + +def create_test_request(destination="北京", days=3): + """创建测试请求""" + return TripPlanRequest( + destination=destination, + start_date="2024-10-01", + end_date=f"2024-10-{days:02d}", + preferences=["历史", "文化"], + hotel_preferences=["经济型"], + budget="中等" + ) + +def test_single_request(destination="北京", label="测试"): + """测试单个请求的性能""" + logger.info(f"\n{'='*60}") + logger.info(f"开始 {label} - 目的地: {destination}") + logger.info(f"{'='*60}") + + try: + # 初始化服务 + logger.info("初始化服务...") + llm_service = LLMService() + memory_service = VectorMemoryService() + planner = PlannerAgent(llm_service=llm_service, memory_service=memory_service) + + # 创建测试请求 + request = create_test_request(destination) + + # 开始计时 + start_time = time.time() + + # 执行行程规划 + logger.info(f"\n执行行程规划...") + result = planner.plan_trip(request, user_id=f"test_{destination}") + + # 结束计时 + end_time = time.time() + elapsed_time = end_time - start_time + + # 输出结果 + logger.info(f"\n{'='*60}") + logger.info(f"✅ {label} 完成!") + logger.info(f"{'='*60}") + logger.info(f"⏱️ 总耗时: {elapsed_time:.2f} 秒") + + if result: + logger.info(f"📝 行程标题: {result.trip_title}") + logger.info(f"📅 行程天数: {len(result.days)} 天") + logger.info(f"💰 总预算: {result.total_budget.total if result.total_budget else 'N/A'}") + + # 统计景点数量 + total_attractions = sum(len(day.attractions) for day in result.days) + logger.info(f"🏛️ 景点数量: {total_attractions} 个") + + return elapsed_time, True + else: + logger.error("❌ 规划结果为 None") + return elapsed_time, False + + except Exception as e: + logger.error(f"❌ {label} 执行失败: {e}") + logger.error(traceback.format_exc()) + return 0, False + +def run_performance_test(): + """运行性能测试""" + logger.info("\n" + "="*80) + logger.info("🚀 性能优化测试 - 并行执行 vs 串行执行") + logger.info("="*80) + + # 测试参数 + destinations = ["北京", "上海", "杭州"] + + results = [] + + for destination in destinations: + logger.info(f"\n\n{'#'*80}") + logger.info(f"测试目的地: {destination}") + logger.info(f"{'#'*80}") + + # 执行测试 + elapsed_time, success = test_single_request(destination, f"{destination}行程规划") + + results.append({ + "destination": destination, + "elapsed_time": elapsed_time, + "success": success + }) + + # 输出总结 + logger.info("\n\n" + "="*80) + logger.info("📊 性能测试总结") + logger.info("="*80) + + total_time = 0 + success_count = 0 + + for i, result in enumerate(results, 1): + status = "✅ 成功" if result["success"] else "❌ 失败" + logger.info(f"{i}. {result['destination']}: {result['elapsed_time']:.2f} 秒 - {status}") + total_time += result['elapsed_time'] + if result['success']: + success_count += 1 + + logger.info("-" * 80) + logger.info(f"总耗时: {total_time:.2f} 秒") + logger.info(f"成功率: {success_count}/{len(results)} ({100*success_count/len(results):.1f}%)") + + # 计算平均时间 + if success_count > 0: + avg_time = total_time / success_count + logger.info(f"平均耗时: {avg_time:.2f} 秒/请求") + + # 性能对比预期 + logger.info("\n" + "="*80) + logger.info("📈 性能对比") + logger.info("="*80) + + # 根据PERFORMANCE_ANALYSIS.md中的估算 + # 串行执行:景点(3-5s) + 酒店(3-5s) + 天气(2-3s) = 8-13s + serial_estimate = 11 # 取中间值 + # 并行执行:三个查询并行 = max(3-5s, 3-5s, 2-3s) = 3-5s + parallel_estimate = 4 # 取中间值 + + if success_count > 0: + actual_avg = total_time / success_count + improvement = (serial_estimate - actual_avg) / serial_estimate * 100 + + logger.info(f"串行执行预估: {serial_estimate:.0f} 秒") + logger.info(f"并行执行预估: {parallel_estimate:.0f} 秒") + logger.info(f"实际执行平均: {actual_avg:.2f} 秒") + logger.info("-" * 80) + logger.info(f"⚡ 性能提升: ~{improvement:.1f}%") + + if actual_avg < parallel_estimate * 1.5: # 容错范围50% + logger.info("✅ 优化效果显著!") + elif actual_avg < serial_estimate * 0.8: # 比串行快20%以上 + logger.info("✅ 优化有效!") + else: + logger.info("⚠️ 优化效果不明显,可能需要进一步调优") + + logger.info("="*80) + + return results + +if __name__ == "__main__": + try: + results = run_performance_test() + logger.info("\n✅ 性能测试完成!") + + # 保存结果到文件 + with open("performance_test_results.json", "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + logger.info("📄 测试结果已保存到: performance_test_results.json") + + except KeyboardInterrupt: + logger.warning("\n⚠️ 测试被用户中断") + except Exception as e: + logger.error(f"\n❌ 测试失败: {e}") + logger.error(traceback.format_exc()) + sys.exit(1) \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/tests/test_trip_deletion.py b/Co-creation-projects/275145-TripPlanner/backend/tests/test_trip_deletion.py new file mode 100644 index 00000000..05540a47 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/tests/test_trip_deletion.py @@ -0,0 +1,237 @@ +""" +测试行程删除功能 +验证权限验证、原子性操作和边界情况处理 +""" +import sys +import requests +import json +from datetime import datetime + +# API基础URL +BASE_URL = "http://localhost:8000" + + +def test_trip_deletion(): + """测试行程删除功能""" + print("="*80) + print("🧪 行程删除功能测试") + print("="*80) + + # 检查服务器是否运行 + try: + response = requests.get(f"{BASE_URL}/health", timeout=5) + if response.status_code != 200: + print("❌ 服务器未正常运行,请先启动服务器: python run.py") + return False + except requests.exceptions.ConnectionError: + print("❌ 服务器未运行,请先启动服务器: python run.py") + return False + + print("✅ 服务器运行正常\n") + + # 1. 测试用户注册和登录 + print("1. 测试用户认证...") + register_data = { + "username": "test_deletion_user", + "password": "testpassword123" + } + + try: + response = requests.post(f"{BASE_URL}/api/v1/auth/register", json=register_data) + if response.status_code == 200: + auth_data = response.json() + access_token = auth_data['access_token'] + print(f" ✅ 用户注册成功") + else: + # 用户可能已存在,直接登录 + login_data = { + "username": "test_deletion_user", + "password": "testpassword123" + } + response = requests.post(f"{BASE_URL}/api/v1/auth/login", json=login_data) + if response.status_code == 200: + auth_data = response.json() + access_token = auth_data['access_token'] + print(f" ✅ 用户登录成功") + else: + print(f" ❌ 用户认证失败: {response.text}") + return False + except Exception as e: + print(f" ❌ 认证过程出错: {e}") + return False + + headers = {"Authorization": f"Bearer {access_token}"} + + # 2. 创建一个测试行程 + print("\n2. 创建测试行程...") + trip_request = { + "destination": "北京", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": ["历史", "文化"], + "hotel_preferences": ["经济型"], + "budget": "中等" + } + + try: + response = requests.post(f"{BASE_URL}/api/v1/trips/plan", json=trip_request, headers=headers, timeout=180) + if response.status_code == 200: + trip_data = response.json() + trip_id = trip_data.get('id') + print(f" ✅ 行程创建成功,TripID: {trip_id}") + else: + print(f" ❌ 行程创建失败: {response.text}") + return False + except Exception as e: + print(f" ❌ 创建行程时出错: {e}") + return False + + # 3. 测试正常删除 + print("\n3. 测试正常删除...") + try: + response = requests.delete(f"{BASE_URL}/api/v1/trips/{trip_id}", headers=headers) + if response.status_code == 200: + print(f" ✅ 行程删除成功") + else: + print(f" ❌ 行程删除失败: {response.text}") + return False + + # 验证行程确实被删除 + response = requests.get(f"{BASE_URL}/api/v1/trips/{trip_id}", headers=headers) + if response.status_code == 404: + print(f" ✅ 验证通过:行程已不存在") + else: + print(f" ❌ 验证失败:行程仍然存在") + return False + except Exception as e: + print(f" ❌ 删除操作时出错: {e}") + return False + + # 4. 测试删除不存在的行程 + print("\n4. 测试删除不存在的行程...") + fake_trip_id = "00000000-0000-0000-0000-000000000000" + try: + response = requests.delete(f"{BASE_URL}/api/v1/trips/{fake_trip_id}", headers=headers) + if response.status_code == 404: + print(f" ✅ 正确返回404错误") + else: + print(f" ⚠️ 意外响应: 状态码 {response.status_code}") + except Exception as e: + print(f" ❌ 测试时出错: {e}") + + # 5. 测试跨用户删除(权限验证) + print("\n5. 测试跨用户删除(权限验证)...") + + # 创建第二个用户 + register_data2 = { + "username": "test_user2", + "password": "testpassword123" + } + + try: + response = requests.post(f"{BASE_URL}/api/v1/auth/register", json=register_data2) + if response.status_code == 200: + auth_data2 = response.json() + access_token2 = auth_data2['access_token'] + print(f" ✅ 第二个用户创建成功") + else: + print(f" ⚠️ 第二个用户创建可能失败(可能已存在),尝试登录") + login_data2 = { + "username": "test_user2", + "password": "testpassword123" + } + response = requests.post(f"{BASE_URL}/api/v1/auth/login", json=login_data2) + if response.status_code == 200: + auth_data2 = response.json() + access_token2 = auth_data2['access_token'] + print(f" ✅ 第二个用户登录成功") + else: + print(f" ❌ 第二个用户认证失败") + access_token2 = None + except Exception as e: + print(f" ❌ 第二个用户认证出错: {e}") + access_token2 = None + + if access_token2: + # 第一个用户再次创建一个行程 + print(" 创建测试行程...") + try: + response = requests.post(f"{BASE_URL}/api/v1/trips/plan", json=trip_request, headers=headers, timeout=180) + if response.status_code == 200: + trip_data = response.json() + trip_id = trip_data.get('id') + print(f" ✅ 行程创建成功,TripID: {trip_id}") + + # 尝试用第二个用户删除第一个用户的行程 + print(f" 尝试用第二个用户删除第一个用户的行程...") + headers2 = {"Authorization": f"Bearer {access_token2}"} + response = requests.delete(f"{BASE_URL}/api/v1/trips/{trip_id}", headers=headers2) + + if response.status_code == 404 or response.status_code == 403: + print(f" ✅ 权限验证通过:第二个用户无法删除第一个用户的行程") + else: + print(f" ❌ 权限验证失败:状态码 {response.status_code}") + print(f" 响应: {response.text}") + else: + print(f" ⚠️ 行程创建失败,跳过权限测试") + except Exception as e: + print(f" ❌ 权限测试出错: {e}") + + # 6. 测试删除后行程列表更新 + print("\n6. 测试删除后行程列表更新...") + try: + # 创建多个行程 + print(" 创建多个测试行程...") + trip_ids = [] + for i in range(3): + response = requests.post(f"{BASE_URL}/api/v1/trips/plan", json=trip_request, headers=headers, timeout=180) + if response.status_code == 200: + trip_data = response.json() + trip_ids.append(trip_data.get('id')) + + print(f" 创建了 {len(trip_ids)} 个行程") + + # 获取行程列表 + response = requests.get(f"{BASE_URL}/api/v1/trips/list", headers=headers) + if response.status_code == 200: + trips_before = response.json() + print(f" 删除前列表中有 {len(trips_before)} 个行程") + + # 删除其中一个 + if trip_ids: + response = requests.delete(f"{BASE_URL}/api/v1/trips/{trip_ids[0]}", headers=headers) + if response.status_code == 200: + print(f" ✅ 删除成功") + + # 再次获取列表 + response = requests.get(f"{BASE_URL}/api/v1/trips/list", headers=headers) + if response.status_code == 200: + trips_after = response.json() + print(f" 删除后列表中有 {len(trips_after)} 个行程") + + if len(trips_after) == len(trips_before) - 1: + print(f" ✅ 列表正确更新") + else: + print(f" ❌ 列表更新异常") + else: + print(f" ❌ 删除失败") + except Exception as e: + print(f" ❌ 列表更新测试出错: {e}") + + # 总结 + print("\n" + "="*80) + print("📊 测试总结") + print("="*80) + print("✅ 行程删除功能测试完成") + print("\n修复内容:") + print("1. ✅ 添加行程存在性验证") + print("2. ✅ 添加用户权限验证") + print("3. ✅ 使用Redis管道确保原子性操作") + print("4. ✅ 改进返回值处理") + + return True + + +if __name__ == "__main__": + success = test_trip_deletion() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/tests/test_unsplash_with_attractions.py b/Co-creation-projects/275145-TripPlanner/backend/tests/test_unsplash_with_attractions.py new file mode 100644 index 00000000..dc6f7400 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/tests/test_unsplash_with_attractions.py @@ -0,0 +1,87 @@ +""" +测试景点图片获取逻辑 +直接模拟 planner.py 中的图片搜索流程,方便快速调试 +""" +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from app.services.unsplash_service import UnsplashService + + +def test_attraction_images(): + """测试真实景点名称的图片搜索""" + + # 初始化 Unsplash 服务 + service = UnsplashService(access_key="qoRz2cAQwJD5kPyrN7_2dqdn_1Kfp-DXq4TQKK_dgI8") + + # 模拟几个真实的景点名称(来自北京行程) + test_cases = [ + { + "attraction_name": "故宫博物院", + "destination": "北京" + }, + { + "attraction_name": "天安门广场", + "destination": "北京" + }, + { + "attraction_name": "八达岭长城", + "destination": "北京" + }, + { + "attraction_name": "颐和园", + "destination": "北京" + }, + { + "attraction_name": "中国明清两代的皇家宫殿,也是世界上现存规模最大、保存最为完整的木质结构古建筑之一。", + "destination": "北京" + } + ] + + print("=" * 80) + print("开始测试景点图片获取逻辑") + print("=" * 80) + + for idx, case in enumerate(test_cases, 1): + attraction_name = case["attraction_name"] + destination = case["destination"] + + print(f"\n【测试 {idx}/{len(test_cases)}】") + print(f"景点名称: {attraction_name}") + print(f"目标城市: {destination}") + print("-" * 80) + + # 构造搜索关键词(与 planner.py 保持一致) + search_queries = [ + f"{attraction_name} {destination}", # 完整名称 + 城市 + f"{attraction_name}", # 只用景点名 + f"{destination} landmark" # 兜底:城市地标 + ] + + image_url = None + for query in search_queries: + print(f" 尝试关键词: '{query}'") + image_url = service.get_photo_url(query) + + if image_url: + print(f" ✅ 成功获取图片: {image_url}") + break + else: + print(f" ⚠️ 未找到图片,尝试下一个关键词") + + if not image_url: + print(f" ❌ 所有关键词均未找到图片") + + print("-" * 80) + + print("\n" + "=" * 80) + print("测试完成") + print("=" * 80) + + +if __name__ == "__main__": + test_attraction_images() diff --git a/Co-creation-projects/275145-TripPlanner/backend/vector_memory/knowledge_memory.index b/Co-creation-projects/275145-TripPlanner/backend/vector_memory/knowledge_memory.index new file mode 100644 index 00000000..70afd87e Binary files /dev/null and b/Co-creation-projects/275145-TripPlanner/backend/vector_memory/knowledge_memory.index differ diff --git a/Co-creation-projects/275145-TripPlanner/backend/vector_memory/knowledge_metadata.json b/Co-creation-projects/275145-TripPlanner/backend/vector_memory/knowledge_metadata.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/vector_memory/knowledge_metadata.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/backend/vector_memory/user_memory.index b/Co-creation-projects/275145-TripPlanner/backend/vector_memory/user_memory.index new file mode 100644 index 00000000..6aecd1e4 Binary files /dev/null and b/Co-creation-projects/275145-TripPlanner/backend/vector_memory/user_memory.index differ diff --git a/Co-creation-projects/275145-TripPlanner/backend/vector_memory/user_metadata.json b/Co-creation-projects/275145-TripPlanner/backend/vector_memory/user_metadata.json new file mode 100644 index 00000000..2bbd2506 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/backend/vector_memory/user_metadata.json @@ -0,0 +1,5504 @@ +{ + "0": { + "user_id": "30115041-7b0b-4c4c-92c7-b604c9ef593a", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "trip_result": "```json\n{\n \"trip_title\": \"杭州自然休闲之旅\",\n \"total_budget\": {\n \"transport_cost\": 200.0,\n \"dining_cost\": 400.0,\n \"hotel_cost\": 600.0,\n \"attraction_ticket_cost\": 200.0,\n \"total\": 1400.0\n },\n" + }, + "text_representation": "偏好类型: trip_planning 目的地: 杭州 旅行偏好: 自然, 休闲", + "timestamp": "2025-12-29T17:04:54.905166", + "similarity_score": 0.6542657613754272 + }, + "1": { + "user_id": "30115041-7b0b-4c4c-92c7-b604c9ef593a", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2025-12-29T17:15:13.695721", + "similarity_score": 0.6112585067749023 + }, + "2": { + "user_id": "30115041-7b0b-4c4c-92c7-b604c9ef593a", + "type": "trip", + "data": { + "destination": "杭州", + "start_date": "2026-01-05", + "end_date": "2026-01-06", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然休闲之旅", + "days": [ + { + "day": 1, + "theme": "西湖与飞来峰自然风光", + "weather": { + "date": "2026-01-05", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "10", + "night_temp": "-2", + "day_wind": "东风3级", + "night_wind": "东北风3级" + }, + "recommended_hotel": { + "name": "杭州西湖区瑞通快捷酒店", + "address": "杭州市西湖区断桥路2号", + "location": { + "lat": 30.2545, + "lng": 120.1245 + }, + "price": "200元/晚", + "rating": "4.2", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "西湖", + "type": "自然", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "西湖是中国最著名的湖泊之一,以其美丽的自然风光和丰富的文化底蕴而闻名。", + "address": "浙江省杭州市西湖区岳王路1号", + "location": { + "lat": 30.2409, + "lng": 120.1458 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1753365324076-a2cc26d16e98?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTglQTUlQkYlRTYlQjklOTYlMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY2OTk5NzA4fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "飞来峰", + "type": "自然", + "rating": "4.6", + "suggested_duration_hours": 2.0, + "description": "飞来峰位于西湖风景区内,以其奇特的岩石和宁静的氛围吸引了众多游客。", + "address": "浙江省杭州市西湖区玉泉路1号", + "location": { + "lat": 30.2399, + "lng": 120.1468 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1696513790795-e8fe7f017f02?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOUQlQUQlRTUlQjclOUUlMjBsYW5kbWFya3xlbnwwfHx8fDE3NjY5OTkxMDJ8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "30" + } + ], + "dinings": [ + { + "name": "楼外楼", + "address": "浙江省杭州市西湖区平海路1号", + "location": { + "lat": 30.2408, + "lng": 120.1457 + }, + "cost_per_person": "80", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 160.0, + "hotel_cost": 200.0, + "attraction_ticket_cost": 60.0, + "total": 520.0 + } + }, + { + "day": 2, + "theme": "灵隐寺与钱塘江大桥", + "weather": { + "date": "2026-01-06", + "day_weather": "多云", + "night_weather": "阴", + "day_temp": "8", + "night_temp": "-1", + "day_wind": "北风2级", + "night_wind": "北风2级" + }, + "recommended_hotel": { + "name": "杭州灵隐宾馆", + "address": "杭州市西湖区灵隐路1号", + "location": { + "lat": 30.2845, + "lng": 120.1445 + }, + "price": "250元/晚", + "rating": "4.0", + "distance_to_main_attraction_km": 0.3 + }, + "attractions": [ + { + "name": "灵隐寺", + "type": "文化", + "rating": "4.5", + "suggested_duration_hours": 3.0, + "description": "灵隐寺以佛教寺庙著称,但其周围的自然环境也十分优美,适合休闲游览。", + "address": "浙江省杭州市西湖区灵隐路1号", + "location": { + "lat": 30.2845, + "lng": 120.1445 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1753364971896-a55ebc73361f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTclODElQjUlRTklOUElOTAlRTUlQUYlQkElMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY2OTk5MDk4fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "知味观", + "address": "浙江省杭州市上城区延安路123号", + "location": { + "lat": 30.2523, + "lng": 120.1845 + }, + "cost_per_person": "60", + "rating": "4.4" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 120.0, + "hotel_cost": 250.0, + "attraction_ticket_cost": 0.0, + "total": 420.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 杭州 时间: 2026-01-05 到 2026-01-06 偏好: 自然, 休闲 行程标题: 杭州自然休闲之旅 景点: 西湖, 飞来峰, 灵隐寺", + "timestamp": "2025-12-29T17:15:13.733172", + "similarity_score": 0.5993242263793945 + }, + "3": { + "user_id": "30115041-7b0b-4c4c-92c7-b604c9ef593a", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2025-12-29T17:15:13.767846", + "similarity_score": 0.5877498388290405 + }, + "4": { + "user_id": "30115041-7b0b-4c4c-92c7-b604c9ef593a", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2025-12-29T17:25:17.179786" + }, + "5": { + "user_id": "30115041-7b0b-4c4c-92c7-b604c9ef593a", + "type": "trip", + "data": { + "destination": "杭州", + "start_date": "2026-01-05", + "end_date": "2026-01-06", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然休闲之旅", + "days": [ + { + "day": 1, + "theme": "西湖周边自然休闲", + "weather": { + "date": "2026-01-05", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "7", + "night_temp": "-2", + "day_wind": "微风1级", + "night_wind": "微风1级" + }, + "recommended_hotel": { + "name": "杭州西湖四季民家民宿", + "address": "浙江省杭州市西湖区杨公堤18号", + "location": { + "lat": 30.259432, + "lng": 120.135552 + }, + "price": "经济型", + "rating": "4.5", + "distance_to_main_attraction_km": 0.8 + }, + "attractions": [ + { + "name": "西湖", + "type": "自然", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "西湖以其美丽的自然风光和丰富的文化遗产而闻名,适合在晴朗的天气下漫步湖畔,欣赏断桥残雪和苏堤春晓等经典景点。", + "address": "浙江省杭州市西湖区", + "location": { + "lat": 30.267129, + "lng": 120.139048 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1753365324076-a2cc26d16e98?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTglQTUlQkYlRTYlQjklOTYlMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY2OTk5NzA4fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "飞来峰", + "type": "自然", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "飞来峰位于玉皇山上,是一处风景秀丽的自然景观区,也是杭州著名的佛教石窟艺术遗址,建议上午前往游览。", + "address": "浙江省杭州市西湖区玉皇山", + "location": { + "lat": 30.268735, + "lng": 120.182852 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1696513790795-e8fe7f017f02?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOUQlQUQlRTUlQjclOUUlMjBsYW5kbWFya3xlbnwwfHx8fDE3NjY5OTkxMDJ8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "20" + } + ], + "dinings": [ + { + "name": "湖滨银泰in77", + "address": "浙江省杭州市上城区延安路222号", + "location": { + "lat": 30.254036, + "lng": 120.158685 + }, + "cost_per_person": "80", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 130.0, + "total": 880.0 + } + }, + { + "day": 2, + "theme": "千岛湖与茶博馆", + "weather": { + "date": "2026-01-06", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "9", + "night_temp": "-1", + "day_wind": "微风1级", + "night_wind": "微风1级" + }, + "recommended_hotel": { + "name": "杭州灵隐寺附近民宿", + "address": "浙江省杭州市西湖区灵隐路1号", + "location": { + "lat": 30.268735, + "lng": 120.182852 + }, + "price": "经济型", + "rating": "4.2", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "茶博馆", + "type": "自然", + "rating": "4.3", + "suggested_duration_hours": 2.0, + "description": "了解杭州茶文化的绝佳去处,同时也提供了一些自然景观的参观。", + "address": "浙江省杭州市西湖区龙井路28号", + "location": { + "lat": 30.268735, + "lng": 120.182852 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1686757569201-21c37c23eb63?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTglOEMlQjYlRTUlOEQlOUElRTklQTYlODYlMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3MDAwMzE4fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "30" + } + ], + "dinings": [], + "budget": { + "transport_cost": 150.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 230.0, + "total": 980.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 杭州 时间: 2026-01-05 到 2026-01-06 偏好: 自然, 休闲 行程标题: 杭州自然休闲之旅 景点: 西湖, 飞来峰, 茶博馆", + "timestamp": "2025-12-29T17:25:17.214332" + }, + "6": { + "user_id": "30115041-7b0b-4c4c-92c7-b604c9ef593a", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2025-12-29T17:25:17.247022" + }, + "7": { + "user_id": "d10ff920-d809-405d-89ce-33dce5b47886", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "成都美食与休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 成都 旅行偏好: 美食, 休闲 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2025-12-30T15:15:18.583097" + }, + "8": { + "user_id": "d10ff920-d809-405d-89ce-33dce5b47886", + "type": "trip", + "data": { + "destination": "成都", + "start_date": "2026-01-06", + "end_date": "2026-01-09", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "成都美食与休闲之旅", + "days": [ + { + "day": 1, + "theme": "美食探索", + "weather": { + "date": "2026-01-06", + "day_weather": "多云", + "night_weather": "多云", + "day_temp": "5", + "night_temp": "15", + "day_wind": "东北风2级", + "night_wind": "东北风2级" + }, + "recommended_hotel": { + "name": "成都锦江宾馆", + "address": "四川省成都市青羊区人民中路二段1号", + "location": { + "lat": 30.673871, + "lng": 104.065768 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "成都小吃街", + "type": "美食", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "成都小吃街是一条充满成都地道小吃的步行街,如担担面、龙抄手、钟水饺等。", + "address": "四川省成都市青羊区人民中路一段7号", + "location": { + "lat": 30.674056, + "lng": 104.066164 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1601949117553-68a5936cf9a4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlODglOTAlRTklODMlQkQlMjBsYW5kbWFya3xlbnwwfHx8fDE3NjcwNzg5MDV8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "蜀九香火锅店", + "address": "四川省成都市武侯区玉林西路", + "location": { + "lat": 30.674556, + "lng": 104.058889 + }, + "cost_per_person": "120", + "rating": "4.6" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 0.0, + "total": 700.0 + } + }, + { + "day": 2, + "theme": "文化体验", + "weather": { + "date": "2026-01-07", + "day_weather": "晴朗", + "night_weather": "晴朗", + "day_temp": "4", + "night_temp": "16", + "day_wind": "南风2级", + "night_wind": "南风2级" + }, + "recommended_hotel": { + "name": "成都世纪城诺富特大酒店", + "address": "四川省成都市天府大道北段1700号", + "location": { + "lat": 30.532234, + "lng": 104.062371 + }, + "price": "800元/晚", + "rating": "4.8", + "distance_to_main_attraction_km": 3.5 + }, + "attractions": [ + { + "name": "宽窄巷子", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "宽窄巷子是一个融合了传统与现代文化的旅游区,其中有许多提供地道成都美食的小店。", + "address": "四川省成都市青羊区长顺街附近", + "location": { + "lat": 30.674111, + "lng": 104.066022 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1601949117553-68a5936cf9a4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlODglOTAlRTklODMlQkQlMjBsYW5kbWFya3xlbnwwfHx8fDE3NjcwNzg5MDV8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "老码头火锅", + "address": "四川省成都市武侯区玉林西路", + "location": { + "lat": 30.674556, + "lng": 104.058889 + }, + "cost_per_person": "150", + "rating": "4.8" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 250.0, + "hotel_cost": 800.0, + "attraction_ticket_cost": 0.0, + "total": 1150.0 + } + }, + { + "day": 3, + "theme": "休闲时光", + "weather": { + "date": "2026-01-08", + "day_weather": "阴天", + "night_weather": "阴天", + "day_temp": "6", + "night_temp": "14", + "day_wind": "东南风2级", + "night_wind": "东南风2级" + }, + "recommended_hotel": { + "name": "成都博舍酒店", + "address": "四川省成都市青羊区东城根上街95号", + "location": { + "lat": 30.673405, + "lng": 104.062268 + }, + "price": "600元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 0.7 + }, + "attractions": [ + { + "name": "太古里", + "type": "休闲", + "rating": "4.6", + "suggested_duration_hours": 3.0, + "description": "太古里不仅有时尚的购物场所,还有许多特色餐饮店,可以品尝到成都的美味佳肴。", + "address": "四川省成都市锦江区人民东路66号", + "location": { + "lat": 30.668977, + "lng": 104.060241 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1627542424169-3122c2a3c998?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQUElRTUlOEYlQTQlRTklODclOEMlMjAlRTYlODglOTAlRTklODMlQkR8ZW58MHx8fHwxNzY3MDc4OTE0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "陈麻婆豆腐", + "address": "四川省成都市青羊区太古里附近", + "location": { + "lat": 30.668977, + "lng": 104.060241 + }, + "cost_per_person": "100", + "rating": "4.7" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 200.0, + "hotel_cost": 600.0, + "attraction_ticket_cost": 0.0, + "total": 900.0 + } + }, + { + "day": 4, + "theme": "自然与小雨体验", + "weather": { + "date": "2026-01-09", + "day_weather": "小雨", + "night_weather": "小雨", + "day_temp": "7", + "night_temp": "13", + "day_wind": "北风3级", + "night_wind": "北风3级" + }, + "recommended_hotel": { + "name": "成都华尔道夫酒店", + "address": "四川省成都市武侯祠大街19号", + "location": { + "lat": 30.663258, + "lng": 104.060241 + }, + "price": "1000元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "锦里", + "type": "休闲", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "锦里是一个以三国文化为主题的旅游区,里面有许多小吃摊位,供应各种成都特色美食。", + "address": "四川省成都市武侯祠大街231号", + "location": { + "lat": 30.663158, + "lng": 104.060241 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1601949117553-68a5936cf9a4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlODglOTAlRTklODMlQkQlMjBsYW5kbWFya3xlbnwwfHx8fDE3NjcwNzg5MDV8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "蓉城人家", + "address": "四川省成都市武侯祠大街231号附近", + "location": { + "lat": 30.663158, + "lng": 104.060241 + }, + "cost_per_person": "120", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 120.0, + "hotel_cost": 1000.0, + "attraction_ticket_cost": 0.0, + "total": 1220.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 成都 时间: 2026-01-06 到 2026-01-09 偏好: 美食, 休闲 行程标题: 成都美食与休闲之旅 景点: 成都小吃街, 宽窄巷子, 太古里, 锦里", + "timestamp": "2025-12-30T15:15:18.632012" + }, + "9": { + "user_id": "d10ff920-d809-405d-89ce-33dce5b47886", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 成都 旅行偏好: 美食, 休闲 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2025-12-30T15:15:18.669599" + }, + "10": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然与休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-05T00:15:23.405444", + "similarity_score": 0.6112585067749023 + }, + "11": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "trip", + "data": { + "destination": "杭州", + "start_date": "2026-01-11", + "end_date": "2026-01-12", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然与休闲之旅", + "days": [ + { + "day": 1, + "theme": "西湖休闲游赏", + "weather": { + "date": "2026-01-11", + "day_weather": "多云转晴", + "night_weather": "晴", + "day_temp": "10", + "night_temp": "2", + "day_wind": "东风3级", + "night_wind": "东北风2级" + }, + "recommended_hotel": { + "name": "杭州西湖银泰城喜来登酒店", + "address": "杭州市西湖区环城西路5号", + "location": { + "lat": 30.2592, + "lng": 120.1325 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 0.8 + }, + "attractions": [ + { + "name": "西湖", + "type": "自然", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "西湖四季景色各异,是杭州最著名的自然景观之一。您可以在这里漫步湖畔,欣赏美景。", + "address": "杭州市西湖区断桥路1号", + "location": { + "lat": 30.2592, + "lng": 120.1325 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1753365310761-012a341f9210?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTglQTUlQkYlRTYlQjklOTYlMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NTQzMzA0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "楼外楼", + "address": "杭州市西湖区平湖门直街10号", + "location": { + "lat": 30.2573, + "lng": 120.1342 + }, + "cost_per_person": "80", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 0.0, + "total": 650.0 + } + }, + { + "day": 2, + "theme": "钱塘江与文化遗址", + "weather": { + "date": "2026-01-12", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "12", + "night_temp": "3", + "day_wind": "东北风2级", + "night_wind": "北风1级" + }, + "recommended_hotel": null, + "attractions": [ + { + "name": "良渚古城遗址公园", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "了解杭州悠久历史文化的自然遗址,环境优美。", + "address": "杭州市余杭区良渚街道", + "location": { + "lat": 30.4567, + "lng": 120.4567 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1696513790795-e8fe7f017f02?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOUQlQUQlRTUlQjclOUUlMjBsYW5kbWFya3xlbnwwfHx8fDE3Njc1NDMzMjR8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + } + ], + "dinings": [ + { + "name": "知味观", + "address": "杭州市上城区中山南路270号", + "location": { + "lat": 30.2545, + "lng": 120.1825 + }, + "cost_per_person": "60", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 300.0, + "attraction_ticket_cost": 250.0, + "total": 700.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 杭州 时间: 2026-01-11 到 2026-01-12 偏好: 自然, 休闲 行程标题: 杭州自然与休闲之旅 景点: 西湖, 良渚古城遗址公园", + "timestamp": "2026-01-05T00:15:23.454594", + "similarity_score": 0.5961674451828003 + }, + "12": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-05T00:15:23.489408", + "similarity_score": 0.5877498388290405 + }, + "13": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "北京历史与美食之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 美食 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2026-01-05T00:33:58.148653", + "similarity_score": 0.5290427207946777 + }, + "14": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2026-01-11", + "end_date": "2026-01-13", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "北京历史与美食之旅", + "days": [ + { + "day": 1, + "theme": "故宫历史文化探索", + "weather": { + "date": "2026-01-11", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "-5", + "night_temp": "5", + "day_wind": "东风3级", + "night_wind": "东风3级" + }, + "recommended_hotel": { + "name": "北京天伦王朝酒店", + "address": "北京市东城区王府井大街88号", + "location": { + "lat": 39.916935, + "lng": 116.405245 + }, + "price": "1000元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.2 + }, + "attractions": [ + { + "name": "故宫", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "故宫是中国明清两代的皇家宫殿,也是世界上现存规模最大、保存最为完整的木质结构古建筑之一。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.916527, + "lng": 116.405226 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1621163771613-bd0a06ceb600?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOTUlODUlRTUlQUUlQUIlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTQ0NDA1fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市东城区王府井大街119号", + "location": { + "lat": 39.916667, + "lng": 116.405417 + }, + "cost_per_person": "200", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 200.0, + "dining_cost": 400.0, + "hotel_cost": 1000.0, + "attraction_ticket_cost": 60.0, + "total": 660.0 + } + }, + { + "day": 2, + "theme": "天安门广场与颐和园", + "weather": { + "date": "2026-01-12", + "day_weather": "多云", + "night_weather": "多云", + "day_temp": "-3", + "night_temp": "6", + "day_wind": "东北风4级", + "night_wind": "东北风4级" + }, + "recommended_hotel": { + "name": "北京华尔道夫酒店", + "address": "北京市东城区金宝街99号", + "location": { + "lat": 39.917053, + "lng": 116.404807 + }, + "price": "1500元/晚", + "rating": "4.8", + "distance_to_main_attraction_km": 1.5 + }, + "attractions": [ + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 3.0, + "description": "天安门广场是世界上最大的城市中心广场,拥有丰富的历史文化遗产。", + "address": "北京市中心", + "location": { + "lat": 39.916527, + "lng": 116.405226 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTQ0NDEyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "颐和园", + "type": "历史文化", + "rating": "4.6", + "suggested_duration_hours": 4.0, + "description": "颐和园是中国清朝时期的皇家园林,被誉为‘皇家园林博物馆’。", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.997105, + "lng": 116.300303 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTQ0NDE4fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "30" + } + ], + "dinings": [ + { + "name": "大董烤鸭店", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.997105, + "lng": 116.300303 + }, + "cost_per_person": "250", + "rating": "4.7" + } + ], + "budget": { + "transport_cost": 200.0, + "dining_cost": 500.0, + "hotel_cost": 1500.0, + "attraction_ticket_cost": 80.0, + "total": 1280.0 + } + }, + { + "day": 3, + "theme": "北京猿人遗址", + "weather": { + "date": "2026-01-13", + "day_weather": "阴", + "night_weather": "阴", + "day_temp": "-2", + "night_temp": "7", + "day_wind": "北风2级", + "night_wind": "北风2级" + }, + "recommended_hotel": { + "name": "北京天伦王朝酒店", + "address": "北京市东城区王府井大街88号", + "location": { + "lat": 39.916935, + "lng": 116.405245 + }, + "price": "1000元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.2 + }, + "attractions": [ + { + "name": "北京猿人遗址", + "type": "自然", + "rating": "4.3", + "suggested_duration_hours": 2.0, + "description": "北京猿人遗址是中国发现最早、材料最丰富、研究最广泛的直立人遗址。", + "address": "北京市房山区周口店镇龙骨山北部", + "location": { + "lat": 39.966563, + "lng": 115.946165 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1648937521971-0732cf4451aa?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOEMlOTclRTQlQkElQUMlMjBsYW5kbWFya3xlbnwwfHx8fDE3Njc1NDQ0Mzh8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "50" + } + ], + "dinings": [ + { + "name": "四季民福烤鸭店", + "address": "北京市房山区周口店镇龙骨山北部", + "location": { + "lat": 39.966563, + "lng": 115.946165 + }, + "cost_per_person": "180", + "rating": "4.4" + } + ], + "budget": { + "transport_cost": 200.0, + "dining_cost": 360.0, + "hotel_cost": 1000.0, + "attraction_ticket_cost": 50.0, + "total": 1110.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2026-01-11 到 2026-01-13 偏好: 历史, 美食 行程标题: 北京历史与美食之旅 景点: 故宫, 天安门广场, 颐和园, 北京猿人遗址", + "timestamp": "2026-01-05T00:33:58.194775", + "similarity_score": 0.6568377614021301 + }, + "15": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 美食 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2026-01-05T00:33:58.228539", + "similarity_score": 0.6206287145614624 + }, + "16": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "中等", + "trip_title": "北京历史与美食之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 美食 酒店偏好: 舒适型, 高档型 预算水平: 中等", + "timestamp": "2026-01-05T01:43:20.048434", + "similarity_score": 0.6115542650222778 + }, + "17": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2026-01-11", + "end_date": "2026-01-13", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "中等", + "trip_title": "北京历史与美食之旅", + "days": [ + { + "day": 1, + "theme": "古都历史探索", + "weather": { + "date": "2026-01-11", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "10", + "night_temp": "2", + "day_wind": "东风3级", + "night_wind": "东北风2级" + }, + "recommended_hotel": { + "name": "北京王府井希尔顿酒店", + "address": "北京市东城区王府井大街238号", + "location": { + "lat": 39.91723, + "lng": 116.4074 + }, + "price": "800元/晚", + "rating": "4.7", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "故宫", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "参观故宫博物院,了解中国古代宫廷建筑之美。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.91723, + "lng": 116.4074 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1621163771613-bd0a06ceb600?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOTUlODUlRTUlQUUlQUIlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTQ0NDA1fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + }, + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 2.0, + "description": "游览天安门广场,感受中国的历史文化。", + "address": "北京市中心", + "location": { + "lat": 39.91723, + "lng": 116.4074 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTQ0NDEyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市东城区前门大街18号", + "location": { + "lat": 39.91723, + "lng": 116.4074 + }, + "cost_per_person": "200", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 200.0, + "hotel_cost": 800.0, + "attraction_ticket_cost": 120.0, + "total": 1220.0 + } + }, + { + "day": 2, + "theme": "皇家园林探秘", + "weather": { + "date": "2026-01-12", + "day_weather": "多云", + "night_weather": "多云", + "day_temp": "12", + "night_temp": "4", + "day_wind": "东北风2级", + "night_wind": "东北风2级" + }, + "recommended_hotel": { + "name": "北京华尔道夫酒店", + "address": "北京市东城区金宝街99号", + "location": { + "lat": 39.9157, + "lng": 116.4614 + }, + "price": "1200元/晚", + "rating": "4.8", + "distance_to_main_attraction_km": 1.2 + }, + "attractions": [ + { + "name": "颐和园", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "游览颐和园,欣赏中国保存最完整的皇家园林。", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.9966, + "lng": 116.2995 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTQ0NDE4fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + }, + { + "name": "圆明园", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "参观圆明园,了解其辉煌与毁灭的历史。", + "address": "北京市海淀区圆明园西路", + "location": { + "lat": 39.9966, + "lng": 116.2995 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1698247122593-4f3c2e4b8219?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOUMlODYlRTYlOTglOEUlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTQ4NTc2fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "40" + } + ], + "dinings": [ + { + "name": "四季民福烤鸭店", + "address": "北京市朝阳区三里屯北路19号", + "location": { + "lat": 40.0416, + "lng": 116.4538 + }, + "cost_per_person": "150", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 150.0, + "hotel_cost": 1200.0, + "attraction_ticket_cost": 100.0, + "total": 1550.0 + } + }, + { + "day": 3, + "theme": "燕京文化体验", + "weather": { + "date": "2026-01-13", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "15", + "night_temp": "6", + "day_wind": "东风3级", + "night_wind": "东风3级" + }, + "recommended_hotel": { + "name": "北京香山饭店", + "address": "北京市海淀区香山南辛村8号", + "location": { + "lat": 39.9966, + "lng": 116.2995 + }, + "price": "600元/晚", + "rating": "4.6", + "distance_to_main_attraction_km": 1.5 + }, + "attractions": [ + { + "name": "北京猿人遗址", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 2.0, + "description": "参观北京猿人遗址,了解人类早期文明。", + "address": "北京市房山区周口店镇龙骨山", + "location": { + "lat": 39.9966, + "lng": 116.2995 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1648937521971-0732cf4451aa?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOEMlOTclRTQlQkElQUMlMjBsYW5kbWFya3xlbnwwfHx8fDE3Njc1NDQ0Mzh8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "50" + }, + { + "name": "燕京八绝博物馆", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "参观燕京八绝博物馆,体验燕京八绝技艺的魅力。", + "address": "北京市西城区陶然亭南广大街22号", + "location": { + "lat": 39.91723, + "lng": 116.4074 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1633954935960-60dbe143edbd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTclODclOTUlRTQlQkElQUMlRTUlODUlQUIlRTclQkIlOUQlRTUlOEQlOUElRTclODklQTklRTklQTYlODYlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTQ4NjAwfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "100" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市朝阳区工体北路18号", + "location": { + "lat": 40.0416, + "lng": 116.4538 + }, + "cost_per_person": "180", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 180.0, + "hotel_cost": 600.0, + "attraction_ticket_cost": 150.0, + "total": 1530.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2026-01-11 到 2026-01-13 偏好: 历史, 美食 行程标题: 北京历史与美食之旅 景点: 故宫, 天安门广场, 颐和园, 圆明园, 北京猿人遗址, 燕京八绝博物馆", + "timestamp": "2026-01-05T01:43:20.091162", + "similarity_score": 0.6292707324028015 + }, + "18": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "中等" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 美食 酒店偏好: 舒适型, 高档型 预算水平: 中等", + "timestamp": "2026-01-05T01:43:20.121403", + "similarity_score": 0.5980846881866455 + }, + "19": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "成都美食与休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 成都 旅行偏好: 美食, 休闲 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2026-01-05T01:57:05.203290", + "similarity_score": 0.6521344780921936 + }, + "20": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "trip", + "data": { + "destination": "成都", + "start_date": "2026-01-13", + "end_date": "2026-01-14", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "成都美食与休闲之旅", + "days": [ + { + "day": 1, + "theme": "春熙路购物与美食", + "weather": { + "date": "2026-01-13", + "day_weather": "多云转晴", + "night_weather": "晴", + "day_temp": "10", + "night_temp": "2", + "day_wind": "东风3级", + "night_wind": "南风2级" + }, + "recommended_hotel": { + "name": "成都华尔道夫酒店", + "address": "成都市青羊区春熙路大悦城H馆8层", + "location": { + "lat": 30.665441, + "lng": 104.023854 + }, + "price": "1000元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 0.8 + }, + "attractions": [ + { + "name": "春熙路", + "type": "购物", + "rating": "4.7", + "suggested_duration_hours": 2.0, + "description": "春熙路不仅是一个繁华的商业区,还汇集了许多知名的餐厅和小吃店,让您在享受购物的同时也能品尝到成都的地道美食。", + "address": "成都市青羊区春熙路", + "location": { + "lat": 30.666183, + "lng": 104.024185 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1622635272666-011cb854fc97?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOTglQTUlRTclODYlOTklRTglQjclQUYlMjAlRTYlODglOTAlRTklODMlQkR8ZW58MHx8fHwxNzY3NTQ5Mzg1fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "蜀九香火锅店", + "address": "成都市青羊区春熙路1号", + "location": { + "lat": 30.665672, + "lng": 104.024421 + }, + "cost_per_person": "150", + "rating": "4.8" + }, + { + "name": "陈麻婆豆腐店", + "address": "成都市青羊区春熙路1号", + "location": { + "lat": 30.665672, + "lng": 104.024421 + }, + "cost_per_person": "100", + "rating": "4.7" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 250.0, + "hotel_cost": 1000.0, + "attraction_ticket_cost": 0.0, + "total": 1350.0 + } + }, + { + "day": 2, + "theme": "锦里古街与杜甫草堂", + "weather": { + "date": "2026-01-14", + "day_weather": "晴朗", + "night_weather": "晴", + "day_temp": "15", + "night_temp": "5", + "day_wind": "南风2级", + "night_wind": "北风1级" + }, + "recommended_hotel": { + "name": "成都博舍酒店", + "address": "成都市武侯区锦里东路1号", + "location": { + "lat": 30.680436, + "lng": 104.055614 + }, + "price": "800元/晚", + "rating": "4.8", + "distance_to_main_attraction_km": 1.5 + }, + "attractions": [ + { + "name": "成都锦里古街", + "type": "美食", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "成都锦里古街是一个充满四川传统文化气息的地方,您可以在这里品尝到各种地道的四川小吃和特色美食。", + "address": "成都市武侯区武侯祠大街231号", + "location": { + "lat": 30.681457, + "lng": 104.056175 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1731574555291-00cbf96f749c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlODglOTAlRTklODMlQkQlMjBsYW5kbWFya3xlbnwwfHx8fDE3Njc1NDk0MDZ8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "杜甫草堂", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "杜甫草堂是中国唐代著名诗人杜甫的故居,周围有许多餐馆提供川菜和其他当地特色美食。", + "address": "成都市青羊区青华路37号", + "location": { + "lat": 30.675448, + "lng": 104.034265 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1731574555291-00cbf96f749c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlODglOTAlRTklODMlQkQlMjBsYW5kbWFya3xlbnwwfHx8fDE3Njc1NDk0MDZ8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "40" + } + ], + "dinings": [ + { + "name": "张老记串串香", + "address": "成都市武侯区武侯祠大街231号", + "location": { + "lat": 30.681457, + "lng": 104.056175 + }, + "cost_per_person": "80", + "rating": "4.6" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 150.0, + "hotel_cost": 800.0, + "attraction_ticket_cost": 40.0, + "total": 490.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 成都 时间: 2026-01-13 到 2026-01-14 偏好: 美食, 休闲 行程标题: 成都美食与休闲之旅 景点: 春熙路, 成都锦里古街, 杜甫草堂", + "timestamp": "2026-01-05T01:57:05.237907" + }, + "21": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 成都 旅行偏好: 美食, 休闲 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2026-01-05T01:57:05.275813" + }, + "22": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然与休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-05T02:10:49.520449" + }, + "23": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "trip", + "data": { + "destination": "杭州", + "start_date": "2026-01-11", + "end_date": "2026-01-12", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然与休闲之旅", + "days": [ + { + "day": 1, + "theme": "植物园与自然风光", + "weather": { + "date": "2026-01-11", + "day_weather": "多云", + "night_weather": "阴", + "day_temp": "5", + "night_temp": "2", + "day_wind": "东北风2级", + "night_wind": "北风1级" + }, + "recommended_hotel": { + "name": "杭州植物园精品酒店", + "address": "杭州市西湖区玉泉路1号", + "location": { + "lat": 30.255, + "lng": 120.132 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 0.1 + }, + "attractions": [ + { + "name": "杭州植物园", + "type": "自然", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "杭州植物园是一个集科研、科普、游览于一体的综合性植物园,园内有丰富的植物种类和美丽的自然景观。", + "address": "杭州市西湖区玉泉路1号", + "location": { + "lat": 30.255, + "lng": 120.132 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1696513790795-e8fe7f017f02?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOUQlQUQlRTUlQjclOUUlMjBsYW5kbWFya3xlbnwwfHx8fDE3Njc1NDMzMjR8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + }, + { + "name": "钱塘江大桥", + "type": "自然", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "欣赏钱塘江的自然风光,同时也可以感受这座历史悠久的桥梁带来的震撼。", + "address": "杭州市滨江区江汉路1999号", + "location": { + "lat": 30.225, + "lng": 120.223 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1715245628884-b93666e687fb?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklOTIlQjElRTUlQTElOTglRTYlQjElOUYlRTUlQTQlQTclRTYlQTElQTUlMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NTUwMjI2fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "西湖边茶馆", + "address": "杭州市西湖区断桥路1号", + "location": { + "lat": 30.245, + "lng": 120.135 + }, + "cost_per_person": "50", + "rating": "4.3" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 150.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 150.0, + "total": 750.0 + } + }, + { + "day": 2, + "theme": "寺庙与湿地公园", + "weather": { + "date": "2026-01-12", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "7", + "night_temp": "4", + "day_wind": "南风3级", + "night_wind": "东南风2级" + }, + "recommended_hotel": { + "name": "杭州灵隐寺附近民宿", + "address": "杭州市西湖区灵隐路18号", + "location": { + "lat": 30.267, + "lng": 120.163 + }, + "price": "350元/晚", + "rating": "4.2", + "distance_to_main_attraction_km": 0.2 + }, + "attractions": [ + { + "name": "灵隐寺", + "type": "宗教文化", + "rating": "4.5", + "suggested_duration_hours": 3.0, + "description": "灵隐寺周围环境优美,可以体验到自然与宗教文化的完美结合。", + "address": "杭州市西湖区灵隐路18号", + "location": { + "lat": 30.267, + "lng": 120.163 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1753364971896-a55ebc73361f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTclODElQjUlRTklOUElOTAlRTUlQUYlQkElMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NTUwMjMyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "西溪国家湿地公园", + "type": "自然", + "rating": "4.7", + "suggested_duration_hours": 5.0, + "description": "西溪国家湿地公园是中国第一个国家湿地公园,这里不仅有美丽的自然景色,还有许多珍稀动植物。", + "address": "杭州市西湖区紫金港路1号", + "location": { + "lat": 30.267, + "lng": 120.163 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1696513790795-e8fe7f017f02?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOUQlQUQlRTUlQjclOUUlMjBsYW5kbWFya3xlbnwwfHx8fDE3Njc1NDMzMjR8MA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "西溪湿地餐厅", + "address": "杭州市西湖区紫金港路1号", + "location": { + "lat": 30.267, + "lng": 120.163 + }, + "cost_per_person": "60", + "rating": "4.4" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 250.0, + "hotel_cost": 350.0, + "attraction_ticket_cost": 0.0, + "total": 650.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 杭州 时间: 2026-01-11 到 2026-01-12 偏好: 自然, 休闲 行程标题: 杭州自然与休闲之旅 景点: 杭州植物园, 钱塘江大桥, 灵隐寺, 西溪国家湿地公园", + "timestamp": "2026-01-05T02:10:49.566355", + "similarity_score": 0.7015855312347412 + }, + "24": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-05T02:10:49.597600" + }, + "25": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "trip_result": "```json\n{\n \"trip_title\": \"杭州自然休闲之旅\",\n \"total_budget\": {\n \"transport_cost\": 200.0,\n \"dining_cost\": 600.0,\n \"hotel_cost\": 800.0,\n \"attraction_ticket_cost\": 300.0,\n \"total\": 1900.0\n },\n" + }, + "text_representation": "偏好类型: trip_planning 目的地: 杭州 旅行偏好: 自然, 休闲", + "timestamp": "2026-01-05T15:26:03.466744", + "similarity_score": 0.6476990580558777 + }, + "26": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-05T15:26:04.925389", + "similarity_score": 0.6112585067749023 + }, + "27": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "trip", + "data": { + "destination": "杭州", + "start_date": "2026-01-12", + "end_date": "2026-01-13", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然休闲之旅", + "days": [ + { + "day": 1, + "theme": "西湖漫步与植物园休闲", + "weather": { + "date": "2026-01-12", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "10", + "night_temp": "1", + "day_wind": "东风3级", + "night_wind": "北风2级" + }, + "recommended_hotel": { + "name": "西湖附近民宿", + "address": "杭州市西湖区", + "location": { + "lat": 30.254922, + "lng": 120.121424 + }, + "price": "200元/晚", + "rating": "4.2", + "distance_to_main_attraction_km": 2.0 + }, + "attractions": [ + { + "name": "西湖", + "type": "自然", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "西湖被誉为‘人间天堂’,适合散步和观景。", + "address": "杭州市西湖区", + "location": { + "lat": 30.267967, + "lng": 120.145241 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1753365310761-012a341f9210?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTglQTUlQkYlRTYlQjklOTYlMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NTQzMzA0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "杭州植物园", + "type": "自然", + "rating": "4.6", + "suggested_duration_hours": 3.0, + "description": "这是一个集科研、科普、观赏、休憩为一体的综合性园林,四季花开,景色宜人。", + "address": "杭州市西湖区", + "location": { + "lat": 30.274922, + "lng": 120.141424 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=800&h=600&fit=crop" + ], + "ticket_price": "20" + } + ], + "dinings": [ + { + "name": "湖畔餐厅", + "address": "杭州市西湖区", + "location": { + "lat": 30.267967, + "lng": 120.145241 + }, + "cost_per_person": "50", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 200.0, + "attraction_ticket_cost": 20.0, + "total": 470.0 + } + }, + { + "day": 2, + "theme": "钱塘江大桥与灵隐寺", + "weather": { + "date": "2026-01-13", + "day_weather": "多云", + "night_weather": "阴", + "day_temp": "8", + "night_temp": "-3", + "day_wind": "东北风2级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "植物园附近经济型酒店", + "address": "杭州市西湖区", + "location": { + "lat": 30.274922, + "lng": 120.141424 + }, + "price": "150元/晚", + "rating": "3.8", + "distance_to_main_attraction_km": 3.0 + }, + "attractions": [ + { + "name": "钱塘江大桥", + "type": "自然", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "欣赏钱塘江的自然风光,特别是钱塘江大潮的壮观景象。", + "address": "杭州市江干区", + "location": { + "lat": 30.264922, + "lng": 120.131424 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1715245628884-b93666e687fb?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklOTIlQjElRTUlQTElOTglRTYlQjElOUYlRTUlQTQlQTclRTYlQTElQTUlMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NTUwMjI2fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "灵隐寺", + "type": "文化", + "rating": "4.4", + "suggested_duration_hours": 3.0, + "description": "灵隐寺位于飞来峰下,环境幽静,周围有美丽的自然景观。", + "address": "杭州市西湖区", + "location": { + "lat": 30.274922, + "lng": 120.141424 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1753364971896-a55ebc73361f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTclODElQjUlRTklOUElOTAlRTUlQUYlQkElMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NTUwMjMyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "30" + } + ], + "dinings": [ + { + "name": "素斋餐厅", + "address": "杭州市西湖区", + "location": { + "lat": 30.274922, + "lng": 120.141424 + }, + "cost_per_person": "40", + "rating": "4.3" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 150.0, + "attraction_ticket_cost": 60.0, + "total": 460.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 杭州 时间: 2026-01-12 到 2026-01-13 偏好: 自然, 休闲 行程标题: 杭州自然休闲之旅 景点: 西湖, 杭州植物园, 钱塘江大桥, 灵隐寺", + "timestamp": "2026-01-05T15:26:04.960846", + "similarity_score": 0.701876163482666 + }, + "28": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-05T15:26:04.994827" + }, + "29": { + "user_id": "test_北京", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史与文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 600.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 300.0,\n \"total\": 2400.0\n }" + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 文化", + "timestamp": "2026-01-05T15:46:24.045358", + "similarity_score": 0.6565035581588745 + }, + "30": { + "user_id": "test_北京", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史与文化之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-05T15:46:26.212701", + "similarity_score": 0.5123157501220703 + }, + "31": { + "user_id": "test_上海", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "上海", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"上海历史文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 上海 旅行偏好: 历史, 文化", + "timestamp": "2026-01-05T15:47:38.394667", + "similarity_score": 0.6446634531021118 + }, + "32": { + "user_id": "test_上海", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "上海", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "上海历史文化之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 上海 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-05T15:47:40.475213", + "similarity_score": 0.5291430950164795 + }, + "33": { + "user_id": "test_杭州", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "杭州", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"杭州历史文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 杭州 旅行偏好: 历史, 文化", + "timestamp": "2026-01-05T15:48:48.135735", + "similarity_score": 0.5770336389541626 + }, + "34": { + "user_id": "test_北京", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史文化探索之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n " + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 文化", + "timestamp": "2026-01-05T15:54:41.418900" + }, + "35": { + "user_id": "test_北京", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化探索之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-05T15:54:43.108968" + }, + "36": { + "user_id": "test_上海", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "上海", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"上海历史文化探索之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n " + }, + "text_representation": "偏好类型: trip_planning 目的地: 上海 旅行偏好: 历史, 文化", + "timestamp": "2026-01-05T15:55:45.857114" + }, + "37": { + "user_id": "test_上海", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "上海", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "上海历史文化探索之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 上海 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-05T15:55:48.309962" + }, + "38": { + "user_id": "test_杭州", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "杭州", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"杭州历史文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 杭州 旅行偏好: 历史, 文化", + "timestamp": "2026-01-05T15:56:46.431051" + }, + "39": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "trip_result": "```json\n{\n \"trip_title\": \"杭州自然休闲之旅\",\n \"total_budget\": {\n \"transport_cost\": 150.0,\n \"dining_cost\": 500.0,\n \"hotel_cost\": 800.0,\n \"attraction_ticket_cost\": 200.0,\n \"total\": 1650.0\n },\n" + }, + "text_representation": "偏好类型: trip_planning 目的地: 杭州 旅行偏好: 自然, 休闲", + "timestamp": "2026-01-06T01:45:23.225941", + "similarity_score": 0.6476990580558777 + }, + "40": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-06T01:45:25.166760" + }, + "41": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "trip", + "data": { + "destination": "杭州", + "start_date": "2026-01-12", + "end_date": "2026-01-13", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然休闲之旅", + "days": [ + { + "day": 1, + "theme": "西湖风光", + "weather": { + "date": "2026-01-12", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "25", + "night_temp": "15", + "day_wind": "东风3级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "杭州西湖国际大酒店", + "address": "杭州市西湖区", + "location": { + "lat": 30.267153, + "lng": 120.153082 + }, + "price": "350元/晚", + "rating": "4.3", + "distance_to_main_attraction_km": 1.5 + }, + "attractions": [ + { + "name": "西湖", + "type": "自然", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "游览西湖美景,建议上午乘船游湖,下午步行欣赏苏堤春晓、断桥残雪等著名景点。", + "address": "浙江省杭州市西湖区", + "location": { + "lat": 30.240974, + "lng": 120.139044 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1753365310761-012a341f9210?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTglQTUlQkYlRTYlQjklOTYlMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NTQzMzA0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "龙井茶园", + "type": "自然", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "参观龙井茶园,了解茶文化,品尝当地绿茶。", + "address": "浙江省杭州市西湖区龙井村", + "location": { + "lat": 30.266665, + "lng": 120.158743 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=800&h=600&fit=crop" + ], + "ticket_price": "20" + } + ], + "dinings": [ + { + "name": "西湖醋鱼馆", + "address": "杭州市西湖区", + "location": { + "lat": 30.240974, + "lng": 120.139044 + }, + "cost_per_person": "60", + "rating": "4.6" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 350.0, + "attraction_ticket_cost": 120.0, + "total": 720.0 + } + }, + { + "day": 2, + "theme": "天目山探险", + "weather": { + "date": "2026-01-13", + "day_weather": "阴", + "night_weather": "小雨", + "day_temp": "20", + "night_temp": "12", + "day_wind": "北风4级", + "night_wind": "东北风3级" + }, + "recommended_hotel": { + "name": "杭州灵隐山庄", + "address": "杭州市临安区清凉峰镇", + "location": { + "lat": 30.344997, + "lng": 119.968222 + }, + "price": "300元/晚", + "rating": "4.2", + "distance_to_main_attraction_km": 20.0 + }, + "attractions": [ + { + "name": "临安天目山景区", + "type": "自然", + "rating": "4.4", + "suggested_duration_hours": 5.0, + "description": "天目山景区内有许多自然景观和古迹,建议全天徒步游览,感受大自然的魅力。", + "address": "杭州市临安区天目山镇", + "location": { + "lat": 30.344997, + "lng": 119.968222 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?w=800&h=600&fit=crop" + ], + "ticket_price": "免费" + } + ], + "dinings": [], + "budget": { + "transport_cost": 100.0, + "dining_cost": 300.0, + "hotel_cost": 300.0, + "attraction_ticket_cost": 0.0, + "total": 700.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 杭州 时间: 2026-01-12 到 2026-01-13 偏好: 自然, 休闲 行程标题: 杭州自然休闲之旅 景点: 西湖, 龙井茶园, 临安天目山景区", + "timestamp": "2026-01-06T01:45:25.205017" + }, + "42": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-06T01:45:25.237271" + }, + "43": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史与美食之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }" + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 美食", + "timestamp": "2026-01-06T01:50:04.279213", + "similarity_score": 0.7381951808929443 + }, + "44": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "中等", + "trip_title": "北京历史与美食之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 美食 酒店偏好: 舒适型, 高档型 预算水平: 中等", + "timestamp": "2026-01-06T01:50:06.749200" + }, + "45": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2026-01-06", + "end_date": "2026-01-08", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "中等", + "trip_title": "北京历史与美食之旅", + "days": [ + { + "day": 1, + "theme": "古都历史探索", + "weather": { + "date": "2026-01-06", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "25", + "night_temp": "15", + "day_wind": "东风3级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "北京华尔道夫酒店", + "address": "北京市朝阳区建国路89号", + "location": { + "lat": 39.929434, + "lng": 116.411458 + }, + "price": "1200元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 2.5 + }, + "attractions": [ + { + "name": "故宫博物院", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "故宫是中国明清两代的皇家宫殿,也是世界上现存规模最大、保存最完整的木质结构古建筑之一。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.916527, + "lng": 116.405225 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1636462202799-6883b127b57e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOTUlODUlRTUlQUUlQUIlRTUlOEQlOUElRTclODklQTklRTklOTklQTIlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTk5MTg2fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + }, + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.8", + "suggested_duration_hours": 2.0, + "description": "天安门广场位于北京市中心,是世界上最大的城市中心广场。", + "address": "北京市东城区东长安街", + "location": { + "lat": 39.916527, + "lng": 116.405225 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NjM1NDA3fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市东城区前门大街18号", + "location": { + "lat": 39.919671, + "lng": 116.405753 + }, + "cost_per_person": "200", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 1200.0, + "attraction_ticket_cost": 120.0, + "total": 1470.0 + } + }, + { + "day": 2, + "theme": "皇家园林游赏", + "weather": { + "date": "2026-01-07", + "day_weather": "阴", + "night_weather": "小雨", + "day_temp": "20", + "night_temp": "10", + "day_wind": "东北风4级", + "night_wind": "北风3级" + }, + "recommended_hotel": { + "name": "北京瑞吉酒店", + "address": "北京市朝阳区东三环中路66号", + "location": { + "lat": 39.929529, + "lng": 116.413013 + }, + "price": "1000元/晚", + "rating": "4.8", + "distance_to_main_attraction_km": 3.0 + }, + "attractions": [ + { + "name": "颐和园", + "type": "皇家园林", + "rating": "4.6", + "suggested_duration_hours": 3.0, + "description": "颐和园是中国清朝时期的皇家园林,被誉为“皇家园林博物馆”。", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.999433, + "lng": 116.324224 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NTQ0NDE4fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + } + ], + "dinings": [ + { + "name": "护国寺小吃店", + "address": "北京市西城区护国寺大街甲32号", + "location": { + "lat": 39.925316, + "lng": 116.39211 + }, + "cost_per_person": "80", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 80.0, + "hotel_cost": 1000.0, + "attraction_ticket_cost": 60.0, + "total": 1150.0 + } + }, + { + "day": 3, + "theme": "现代都市体验", + "weather": { + "date": "2026-01-08", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "22", + "night_temp": "13", + "day_wind": "南风3级", + "night_wind": "南风2级" + }, + "recommended_hotel": { + "name": "北京华尔道夫酒店", + "address": "北京市朝阳区建国路89号", + "location": { + "lat": 39.929434, + "lng": 116.411458 + }, + "price": "1200元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 2.5 + }, + "attractions": [ + { + "name": "798艺术区", + "type": "现代艺术", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "798艺术区是中国北京的一个现代艺术区,汇集了各种艺术画廊、设计师工作室和咖啡馆。", + "address": "北京市朝阳区酒仙桥路2号", + "location": { + "lat": 40.000563, + "lng": 116.460256 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1626235797878-094c3ff1d6a6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHw3OTglRTglODklQkElRTYlOUMlQUYlRTUlOEMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NjM1NDA3fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店(王府井店)", + "address": "北京市东城区王府井大街170号", + "location": { + "lat": 39.91876, + "lng": 116.406395 + }, + "cost_per_person": "200", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 1200.0, + "attraction_ticket_cost": 0.0, + "total": 1450.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2026-01-06 到 2026-01-08 偏好: 历史, 美食 行程标题: 北京历史与美食之旅 景点: 故宫博物院, 天安门广场, 颐和园, 798艺术区", + "timestamp": "2026-01-06T01:50:06.799032" + }, + "46": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "中等" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 美食 酒店偏好: 舒适型, 高档型 预算水平: 中等", + "timestamp": "2026-01-06T01:50:06.837268" + }, + "47": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "trip_result": "```json\n{\n \"trip_title\": \"成都美食与休闲之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }" + }, + "text_representation": "偏好类型: trip_planning 目的地: 成都 旅行偏好: 美食, 休闲", + "timestamp": "2026-01-06T01:53:32.256139" + }, + "48": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "成都美食与休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 成都 旅行偏好: 美食, 休闲 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2026-01-06T01:53:34.399011" + }, + "49": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "trip", + "data": { + "destination": "成都", + "start_date": "2026-01-06", + "end_date": "2026-01-07", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "成都美食与休闲之旅", + "days": [ + { + "day": 1, + "theme": "品味成都美食", + "weather": { + "date": "2026-01-06", + "day_weather": "晴", + "night_weather": "阴", + "day_temp": "18", + "night_temp": "10", + "day_wind": "东北风3级", + "night_wind": "北风2级" + }, + "recommended_hotel": { + "name": "成都华尔道夫酒店", + "address": "成都市青羊区人民公园路1号", + "location": { + "lat": 30.665, + "lng": 104.041 + }, + "price": "1200元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 1.0 + }, + "attractions": [ + { + "name": "成都锦里古街", + "type": "美食", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "成都锦里古街位于武侯祠旁,这里汇集了众多四川传统小吃和餐馆,您可以品尝到正宗的麻婆豆腐、担担面等经典川菜。", + "address": "成都市武侯区武侯祠大街西段251号", + "location": { + "lat": 30.662, + "lng": 104.04 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&h=600&fit=crop" + ], + "ticket_price": "免费" + }, + { + "name": "太平鸟街", + "type": "美食", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "太平鸟街是一条充满老成都风情的小吃街,您可以在这里享受到各种地道的四川美食。", + "address": "成都市青羊区太平鸟街", + "location": { + "lat": 30.663, + "lng": 104.04 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?w=800&h=600&fit=crop" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "蜀九香火锅", + "address": "成都市武侯区锦里西路15号", + "location": { + "lat": 30.663, + "lng": 104.04 + }, + "cost_per_person": "150", + "rating": "4.8" + }, + { + "name": "陈麻婆豆腐", + "address": "成都市武侯祠大街251号", + "location": { + "lat": 30.662, + "lng": 104.04 + }, + "cost_per_person": "80", + "rating": "4.9" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 400.0, + "hotel_cost": 600.0, + "attraction_ticket_cost": 100.0, + "total": 1150.0 + } + }, + { + "day": 2, + "theme": "休闲游成都", + "weather": { + "date": "2026-01-07", + "day_weather": "多云", + "night_weather": "晴", + "day_temp": "15", + "night_temp": "8", + "day_wind": "东南风3级", + "night_wind": "南风2级" + }, + "recommended_hotel": { + "name": "成都华尔道夫酒店", + "address": "成都市青羊区人民公园路1号", + "location": { + "lat": 30.665, + "lng": 104.041 + }, + "price": "1200元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 1.0 + }, + "attractions": [ + { + "name": "宽窄巷子", + "type": "休闲", + "rating": "4.6", + "suggested_duration_hours": 2.0, + "description": "宽窄巷子是成都著名的旅游景点,您可以在这里体验成都的传统生活气息。", + "address": "成都市青羊区长顺街2号", + "location": { + "lat": 30.665, + "lng": 104.04 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?w=800&h=600&fit=crop" + ], + "ticket_price": "免费" + }, + { + "name": "成都熊猫基地", + "type": "休闲", + "rating": "4.5", + "suggested_duration_hours": 3.0, + "description": "成都熊猫基地是观看大熊猫的好去处,您可以近距离观察这些可爱的动物。", + "address": "成都市成华区熊猫大道1300号", + "location": { + "lat": 30.67, + "lng": 104.05 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?w=800&h=600&fit=crop" + ], + "ticket_price": "100" + } + ], + "dinings": [ + { + "name": "龙抄手", + "address": "成都市青羊区宽窄巷子内", + "location": { + "lat": 30.665, + "lng": 104.04 + }, + "cost_per_person": "50", + "rating": "4.7" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 150.0, + "hotel_cost": 600.0, + "attraction_ticket_cost": 100.0, + "total": 900.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 成都 时间: 2026-01-06 到 2026-01-07 偏好: 美食, 休闲 行程标题: 成都美食与休闲之旅 景点: 成都锦里古街, 太平鸟街, 宽窄巷子, 成都熊猫基地", + "timestamp": "2026-01-06T01:53:34.440545" + }, + "50": { + "user_id": "16d9c456-e82c-49a0-b889-3ff53ffed029", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 成都 旅行偏好: 美食, 休闲 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2026-01-06T01:53:34.463488" + }, + "51": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "trip_result": "```json\n{\n \"trip_title\": \"杭州自然与休闲之旅\",\n \"total_budget\": {\n \"transport_cost\": 200.0,\n \"dining_cost\": 600.0,\n \"hotel_cost\": 800.0,\n \"attraction_ticket_cost\": 300.0,\n \"total\": 1900.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 杭州 旅行偏好: 自然, 休闲", + "timestamp": "2026-01-07T00:48:21.293461", + "similarity_score": 0.6476990580558777 + }, + "52": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然与休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-07T00:48:37.303609" + }, + "53": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "trip", + "data": { + "destination": "杭州", + "start_date": "2026-01-13", + "end_date": "2026-01-14", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济", + "trip_title": "杭州自然与休闲之旅", + "days": [ + { + "day": 1, + "theme": "西湖畔的自然与休闲", + "weather": { + "date": "2026-01-13", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "23", + "night_temp": "13", + "day_wind": "东南风2级", + "night_wind": "东北风3级" + }, + "recommended_hotel": { + "name": "杭州植物园附近酒店", + "address": "杭州市西湖区", + "location": { + "lat": 30.266536, + "lng": 120.134288 + }, + "price": "300元/晚", + "rating": "4.2", + "distance_to_main_attraction_km": 1.5 + }, + "attractions": [ + { + "name": "杭州植物园", + "type": "自然", + "rating": "4.8", + "suggested_duration_hours": 4.0, + "description": "杭州植物园是杭州最大的综合性植物园之一,拥有丰富的植物种类和优美的自然景观。", + "address": "杭州市西湖区", + "location": { + "lat": 30.266536, + "lng": 120.134288 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1697395886596-fd611f9032ba?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NzE4MTEzfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "50" + }, + { + "name": "西溪国家湿地公园", + "type": "自然", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "西溪国家湿地公园是中国首个国家湿地公园,以其独特的自然生态和文化景观闻名。", + "address": "杭州市西湖区", + "location": { + "lat": 30.284314, + "lng": 120.128277 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?w=800&h=600&fit=crop" + ], + "ticket_price": "70" + } + ], + "dinings": [ + { + "name": "西湖边茶楼", + "address": "杭州市西湖区", + "location": { + "lat": 30.267536, + "lng": 120.133288 + }, + "cost_per_person": "60", + "rating": "4.3" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 200.0, + "hotel_cost": 300.0, + "attraction_ticket_cost": 100.0, + "total": 700.0 + } + }, + { + "day": 2, + "theme": "钱塘江畔的休闲时光", + "weather": { + "date": "2026-01-14", + "day_weather": "阴", + "night_weather": "小雨", + "day_temp": "18", + "night_temp": "10", + "day_wind": "北风3级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "西溪国家湿地公园附近酒店", + "address": "杭州市西湖区", + "location": { + "lat": 30.284314, + "lng": 120.128277 + }, + "price": "250元/晚", + "rating": "4.0", + "distance_to_main_attraction_km": 2.0 + }, + "attractions": [ + { + "name": "钱塘江大桥", + "type": "休闲", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "钱塘江大桥是连接杭州南北的重要交通枢纽,同时也是杭州的一处著名景观。", + "address": "杭州市下城区", + "location": { + "lat": 30.268536, + "lng": 120.134288 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1697395886596-fd611f9032ba?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NzE4MTEzfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "灵隐寺", + "type": "休闲", + "rating": "4.6", + "suggested_duration_hours": 2.0, + "description": "灵隐寺是一座有着千年历史的佛教寺庙,以其美丽的自然风光和悠久的文化底蕴而著称。", + "address": "杭州市西湖区", + "location": { + "lat": 30.266536, + "lng": 120.134288 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1753364971896-a55ebc73361f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTclODElQjUlRTklOUElOTAlRTUlQUYlQkElMjAlRTYlOUQlQUQlRTUlQjclOUV8ZW58MHx8fHwxNzY3NzE4MTA3fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "40" + } + ], + "dinings": [ + { + "name": "江边海鲜馆", + "address": "杭州市下城区", + "location": { + "lat": 30.268536, + "lng": 120.134288 + }, + "cost_per_person": "80", + "rating": "4.4" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 250.0, + "attraction_ticket_cost": 100.0, + "total": 600.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 杭州 时间: 2026-01-13 到 2026-01-14 偏好: 自然, 休闲 行程标题: 杭州自然与休闲之旅 景点: 杭州植物园, 西溪国家湿地公园, 钱塘江大桥, 灵隐寺", + "timestamp": "2026-01-07T00:48:37.450726" + }, + "54": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "杭州", + "preferences": [ + "自然", + "休闲" + ], + "hotel_preferences": [ + "经济型", + "民宿" + ], + "budget": "经济" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 杭州 旅行偏好: 自然, 休闲 酒店偏好: 经济型, 民宿 预算水平: 经济", + "timestamp": "2026-01-07T00:48:37.476446" + }, + "55": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史与美食之旅\",\n \"total_budget\": {\n \"transport_cost\": 200.0,\n \"dining_cost\": 600.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2400.0\n }" + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 美食", + "timestamp": "2026-01-07T00:57:57.766734", + "similarity_score": 0.7381951808929443 + }, + "56": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "中等", + "trip_title": "北京历史与美食之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 美食 酒店偏好: 舒适型, 高档型 预算水平: 中等", + "timestamp": "2026-01-07T00:58:18.743239" + }, + "57": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2026-01-14", + "end_date": "2026-01-15", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "中等", + "trip_title": "北京历史与美食之旅", + "days": [ + { + "day": 1, + "theme": "古都历史探索", + "weather": { + "date": "2026-01-14", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "25", + "night_temp": "15", + "day_wind": "东风3级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "北京王府半岛酒店", + "address": "北京市东城区王府井大街88号", + "location": { + "lat": 39.917231, + "lng": 116.407345 + }, + "price": "400元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 0.7 + }, + "attractions": [ + { + "name": "故宫", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "故宫是中国明清两代的皇家宫殿,拥有近600年的历史。游客可以在太和殿、中和殿、保和殿等建筑中感受到古代宫廷建筑的辉煌。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.916527, + "lng": 116.405225 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?w=800&h=600&fit=crop" + ], + "ticket_price": "60" + }, + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "天安门广场是世界上最大的城市广场之一,具有重要的历史意义。游客可以参观人民英雄纪念碑、毛主席纪念堂等标志性建筑。", + "address": "北京市东城区正义路4号", + "location": { + "lat": 39.916527, + "lng": 116.405225 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市东城区王府井大街1号", + "location": { + "lat": 39.917231, + "lng": 116.407345 + }, + "cost_per_person": "200", + "rating": "4.8" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 300.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 200.0, + "total": 1000.0 + } + }, + { + "day": 2, + "theme": "皇家园林体验", + "weather": { + "date": "2026-01-15", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "23", + "night_temp": "13", + "day_wind": "东南风3级", + "night_wind": "东北风2级" + }, + "recommended_hotel": { + "name": "北京华尔道夫酒店", + "address": "北京市朝阳区建国门外大街1号", + "location": { + "lat": 39.926666, + "lng": 116.421678 + }, + "price": "400元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 2.3 + }, + "attractions": [ + { + "name": "颐和园", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "颐和园是一座大型皇家园林,有着260多年的历史。游客可以在这里欣赏到美丽的昆明湖和万寿山,感受中国古代园林的魅力。", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.996875, + "lng": 116.303529 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + }, + { + "name": "圆明园遗址公园", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 3.0, + "description": "圆明园遗址公园虽然大部分被毁,但仍保留着丰富的历史文化价值。游客可以在这里了解清朝皇家园林的历史变迁。", + "address": "北京市海淀区清华西路28号", + "location": { + "lat": 39.996875, + "lng": 116.303529 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1698247122593-4f3c2e4b8219?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOUMlODYlRTYlOTglOEUlRTUlOUIlQUQlRTklODElOTclRTUlOUQlODAlRTUlODUlQUMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg5fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "40" + } + ], + "dinings": [ + { + "name": "大董烤鸭店", + "address": "北京市海淀区中关村大街35号", + "location": { + "lat": 39.970209, + "lng": 116.305613 + }, + "cost_per_person": "180", + "rating": "4.9" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 300.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 160.0, + "total": 1060.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2026-01-14 到 2026-01-15 偏好: 历史, 美食 行程标题: 北京历史与美食之旅 景点: 故宫, 天安门广场, 颐和园, 圆明园遗址公园", + "timestamp": "2026-01-07T00:58:18.868761" + }, + "58": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "美食" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "中等" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 美食 酒店偏好: 舒适型, 高档型 预算水平: 中等", + "timestamp": "2026-01-07T00:58:18.915738" + }, + "59": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 600.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 300.0,\n \"total\": 2400.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 文化", + "timestamp": "2026-01-07T01:05:26.458016", + "similarity_score": 0.6916419267654419 + }, + "60": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:05:46.612659" + }, + "61": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅", + "days": [ + { + "day": 1, + "theme": "古都历史探索", + "weather": { + "date": "2024-10-01", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "22", + "night_temp": "10", + "day_wind": "东风3级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "北京首都宾馆", + "address": "北京市东城区东直门南大街1号", + "location": { + "lat": 39.917222, + "lng": 116.401389 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.2 + }, + "attractions": [ + { + "name": "故宫博物院", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "故宫博物院是中国明清两代的皇家宫殿,拥有近600年的历史。您可以在这里了解中国古代宫廷文化,欣赏精美的文物。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.917222, + "lng": 116.416722 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&h=600&fit=crop" + ], + "ticket_price": "60" + }, + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "天安门广场是世界上最大的城市中心广场之一,周围有许多具有历史意义的建筑。您可以在这里感受中国历史的魅力。", + "address": "北京市中心", + "location": { + "lat": 39.917222, + "lng": 116.416722 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市东城区王府井大街369号", + "location": { + "lat": 39.916667, + "lng": 116.416667 + }, + "cost_per_person": "150", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 150.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 120.0, + "total": 720.0 + } + }, + { + "day": 2, + "theme": "皇家园林体验", + "weather": { + "date": "2024-10-02", + "day_weather": "多云", + "night_weather": "多云", + "day_temp": "20", + "night_temp": "12", + "day_wind": "东北风3级", + "night_wind": "东北风3级" + }, + "recommended_hotel": { + "name": "北京首都宾馆", + "address": "北京市东城区东直门南大街1号", + "location": { + "lat": 39.917222, + "lng": 116.401389 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.2 + }, + "attractions": [ + { + "name": "颐和园", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 4.0, + "description": "颐和园是一座大型的皇家园林,建于公元18世纪,具有重要的历史价值。您可以在这里漫步于美丽的湖光山色之中。", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.997222, + "lng": 116.3 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + } + ], + "dinings": [ + { + "name": "四季民福烤鸭店", + "address": "北京市西城区宣武门外大街20号", + "location": { + "lat": 39.927222, + "lng": 116.377222 + }, + "cost_per_person": "120", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 120.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 60.0, + "total": 630.0 + } + }, + { + "day": 3, + "theme": "长城探险之旅", + "weather": { + "date": "2024-10-03", + "day_weather": "小雨转阴", + "night_weather": "阴", + "day_temp": "18", + "night_temp": "9", + "day_wind": "东南风3级", + "night_wind": "东南风3级" + }, + "recommended_hotel": { + "name": "北京首都宾馆", + "address": "北京市东城区东直门南大街1号", + "location": { + "lat": 39.917222, + "lng": 116.401389 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.2 + }, + "attractions": [ + { + "name": "长城(八达岭段)", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 4.0, + "description": "长城是中国最著名的古代防御工程之一,具有丰富的历史背景。您可以在这里领略雄伟壮观的自然风光。", + "address": "北京市延庆区军都山关沟古道北口", + "location": { + "lat": 40.423128, + "lng": 116.05694 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1620964780032-81ef649db4d9?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE5MTQyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "40" + } + ], + "dinings": [ + { + "name": "北京全聚德烤鸭店", + "address": "北京市延庆区八达岭镇长城脚下的商业街", + "location": { + "lat": 40.423128, + "lng": 116.05694 + }, + "cost_per_person": "100", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 100.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 40.0, + "total": 590.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2024-10-01 到 2024-10-03 偏好: 历史, 文化 行程标题: 北京历史文化之旅 景点: 故宫博物院, 天安门广场, 颐和园, 长城(八达岭段)", + "timestamp": "2026-01-07T01:05:46.649500", + "similarity_score": 0.654515266418457 + }, + "62": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:05:46.681492" + }, + "63": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 文化", + "timestamp": "2026-01-07T01:10:17.834847", + "similarity_score": 0.6916419267654419 + }, + "64": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:10:38.004722" + }, + "65": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅", + "days": [ + { + "day": 1, + "theme": "古都历史探索", + "weather": { + "date": "2024-10-01", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "25", + "night_temp": "15", + "day_wind": "东风3级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "北京东苑饭店", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.915617, + "lng": 116.404041 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 0.8 + }, + "attractions": [ + { + "name": "故宫博物院", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "明清两代的皇家宫殿,是中国古代宫廷建筑之精华。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.915617, + "lng": 116.404041 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&h=600&fit=crop" + ], + "ticket_price": "60" + }, + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 2.0, + "description": "中国最大的城市中心广场,具有重要的政治意义。", + "address": "北京市东城区天安门金水桥以南", + "location": { + "lat": 39.916527, + "lng": 116.405685 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市东城区王府井大街369号", + "location": { + "lat": 39.916527, + "lng": 116.405685 + }, + "cost_per_person": "150", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 120.0, + "total": 820.0 + } + }, + { + "day": 2, + "theme": "皇家园林之旅", + "weather": { + "date": "2024-10-02", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "28", + "night_temp": "18", + "day_wind": "东南风2级", + "night_wind": "西南风3级" + }, + "recommended_hotel": { + "name": "北京香山饭店", + "address": "北京市海淀区香山南辛村8号", + "location": { + "lat": 39.990846, + "lng": 116.270219 + }, + "price": "350元/晚", + "rating": "4.3", + "distance_to_main_attraction_km": 20.0 + }, + "attractions": [ + { + "name": "颐和园", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "中国现存规模最大、保存最完整的皇家园林。", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.999378, + "lng": 116.336421 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "30" + } + ], + "dinings": [ + { + "name": "大董烤鸭店", + "address": "北京市海淀区中关村南大街1号", + "location": { + "lat": 39.997081, + "lng": 116.306849 + }, + "cost_per_person": "200", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 200.0, + "hotel_cost": 350.0, + "attraction_ticket_cost": 30.0, + "total": 680.0 + } + }, + { + "day": 3, + "theme": "长城探险之旅", + "weather": { + "date": "2024-10-03", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "26", + "night_temp": "16", + "day_wind": "东北风3级", + "night_wind": "北风2级" + }, + "recommended_hotel": { + "name": "长城脚下的公社", + "address": "北京市延庆区八达岭镇司马台村", + "location": { + "lat": 40.646386, + "lng": 116.156941 + }, + "price": "500元/晚", + "rating": "4.7", + "distance_to_main_attraction_km": 60.0 + }, + "attractions": [ + { + "name": "圆明园遗址公园", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 3.0, + "description": "清朝皇家园林,被誉为‘万园之园’,现为遗址公园。", + "address": "北京市海淀区清华西路28号", + "location": { + "lat": 39.985438, + "lng": 116.272665 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1620964780032-81ef649db4d9?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE5MTQyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "30" + } + ], + "dinings": [ + { + "name": "长城脚下的公社餐厅", + "address": "北京市延庆区八达岭镇司马台村", + "location": { + "lat": 40.646386, + "lng": 116.156941 + }, + "cost_per_person": "100", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 100.0, + "hotel_cost": 500.0, + "attraction_ticket_cost": 30.0, + "total": 730.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2024-10-01 到 2024-10-03 偏好: 历史, 文化 行程标题: 北京历史文化之旅 景点: 故宫博物院, 天安门广场, 颐和园, 圆明园遗址公园", + "timestamp": "2026-01-07T01:10:38.041483", + "similarity_score": 0.6547775268554688 + }, + "66": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:10:38.073870" + }, + "67": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 文化", + "timestamp": "2026-01-07T01:11:39.024412", + "similarity_score": 0.6916419267654419 + }, + "68": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:11:39.079311" + }, + "69": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅", + "days": [ + { + "day": 1, + "theme": "古都历史探索", + "weather": { + "date": "2024-10-01", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "25", + "night_temp": "15", + "day_wind": "东风3级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "北京王府半岛酒店", + "address": "北京市东城区东长安街1号", + "location": { + "lat": 39.917235, + "lng": 116.407398 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.2 + }, + "attractions": [ + { + "name": "故宫博物院", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "故宫博物院是明清两代的皇宫,拥有丰富的文物藏品和精美的建筑艺术。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.917235, + "lng": 116.407398 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&h=600&fit=crop" + ], + "ticket_price": "60" + }, + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "天安门广场是世界上最大的城市中心广场之一,是国家的重要象征。", + "address": "北京市东城区正义路", + "location": { + "lat": 39.917235, + "lng": 116.407398 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市东城区王府井大街269号", + "location": { + "lat": 39.917235, + "lng": 116.407398 + }, + "cost_per_person": "180", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 120.0, + "total": 770.0 + } + }, + { + "day": 2, + "theme": "皇家园林体验", + "weather": { + "date": "2024-10-02", + "day_weather": "多云", + "night_weather": "多云", + "day_temp": "20", + "night_temp": "12", + "day_wind": "北风3级", + "night_wind": "北风3级" + }, + "recommended_hotel": { + "name": "北京瑞吉酒店", + "address": "北京市东城区金宝街99号", + "location": { + "lat": 39.917235, + "lng": 116.407398 + }, + "price": "500元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.5 + }, + "attractions": [ + { + "name": "颐和园", + "type": "历史文化", + "rating": "4.6", + "suggested_duration_hours": 3.0, + "description": "颐和园是中国保存最完整的一座皇家园林,以其宏伟的建筑群和优美的湖光山色著称。", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.996423, + "lng": 116.311835 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + } + ], + "dinings": [ + { + "name": "大董烤鸭店", + "address": "北京市海淀区中关村大街20号", + "location": { + "lat": 39.996423, + "lng": 116.311835 + }, + "cost_per_person": "200", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 500.0, + "attraction_ticket_cost": 60.0, + "total": 810.0 + } + }, + { + "day": 3, + "theme": "现代与传统的融合", + "weather": { + "date": "2024-10-03", + "day_weather": "阴", + "night_weather": "阴", + "day_temp": "22", + "night_temp": "14", + "day_wind": "东北风2级", + "night_wind": "东北风2级" + }, + "recommended_hotel": { + "name": "北京西郊宾馆", + "address": "北京市海淀区香山南辛村1号", + "location": { + "lat": 39.997546, + "lng": 116.283462 + }, + "price": "350元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 10.0 + }, + "attractions": [ + { + "name": "圆明园遗址公园", + "type": "历史文化", + "rating": "4.4", + "suggested_duration_hours": 3.0, + "description": "圆明园遗址公园是清朝皇家园林的代表之一,现在已成为一处重要的文化遗产。", + "address": "北京市海淀区清华西路28号", + "location": { + "lat": 39.997546, + "lng": 116.283462 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1620964780032-81ef649db4d9?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE5MTQyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "40" + } + ], + "dinings": [ + { + "name": "四季民福烤鸭店", + "address": "北京市海淀区中关村大街28号", + "location": { + "lat": 39.997546, + "lng": 116.283462 + }, + "cost_per_person": "150", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 150.0, + "hotel_cost": 350.0, + "attraction_ticket_cost": 40.0, + "total": 590.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2024-10-01 到 2024-10-03 偏好: 历史, 文化 行程标题: 北京历史文化之旅 景点: 故宫博物院, 天安门广场, 颐和园, 圆明园遗址公园", + "timestamp": "2026-01-07T01:11:39.111917", + "similarity_score": 0.6547775268554688 + }, + "70": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:11:39.144038" + }, + "71": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 文化", + "timestamp": "2026-01-07T01:12:30.132273", + "similarity_score": 0.6565035581588745 + }, + "72": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:12:45.741229" + }, + "73": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅", + "days": [ + { + "day": 1, + "theme": "天安门广场及周边景点一日游", + "weather": { + "date": "2024-10-01", + "day_weather": "多云", + "night_weather": "多云", + "day_temp": "22", + "night_temp": "13", + "day_wind": "东风3级", + "night_wind": "东风3级" + }, + "recommended_hotel": { + "name": "北京天安门大酒店", + "address": "北京市东城区天安门广场西侧", + "location": { + "lat": 39.917, + "lng": 116.408 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "故宫", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "故宫是中国明清两代的皇家宫殿,拥有近600年的历史,是世界上现存规模最大、保存最为完整的木质结构古建筑之一。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.917, + "lng": 116.408 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1621163771613-bd0a06ceb600?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOTUlODUlRTUlQUUlQUIlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE5NTU2fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + }, + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "天安门广场是世界上最大的城市中心广场,周围有故宫、毛主席纪念堂、国家博物馆等著名景点。", + "address": "北京市中心", + "location": { + "lat": 39.917, + "lng": 116.408 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "N/A" + } + ], + "dinings": [ + { + "name": "天坛餐厅", + "address": "北京市东城区天坛公园内", + "location": { + "lat": 39.926, + "lng": 116.411 + }, + "cost_per_person": "80", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 120.0, + "total": 770.0 + } + }, + { + "day": 2, + "theme": "颐和园及周边景点一日游", + "weather": { + "date": "2024-10-02", + "day_weather": "晴朗", + "night_weather": "晴朗", + "day_temp": "24", + "night_temp": "14", + "day_wind": "西北风2级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "北京南池子公寓", + "address": "北京市东城区南锣鼓巷南池子大街", + "location": { + "lat": 39.917, + "lng": 116.411 + }, + "price": "350元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.0 + }, + "attractions": [ + { + "name": "颐和园", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 3.0, + "description": "颐和园是中国清朝时期的皇家园林,被誉为‘皇家园林博物馆’,以昆明湖、万寿山为基础,以杭州西湖为蓝本,汲取江南园林的设计手法而建成的一座大型山水园林。", + "address": "北京西北郊海淀区", + "location": { + "lat": 39.996, + "lng": 116.306 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市西城区前门大街1号", + "location": { + "lat": 39.929, + "lng": 116.383 + }, + "cost_per_person": "200", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 200.0, + "hotel_cost": 350.0, + "attraction_ticket_cost": 60.0, + "total": 610.0 + } + }, + { + "day": 3, + "theme": "八达岭长城一日游", + "weather": { + "date": "2024-10-03", + "day_weather": "小雨转多云", + "night_weather": "多云", + "day_temp": "21", + "night_temp": "12", + "day_wind": "东北风4级", + "night_wind": "东北风4级" + }, + "recommended_hotel": { + "name": "北京西单建国饭店", + "address": "北京市西城区西单北大街130号", + "location": { + "lat": 39.931, + "lng": 116.362 + }, + "price": "300元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.0 + }, + "attractions": [ + { + "name": "八达岭长城", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 5.0, + "description": "八达岭长城是明长城的一个重要组成部分,也是最著名的段落之一。", + "address": "北京市延庆区军都山关沟古道北口", + "location": { + "lat": 40.425, + "lng": 116.011 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1620964780032-81ef649db4d9?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE5MTQyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "100" + } + ], + "dinings": [ + { + "name": "长城脚下的农家菜馆", + "address": "北京市延庆区八达岭镇八达岭长城脚下", + "location": { + "lat": 40.425, + "lng": 116.011 + }, + "cost_per_person": "100", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 150.0, + "dining_cost": 100.0, + "hotel_cost": 300.0, + "attraction_ticket_cost": 100.0, + "total": 650.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2024-10-01 到 2024-10-03 偏好: 历史, 文化 行程标题: 北京历史文化之旅 景点: 故宫, 天安门广场, 颐和园, 八达岭长城", + "timestamp": "2026-01-07T01:12:45.776758", + "similarity_score": 0.6663380861282349 + }, + "74": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:12:45.810032" + }, + "75": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 文化", + "timestamp": "2026-01-07T01:13:44.821286", + "similarity_score": 0.6916419267654419 + }, + "76": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:13:44.875357" + }, + "77": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅", + "days": [ + { + "day": 1, + "theme": "古都历史初探", + "weather": { + "date": "2024-10-01", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "22", + "night_temp": "10", + "day_wind": "东北风2级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "北京王府井锦江之星酒店", + "address": "北京市东城区王府井大街201号", + "location": { + "lat": 39.917193, + "lng": 116.428201 + }, + "price": "300元/晚", + "rating": "4.0", + "distance_to_main_attraction_km": 1.5 + }, + "attractions": [ + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "天安门广场是中华人民共和国的象征性建筑群之一,游客可以参观人民英雄纪念碑、毛主席纪念堂等重要景点。", + "address": "北京市东城区正义路", + "location": { + "lat": 39.904219, + "lng": 116.407394 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "故宫", + "type": "历史文化", + "rating": "4.8", + "suggested_duration_hours": 4.0, + "description": "故宫是中国明清两代的皇家宫殿,也是世界上现存规模最大、保存最完整的木质结构古建筑之一。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.915257, + "lng": 116.46872 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1621163771613-bd0a06ceb600?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlOTUlODUlRTUlQUUlQUIlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE5NTU2fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "60元" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市东城区王府井大街544号", + "location": { + "lat": 39.917221, + "lng": 116.426694 + }, + "cost_per_person": "180", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 240.0, + "hotel_cost": 300.0, + "attraction_ticket_cost": 120.0, + "total": 710.0 + } + }, + { + "day": 2, + "theme": "皇家园林探访", + "weather": { + "date": "2024-10-02", + "day_weather": "多云转晴", + "night_weather": "晴", + "day_temp": "20", + "night_temp": "9", + "day_wind": "东南风2级", + "night_wind": "西南风2级" + }, + "recommended_hotel": { + "name": "北京颐和园国际酒店", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.912978, + "lng": 116.306542 + }, + "price": "400元/晚", + "rating": "4.2", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "颐和园", + "type": "历史文化", + "rating": "4.6", + "suggested_duration_hours": 4.0, + "description": "颐和园是中国保存最完整的一座皇家行宫御苑,以昆明湖、万寿山为基础,以杭州西湖为蓝本,汲取江南园林的设计手法而建成的一座大型山水园林。", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.912978, + "lng": 116.306542 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "30元" + } + ], + "dinings": [ + { + "name": "四季民福烤鸭店", + "address": "北京市海淀区中关村南大街甲1号", + "location": { + "lat": 39.933221, + "lng": 116.273114 + }, + "cost_per_person": "200", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 30.0, + "total": 680.0 + } + }, + { + "day": 3, + "theme": "长城风光之旅", + "weather": { + "date": "2024-10-03", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "21", + "night_temp": "8", + "day_wind": "东北风3级", + "night_wind": "东南风2级" + }, + "recommended_hotel": { + "name": "北京延庆区长城脚下的公社", + "address": "北京市延庆区八达岭长城景区内", + "location": { + "lat": 40.412021, + "lng": 116.150908 + }, + "price": "450元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 0.0 + }, + "attractions": [ + { + "name": "八达岭长城", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 6.0, + "description": "八达岭长城是明长城的精华部分,以其宏伟壮观著称于世。", + "address": "北京市延庆区军都山关沟古道北口", + "location": { + "lat": 40.412021, + "lng": 116.150908 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1620964780032-81ef649db4d9?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE5MTQyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "40元" + } + ], + "dinings": [ + { + "name": "长城脚下的公社餐厅", + "address": "北京市延庆区八达岭长城景区内", + "location": { + "lat": 40.412021, + "lng": 116.150908 + }, + "cost_per_person": "150", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 50.0, + "dining_cost": 150.0, + "hotel_cost": 450.0, + "attraction_ticket_cost": 40.0, + "total": 690.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2024-10-01 到 2024-10-03 偏好: 历史, 文化 行程标题: 北京历史文化之旅 景点: 天安门广场, 故宫, 颐和园, 八达岭长城", + "timestamp": "2026-01-07T01:13:44.909356", + "similarity_score": 0.6701995134353638 + }, + "78": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:13:44.942473" + }, + "79": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "trip_result": "```json\n{\n \"trip_title\": \"北京历史文化之旅\",\n \"total_budget\": {\n \"transport_cost\": 300.0,\n \"dining_cost\": 800.0,\n \"hotel_cost\": 1200.0,\n \"attraction_ticket_cost\": 400.0,\n \"total\": 2700.0\n }," + }, + "text_representation": "偏好类型: trip_planning 目的地: 北京 旅行偏好: 历史, 文化", + "timestamp": "2026-01-07T01:14:56.172523" + }, + "80": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:15:02.175874" + }, + "81": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "trip", + "data": { + "destination": "北京", + "start_date": "2024-10-01", + "end_date": "2024-10-03", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等", + "trip_title": "北京历史文化之旅", + "days": [ + { + "day": 1, + "theme": "古都历史探索", + "weather": { + "date": "2024-10-01", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "25", + "night_temp": "15", + "day_wind": "东风3级", + "night_wind": "西北风2级" + }, + "recommended_hotel": { + "name": "北京首都宾馆", + "address": "北京市东城区东直门内北小街大甜水井胡同8号", + "location": { + "lat": 39.917324, + "lng": 116.407387 + }, + "price": "400元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 2.0 + }, + "attractions": [ + { + "name": "天安门广场", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "天安门广场是北京市中心的一个大型城市中心广场,是世界上最大的城市中心广场之一。", + "address": "北京市东城区天安门广场", + "location": { + "lat": 39.917324, + "lng": 116.407387 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1603258745248-e18b43394c00?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlQTQlQTklRTUlQUUlODklRTklOTclQTglRTUlQjklQkYlRTUlOUMlQkElMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "故宫博物院", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 4.0, + "description": "故宫博物院是中国明清两代的皇家宫殿,也是世界上现存规模最大、保存最完整的木质结构古建筑之一。", + "address": "北京市东城区景山前街4号", + "location": { + "lat": 39.917324, + "lng": 116.407387 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&h=600&fit=crop" + ], + "ticket_price": "60元" + } + ], + "dinings": [ + { + "name": "全聚德烤鸭店", + "address": "北京市东城区前门大街18号", + "location": { + "lat": 39.917324, + "lng": 116.407387 + }, + "cost_per_person": "150", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 200.0, + "hotel_cost": 400.0, + "attraction_ticket_cost": 120.0, + "total": 820.0 + } + }, + { + "day": 2, + "theme": "皇家园林体验", + "weather": { + "date": "2024-10-02", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "23", + "night_temp": "13", + "day_wind": "东南风2级", + "night_wind": "东北风3级" + }, + "recommended_hotel": { + "name": "北京京师大厦", + "address": "北京市海淀区中关村大街19号", + "location": { + "lat": 39.980823, + "lng": 116.304156 + }, + "price": "350元/晚", + "rating": "4.3", + "distance_to_main_attraction_km": 3.0 + }, + "attractions": [ + { + "name": "颐和园", + "type": "历史文化", + "rating": "4.6", + "suggested_duration_hours": 5.0, + "description": "颐和园是中国清朝时期的皇家园林,被誉为‘皇家园林博物馆’。", + "address": "北京市海淀区新建宫门路19号", + "location": { + "lat": 39.980823, + "lng": 116.304156 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1713409185043-e14b49a87dd3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTklQTIlOTAlRTUlOTIlOEMlRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE4Njg0fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "40元" + } + ], + "dinings": [ + { + "name": "大董烤鸭店", + "address": "北京市海淀区中关村大街19号", + "location": { + "lat": 39.980823, + "lng": 116.304156 + }, + "cost_per_person": "120", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 120.0, + "hotel_cost": 350.0, + "attraction_ticket_cost": 40.0, + "total": 610.0 + } + }, + { + "day": 3, + "theme": "长城与现代文化", + "weather": { + "date": "2024-10-03", + "day_weather": "晴", + "night_weather": "晴", + "day_temp": "22", + "night_temp": "12", + "day_wind": "西南风2级", + "night_wind": "西北风3级" + }, + "recommended_hotel": { + "name": "北京西苑饭店", + "address": "北京市昌平区回龙观镇龙城路3号", + "location": { + "lat": 40.148955, + "lng": 116.288683 + }, + "price": "300元/晚", + "rating": "4.2", + "distance_to_main_attraction_km": 20.0 + }, + "attractions": [ + { + "name": "八达岭长城", + "type": "历史文化", + "rating": "4.8", + "suggested_duration_hours": 6.0, + "description": "八达岭长城是中国古代伟大的军事工程之一,也是世界文化遗产之一。", + "address": "北京市延庆区军都山关沟古道北口", + "location": { + "lat": 40.417623, + "lng": 116.004944 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1620964780032-81ef649db4d9?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE5MTQyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "40元" + }, + { + "name": "北京动物园", + "type": "历史文化", + "rating": "4.5", + "suggested_duration_hours": 2.0, + "description": "北京动物园不仅展示各种珍稀动物,还融入了一些历史文化元素。", + "address": "北京市海淀区西三环北路54号", + "location": { + "lat": 39.980823, + "lng": 116.304156 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1586783284267-38af1eac2657?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTUlOEMlOTclRTQlQkElQUMlRTUlOEElQTglRTclODklQTklRTUlOUIlQUQlMjAlRTUlOEMlOTclRTQlQkElQUN8ZW58MHx8fHwxNzY3NzE5NzAyfDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "海底捞火锅", + "address": "北京市昌平区回龙观镇龙城路3号", + "location": { + "lat": 40.148955, + "lng": 116.288683 + }, + "cost_per_person": "100", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 100.0, + "hotel_cost": 300.0, + "attraction_ticket_cost": 80.0, + "total": 580.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 北京 时间: 2024-10-01 到 2024-10-03 偏好: 历史, 文化 行程标题: 北京历史文化之旅 景点: 天安门广场, 故宫博物院, 颐和园, 八达岭长城, 北京动物园", + "timestamp": "2026-01-07T01:15:02.211405" + }, + "82": { + "user_id": "179d072a-164f-483b-8925-03dd13559495", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "北京", + "preferences": [ + "历史", + "文化" + ], + "hotel_preferences": [ + "经济型" + ], + "budget": "中等" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 北京 旅行偏好: 历史, 文化 酒店偏好: 经济型 预算水平: 中等", + "timestamp": "2026-01-07T01:15:02.243946" + }, + "83": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_planning", + "data": { + "preference_type": "trip_planning", + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "trip_result": "```json\n{\n \"trip_title\": \"成都美食与休闲之旅\",\n \"total_budget\": {\n \"transport_cost\": 400.0,\n \"dining_cost\": 1200.0,\n \"hotel_cost\": 1600.0,\n \"attraction_ticket_cost\": 360.0,\n \"total\": 3560.0\n " + }, + "text_representation": "偏好类型: trip_planning 目的地: 成都 旅行偏好: 美食, 休闲", + "timestamp": "2026-01-07T01:19:14.888524" + }, + "84": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_request", + "data": { + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "成都美食与休闲之旅" + }, + "text_representation": "偏好类型: trip_request 目的地: 成都 旅行偏好: 美食, 休闲 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2026-01-07T01:19:36.041727" + }, + "85": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "trip", + "data": { + "destination": "成都", + "start_date": "2026-01-13", + "end_date": "2026-01-16", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕", + "trip_title": "成都美食与休闲之旅", + "days": [ + { + "day": 1, + "theme": "城市中心探索", + "weather": { + "date": "2026-01-13", + "day_weather": "阴", + "night_weather": "小雨", + "day_temp": "12", + "night_temp": "8", + "day_wind": "北风2级", + "night_wind": "东北风2级" + }, + "recommended_hotel": { + "name": "成都华尔道夫酒店", + "address": "成都市青羊区西大街1号", + "location": { + "lat": 30.6733, + "lng": 104.0603 + }, + "price": "1600元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "锦里古街", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 3.0, + "description": "锦里古街是成都著名的旅游景点之一,这里不仅有各种传统小吃,还有许多手工艺品和古建筑,非常适合品尝成都地道美食的同时感受当地的文化氛围。", + "address": "成都市青羊区青羊大道18号", + "location": { + "lat": 30.6721, + "lng": 104.0635 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=800&h=600&fit=crop" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "蜀大侠火锅", + "address": "成都市青羊区青羊大道18号", + "location": { + "lat": 30.6721, + "lng": 104.0635 + }, + "cost_per_person": "180", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 300.0, + "hotel_cost": 1600.0, + "attraction_ticket_cost": 0.0, + "total": 1000.0 + } + }, + { + "day": 2, + "theme": "美食与购物", + "weather": { + "date": "2026-01-14", + "day_weather": "阴", + "night_weather": "小雨", + "day_temp": "11", + "night_temp": "7", + "day_wind": "北风2级", + "night_wind": "东北风2级" + }, + "recommended_hotel": { + "name": "成都香格里拉大酒店", + "address": "成都市青羊区人民公园路1号", + "location": { + "lat": 30.6695, + "lng": 104.0621 + }, + "price": "1200元/晚", + "rating": "4.5", + "distance_to_main_attraction_km": 1.0 + }, + "attractions": [ + { + "name": "宽窄巷子", + "type": "历史文化", + "rating": "4.6", + "suggested_duration_hours": 4.0, + "description": "宽窄巷子内有许多具有成都特色的餐馆和小吃店,可以品尝到成都的传统美食如担担面、麻婆豆腐等。", + "address": "成都市青羊区长顺街", + "location": { + "lat": 30.6704, + "lng": 104.0618 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1622613744987-0e3527fae518?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlODglOTAlRTklODMlQkR8ZW58MHx8fHwxNzY3NzE5OTY3fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + } + ], + "dinings": [ + { + "name": "陈麻婆豆腐", + "address": "成都市青羊区长顺街", + "location": { + "lat": 30.6704, + "lng": 104.0618 + }, + "cost_per_person": "120", + "rating": "4.5" + }, + { + "name": "赖汤圆", + "address": "成都市青羊区宽窄巷子内", + "location": { + "lat": 30.6704, + "lng": 104.0618 + }, + "cost_per_person": "80", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 200.0, + "hotel_cost": 1200.0, + "attraction_ticket_cost": 0.0, + "total": 700.0 + } + }, + { + "day": 3, + "theme": "自然与文化", + "weather": { + "date": "2026-01-15", + "day_weather": "晴", + "night_weather": "多云", + "day_temp": "15", + "night_temp": "10", + "day_wind": "南风3级", + "night_wind": "西南风2级" + }, + "recommended_hotel": { + "name": "成都华尔道夫酒店", + "address": "成都市青羊区西大街1号", + "location": { + "lat": 30.6733, + "lng": 104.0603 + }, + "price": "1600元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "春熙路美食街", + "type": "美食街", + "rating": "4.5", + "suggested_duration_hours": 3.0, + "description": "春熙路美食街汇聚了来自全国各地的美食,尤其是川菜和火锅,非常热闹,适合寻找更多元化的成都美食体验。", + "address": "成都市锦江区春熙路", + "location": { + "lat": 30.6618, + "lng": 104.0546 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1622613744987-0e3527fae518?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5MzZ8MHwxfHNlYXJjaHwxfHwlRTYlODglOTAlRTklODMlQkR8ZW58MHx8fHwxNzY3NzE5OTY3fDA&ixlib=rb-4.1.0&q=80&w=1080" + ], + "ticket_price": "免费" + }, + { + "name": "成都大熊猫繁育研究基地", + "type": "自然", + "rating": "4.8", + "suggested_duration_hours": 2.0, + "description": "成都大熊猫繁育研究基地位于成都市高新区,是世界上最大的大熊猫保护研究机构,可以近距离观察到可爱的大熊猫。", + "address": "成都市高新区桂溪生态公园", + "location": { + "lat": 30.7378, + "lng": 104.0562 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&h=600&fit=crop" + ], + "ticket_price": "100" + } + ], + "dinings": [ + { + "name": "蜀九香火锅", + "address": "成都市锦江区春熙路", + "location": { + "lat": 30.6618, + "lng": 104.0546 + }, + "cost_per_person": "200", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 300.0, + "hotel_cost": 1600.0, + "attraction_ticket_cost": 100.0, + "total": 1100.0 + } + }, + { + "day": 4, + "theme": "文化与休闲", + "weather": { + "date": "2026-01-16", + "day_weather": "阴", + "night_weather": "小雨", + "day_temp": "13", + "night_temp": "9", + "day_wind": "北风2级", + "night_wind": "东北风2级" + }, + "recommended_hotel": { + "name": "成都华尔道夫酒店", + "address": "成都市青羊区西大街1号", + "location": { + "lat": 30.6733, + "lng": 104.0603 + }, + "price": "1600元/晚", + "rating": "5.0", + "distance_to_main_attraction_km": 0.5 + }, + "attractions": [ + { + "name": "武侯祠", + "type": "历史文化", + "rating": "4.7", + "suggested_duration_hours": 2.0, + "description": "武侯祠位于成都市武侯区,是纪念三国时期蜀汉丞相诸葛亮的祠堂,也是中国唯一的一座君臣合祀祠庙。", + "address": "成都市武侯区武侯祠大街231号", + "location": { + "lat": 30.6656, + "lng": 104.0586 + }, + "image_urls": [ + "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=800&h=600&fit=crop" + ], + "ticket_price": "60" + } + ], + "dinings": [ + { + "name": "龙抄手", + "address": "成都市武侯区武侯祠大街231号", + "location": { + "lat": 30.6656, + "lng": 104.0586 + }, + "cost_per_person": "50", + "rating": "4.5" + } + ], + "budget": { + "transport_cost": 100.0, + "dining_cost": 150.0, + "hotel_cost": 1600.0, + "attraction_ticket_cost": 60.0, + "total": 810.0 + } + } + ] + }, + "text_representation": "旅行行程 目的地: 成都 时间: 2026-01-13 到 2026-01-16 偏好: 美食, 休闲 行程标题: 成都美食与休闲之旅 景点: 锦里古街, 宽窄巷子, 春熙路美食街, 成都大熊猫繁育研究基地, 武侯祠", + "timestamp": "2026-01-07T01:19:36.082089" + }, + "86": { + "user_id": "01a3379c-f974-4e8d-80f4-436ae47a6a2f", + "type": "preference", + "preference_type": "trip_preferences", + "data": { + "destination": "成都", + "preferences": [ + "美食", + "休闲" + ], + "hotel_preferences": [ + "舒适型", + "高档型" + ], + "budget": "宽裕" + }, + "text_representation": "偏好类型: trip_preferences 目的地: 成都 旅行偏好: 美食, 休闲 酒店偏好: 舒适型, 高档型 预算水平: 宽裕", + "timestamp": "2026-01-07T01:19:36.110405" + } +} \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/frontend/.env.example b/Co-creation-projects/275145-TripPlanner/frontend/.env.example new file mode 100644 index 00000000..be9b1f2f --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/.env.example @@ -0,0 +1,8 @@ +# API 基础地址 +VITE_API_BASE_URL=http://localhost:8000 + +# 高德地图 API Key(请替换为您的实际 Key) +VITE_AMAP_KEY="" + +# 高德地图安全密钥(如果需要) +VITE_AMAP_SECURITY_CODE="" diff --git a/Co-creation-projects/275145-TripPlanner/frontend/.gitignore b/Co-creation-projects/275145-TripPlanner/frontend/.gitignore new file mode 100644 index 00000000..42e3178e --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/.gitignore @@ -0,0 +1,27 @@ +# 依赖文件 +node_modules/ +dist/ +dist-ssr/ +*.local + +# 日志文件 +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# 编辑器目录和文件 +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# 环境变量 +.env +.env.*.local diff --git a/Co-creation-projects/275145-TripPlanner/frontend/README.md b/Co-creation-projects/275145-TripPlanner/frontend/README.md new file mode 100644 index 00000000..fc174806 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/README.md @@ -0,0 +1,163 @@ +# 智能旅行助手 - 前端 + +基于 Vue 3 + TypeScript + Element Plus 的智能旅行规划前端应用。 + +## 功能特性 + +- 🌍 **智能行程规划**:输入目的地和偏好,AI 自动生成完整行程 +- 🗺️ **地图可视化**:高德地图集成,标注景点位置和游览路线 +- 💰 **预算计算**:自动统计门票、酒店、餐饮等费用 +- ✏️ **行程编辑**:支持添加、删除、调整景点和活动 +- 📄 **导出功能**:支持导出为 PDF 或图片格式 + +## 技术栈 + +- Vue 3 - 渐进式 JavaScript 框架 +- TypeScript - 类型安全的 JavaScript 超集 +- Vite - 下一代前端构建工具 +- Element Plus - Vue 3 组件库 +- Vue Router - 官方路由管理器 +- Axios - HTTP 客户端 +- 高德地图 JS API - 地图服务 +- html2canvas + jsPDF - 导出功能 + +## 快速开始 + +### 安装依赖 + +\`\`\`bash +npm install +\`\`\` + +### 配置环境变量 + +复制 `.env` 文件并配置您的 API 密钥: + +\`\`\`bash +# .env.local +VITE_API_BASE_URL=http://localhost:8000 +VITE_AMAP_KEY=您的高德地图Key +\`\`\` + +### 开发模式 + +\`\`\`bash +npm run dev +\`\`\` + +访问 http://localhost:5173 + +### 生产构建 + +\`\`\`bash +npm run build +\`\`\` + +## 项目结构 + +\`\`\` +src/ +├── components/ # 通用组件 +│ ├── MapView.vue # 地图组件 +│ ├── BudgetSummary.vue # 预算组件 +│ └── ExportButtons.vue # 导出组件 +├── views/ # 页面组件 +│ ├── Home.vue # 首页(规划表单) +│ ├── Result.vue # 结果展示页 +│ └── EditPlan.vue # 编辑页面 +├── services/ # API 服务 +│ └── api.ts # API 请求封装 +├── types/ # 类型定义 +│ └── index.ts # TypeScript 接口 +├── router/ # 路由配置 +│ └── index.ts # 路由定义 +├── App.vue # 根组件 +└── main.ts # 应用入口 +\`\`\` + +## 主要功能 + +### 1. 智能行程规划 + +用户在首页填写表单: +- 目的地 +- 出行日期 +- 旅行偏好 +- 酒店偏好 +- 预算范围 + +提交后系统调用后端 API,生成智能行程计划。 + +### 2. 地图可视化 + +使用高德地图展示: +- 景点位置标记 +- 游览路线绘制 +- 信息窗口展示详情 +- 自适应视野调整 + +### 3. 预算计算 + +自动统计并分类显示: +- 景点门票费用 +- 餐饮美食费用 +- 酒店住宿费用 +- 交通及其他费用 + +### 4. 行程编辑 + +支持灵活编辑: +- 添加/删除天数 +- 添加/删除/修改活动 +- 编辑酒店信息 +- 实时预算更新 + +### 5. 导出功能 + +支持多种导出格式: +- PDF 文档导出 +- PNG 图片导出 +- 自定义导出内容 + +## API 接口 + +### 创建行程规划 + +\`\`\`typescript +POST /api/v1/trips/plan + +请求体: +{ + destination: string + start_date: string + end_date: string + preferences: string[] + hotel_preferences: string[] + budget: string +} + +响应: +{ + trip_title: string + total_budget: number + hotels: Hotel[] + days: DailyPlan[] +} +\`\`\` + +## 开发说明 + +- 使用 Vue 3 Composition API +- TypeScript 严格模式 +- ESLint + Prettier 代码规范 +- 响应式设计,适配移动端 + +## 注意事项 + +1. 需要申请高德地图 API Key +2. 确保后端服务已启动(默认 http://localhost:8000) +3. 首次运行需要安装依赖 + +## 许可证 + +MIT diff --git a/Co-creation-projects/275145-TripPlanner/frontend/index.html b/Co-creation-projects/275145-TripPlanner/frontend/index.html new file mode 100644 index 00000000..5ae01919 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 智能旅行助手 + + +
+ + + diff --git a/Co-creation-projects/275145-TripPlanner/frontend/package-lock.json b/Co-creation-projects/275145-TripPlanner/frontend/package-lock.json new file mode 100644 index 00000000..4bae78f2 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/package-lock.json @@ -0,0 +1,2918 @@ +{ + "name": "trip-planner-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trip-planner-frontend", + "version": "1.0.0", + "dependencies": { + "@amap/amap-jsapi-loader": "^1.0.1", + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.6.2", + "dayjs": "^1.11.10", + "element-plus": "^2.5.0", + "html2canvas": "^1.4.1", + "jspdf": "^3.0.4", + "pinia": "^2.3.1", + "vue": "^3.4.0", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@types/node": "^20.10.6", + "@vitejs/plugin-vue": "^5.0.0", + "autoprefixer": "^10.4.22", + "postcss": "^8.5.6", + "sass": "^1.69.5", + "tailwindcss": "^4.1.17", + "typescript": "^5.3.3", + "vite": "^6.4.1", + "vue-tsc": "^3.1.5" + } + }, + "node_modules/@amap/amap-jsapi-loader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz", + "integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==", + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.23.tgz", + "integrity": "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.23" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.23.tgz", + "integrity": "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.23.tgz", + "integrity": "sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.5.tgz", + "integrity": "sha512-FMcqyzWN+sYBeqRMWPGT2QY0mUasZMVIuHvmb5NT3eeqPrbHBYtCP8JWEUCDCgM+Zr62uuWY/qoeBrPrzfa78w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz", + "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz", + "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz", + "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/runtime-core": "3.5.25", + "@vue/shared": "3.5.25", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz", + "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "vue": "3.5.25" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.1.tgz", + "integrity": "sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.2.tgz", + "integrity": "sha512-PxSsosKQjI38iXkmb3d0Y32efqyA0uW4s41u4IVBsLlWLhCiYNpH/AfNOVWRqCQBlD8TFJTz6OUWNd4DFJCnmw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-js": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.264", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.264.tgz", + "integrity": "sha512-1tEf0nLgltC3iy9wtlYDlQDc5Rg9lEKVjEmIHJ21rI9OcqkvD45K1oyNIRA4rR1z3LgJ7KeGzEBojVcV6m4qjA==", + "dev": true, + "license": "ISC" + }, + "node_modules/element-plus": { + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.11.9.tgz", + "integrity": "sha512-yTckX+fMGDGiBHVL1gpwfmjEc8P8OwuyU5ZX3f4FhMy2OdC2Y+OwQYWUXmuB+jFMukuSdnGYXYgHq6muBjSxTg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jspdf": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", + "integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.94.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz", + "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", + "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-sfc": "3.5.25", + "@vue/runtime-dom": "3.5.25", + "@vue/server-renderer": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", + "integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.1.5.tgz", + "integrity": "sha512-L/G9IUjOWhBU0yun89rv8fKqmKC+T0HfhrFjlIml71WpfBv9eb4E9Bev8FMbyueBIU9vxQqbd+oOsVcDa5amGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.23", + "@vue/language-core": "3.1.5" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/Co-creation-projects/275145-TripPlanner/frontend/package.json b/Co-creation-projects/275145-TripPlanner/frontend/package.json new file mode 100644 index 00000000..f6ef6656 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "trip-planner-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@amap/amap-jsapi-loader": "^1.0.1", + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.6.2", + "dayjs": "^1.11.10", + "element-plus": "^2.5.0", + "html2canvas": "^1.4.1", + "jspdf": "^3.0.4", + "pinia": "^2.3.1", + "vue": "^3.4.0", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@types/node": "^20.10.6", + "@vitejs/plugin-vue": "^5.0.0", + "autoprefixer": "^10.4.22", + "postcss": "^8.5.6", + "sass": "^1.69.5", + "tailwindcss": "^4.1.17", + "typescript": "^5.3.3", + "vite": "^6.4.1", + "vue-tsc": "^3.1.5" + } +} diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/App.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/App.vue new file mode 100644 index 00000000..31150963 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/App.vue @@ -0,0 +1,225 @@ + + + + + + + diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/components/BudgetSummary.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/components/BudgetSummary.vue new file mode 100644 index 00000000..86314330 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/components/BudgetSummary.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/components/ExportButtons.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/components/ExportButtons.vue new file mode 100644 index 00000000..d8aa5647 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/components/ExportButtons.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/components/LoadingProgress.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/components/LoadingProgress.vue new file mode 100644 index 00000000..3faa40e3 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/components/LoadingProgress.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/components/MapView.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/components/MapView.vue new file mode 100644 index 00000000..a53987b8 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/components/MapView.vue @@ -0,0 +1,552 @@ + + + + + \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/components/UserInfo.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/components/UserInfo.vue new file mode 100644 index 00000000..ce002dd2 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/components/UserInfo.vue @@ -0,0 +1,168 @@ + + + + + \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/env.d.ts b/Co-creation-projects/275145-TripPlanner/frontend/src/env.d.ts new file mode 100644 index 00000000..9d10455c --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/env.d.ts @@ -0,0 +1,17 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string + readonly VITE_AMAP_KEY: string + readonly VITE_AMAP_SECURITY_CODE: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/main.ts b/Co-creation-projects/275145-TripPlanner/frontend/src/main.ts new file mode 100644 index 00000000..45614543 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/main.ts @@ -0,0 +1,30 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import router from './router' +import App from './App.vue' + +const app = createApp(App) + +// 首先,创建并注册Pinia +const pinia = createPinia() +app.use(pinia) + +// 注册 Element Plus +app.use(ElementPlus, { + locale: zhCn +}) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +// 注册路由 +app.use(router) + +// 最后挂载应用 +app.mount('#app') diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/router/index.ts b/Co-creation-projects/275145-TripPlanner/frontend/src/router/index.ts new file mode 100644 index 00000000..ff5a68d3 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/router/index.ts @@ -0,0 +1,82 @@ +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'Home', + component: () => import('@/views/Home.vue'), + meta: { title: '智能行程规划', requiresAuth: false } + }, + { + path: '/my-trips', + name: 'MyTrips', + component: () => import('@/views/MyTrips.vue'), + meta: { title: '我的行程', requiresAuth: true } + }, + { + path: '/result', + name: 'Result', + component: () => import('@/views/Result.vue'), + meta: { title: '行程详情', requiresAuth: true } + }, + { + path: '/edit', + name: 'EditPlan', + component: () => import('@/views/EditPlan.vue'), + meta: { title: '编辑行程', requiresAuth: true } + }, + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { title: '用户登录', requiresAuth: false } + }, + { + path: '/profile', + name: 'Profile', + component: () => import('@/views/Profile.vue'), + meta: { title: '个人资料', requiresAuth: true } + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 - 延迟初始化store以避免循环依赖 +router.beforeEach(async (to, _from, next) => { + try { + // 动态导入store以避免循环依赖 + const { useAuthStore } = await import('@/stores/auth') + const authStore = useAuthStore() + + // 更新页面标题 + if (to.meta.title) { + document.title = `${to.meta.title} - 智能旅行助手` + } + + // 检查是否需要认证 + if (to.meta.requiresAuth) { + // 如果未认证,重定向到登录页面 + if (!authStore.isAuthenticated) { + next('/login') + return + } + } + + // 如果已认证且访问登录页面,重定向到首页 + if (to.path === '/login' && authStore.isAuthenticated) { + next('/') + return + } + + next() + } catch (error) { + console.error('路由守卫初始化失败:', error) + next() + } +}) + +export default router diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/services/api.ts b/Co-creation-projects/275145-TripPlanner/frontend/src/services/api.ts new file mode 100644 index 00000000..f74649c5 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/services/api.ts @@ -0,0 +1,210 @@ +import axios from 'axios' +import type { + TripPlanRequest, + TripPlanResponse, + LoginRequest, + RegisterRequest, + AuthResponse, + User, + UpdateProfileRequest, + ChangePasswordRequest +} from '@/types' + +// 创建axios实例 +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000', + timeout: 300000, // 5分钟超时,为复杂的行程规划留足时间 + headers: { + 'Content-Type': 'application/json' + } +}) + +// 请求拦截器 - 自动添加认证令牌 +apiClient.interceptors.request.use( + (config) => { + // 从localStorage获取token + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 标志,防止多次登出操作 +let isLoggingOut = false + +// 响应拦截器 +apiClient.interceptors.response.use( + (response) => { + return response.data + }, + (error) => { + // 阻止重复的登出操作 + if (isLoggingOut) { + return Promise.reject(error) + } + + // 如果是401错误,清除本地存储的认证信息 + if (error.response?.status === 401) { + console.error('认证失败 (401):', { + url: error.config?.url, + method: error.config?.method, + message: error.response?.data?.detail || '未授权访问', + timestamp: new Date().toISOString() + }) + + // 检查是否是访问auth相关的API,如果是则不清除认证(可能是密码错误等) + const isAuthAPI = error.config?.url?.includes('/auth/') + + if (!isAuthAPI) { + isLoggingOut = true + localStorage.removeItem('access_token') + localStorage.removeItem('user_info') + console.warn('由于认证失败,已清除本地认证状态') + + // 延迟重定向,避免在错误处理中立即跳转 + setTimeout(() => { + window.location.href = '/login' + isLoggingOut = false + }, 100) + } + } + + const errorMessage = error.response?.data?.detail || error.message || '请求失败' + console.error('API请求失败:', { + url: error.config?.url, + status: error.response?.status, + message: errorMessage, + timestamp: new Date().toISOString() + }) + + return Promise.reject(new Error(errorMessage)) + } +) + +// 认证API服务 +export const authApi = { + /** + * 用户登录 + */ + async login(data: LoginRequest): Promise { + return apiClient.post('/api/v1/auth/login', data) + }, + + /** + * 用户注册 + */ + async register(data: RegisterRequest): Promise { + return apiClient.post('/api/v1/auth/register', data) + }, + + /** + * 获取当前用户信息 + */ + async getCurrentUser(): Promise { + return apiClient.get('/api/v1/auth/me') + }, + + /** + * 更新用户资料 + */ + async updateProfile(data: UpdateProfileRequest): Promise { + return apiClient.put('/api/v1/auth/me', data) + }, + + /** + * 修改密码 + */ + async changePassword(data: ChangePasswordRequest): Promise<{ message: string }> { + return apiClient.post('/api/v1/auth/change-password', data) + }, + + /** + * 上传头像 + */ + async uploadAvatar(file: File): Promise<{ url: string }> { + const formData = new FormData() + formData.append('file', file) + + // 使用原始的axios而不使用apiClient,以避免拦截器干扰FormData + const token = localStorage.getItem('access_token') + return apiClient.post('/api/v1/auth/upload-avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total) + console.log(`上传进度: ${percentCompleted}%`) + } + } + }) + }, + + /** + * 退出登录 + */ + async logout(): Promise<{ message: string }> { + return apiClient.post('/api/v1/auth/logout') + }, + + /** + * 创建访客会话 + */ + async createGuestSession(): Promise<{ user_id: string; message: string }> { + return apiClient.post('/api/v1/auth/guest') + } +} + +// 行程规划API服务 +export const tripApi = { + /** + * 创建行程规划 + * @param request 行程规划请求数据 + * @param cancelToken 可选的取消令牌 + */ + async createTripPlan( + request: TripPlanRequest, + cancelToken?: any + ): Promise { + return apiClient.post('/api/v1/trips/plan', request, { + cancelToken + }) + }, + + /** + * 获取用户所有行程列表 + */ + async getTripsList(): Promise { + return apiClient.get('/api/v1/trips/list') + }, + + /** + * 获取指定行程详情 + * @param tripId 行程ID + */ + async getTripDetail(tripId: string): Promise { + return apiClient.get(`/api/v1/trips/${tripId}`) + }, + + /** + * 删除指定行程 + * @param tripId 行程ID + */ + async deleteTrip(tripId: string): Promise<{ message: string }> { + return apiClient.delete(`/api/v1/trips/${tripId}`) + }, + + /** + * 健康检查 + */ + async healthCheck(): Promise<{ status: string }> { + return apiClient.get('/health') + } +} + +export default apiClient diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/stores/auth.ts b/Co-creation-projects/275145-TripPlanner/frontend/src/stores/auth.ts new file mode 100644 index 00000000..9a0a5b9f --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/stores/auth.ts @@ -0,0 +1,114 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { User, AuthState } from '@/types' +import { authApi } from '@/services/api' + +/** + * 认证状态管理 + * 使用Pinia进行状态管理,支持localStorage持久化 + */ +export const useAuthStore = defineStore('auth', () => { + // 状态 + const token = ref(null) + const user = ref(null) + + // 计算属性 + const isAuthenticated = computed(() => !!token.value && !!user.value) + const userId = computed(() => user.value?.user_id || null) + const username = computed(() => user.value?.username || '') + + /** + * 设置认证信息 + * @param accessToken JWT访问令牌 + * @param userInfo 用户信息 + */ + const setAuth = (accessToken: string, userInfo: User) => { + token.value = accessToken + user.value = userInfo + + // 保存到localStorage + localStorage.setItem('access_token', accessToken) + localStorage.setItem('user_info', JSON.stringify(userInfo)) + } + + /** + * 退出登录 + */ + const logout = async () => { + try { + // 调用后端logout API(如果需要记录退出日志等) + // 即使API调用失败,也要清除本地认证信息 + try { + await authApi.logout() + } catch (apiError) { + console.error('后端logout API调用失败:', apiError) + // 不抛出错误,继续清除本地认证信息 + } + } finally { + // 无论如何都要清除本地认证信息 + clearAuth() + } + } + + /** + * 清除认证信息 + */ + const clearAuth = () => { + token.value = null + user.value = null + + // 清除localStorage + localStorage.removeItem('access_token') + localStorage.removeItem('user_info') + } + + /** + * 从localStorage恢复认证状态 + */ + const restoreAuth = () => { + try { + const savedToken = localStorage.getItem('access_token') + const savedUserInfo = localStorage.getItem('user_info') + + if (savedToken && savedUserInfo) { + token.value = savedToken + user.value = JSON.parse(savedUserInfo) + } + } catch (error) { + console.error('恢复认证状态失败:', error) + clearAuth() + } + } + + /** + * 更新用户信息 + * @param updatedUser 更新后的用户信息 + */ + const updateUser = (updatedUser: Partial) => { + if (user.value) { + user.value = { ...user.value, ...updatedUser } + localStorage.setItem('user_info', JSON.stringify(user.value)) + } + } + + // 初始化时恢复认证状态 + restoreAuth() + + return { + // 状态 + token, + user, + + // 计算属性 + isAuthenticated, + userId, + username, + + // 方法 + setAuth, + logout, + clearAuth, + restoreAuth, + updateUser + } +}) \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/types/index.ts b/Co-creation-projects/275145-TripPlanner/frontend/src/types/index.ts new file mode 100644 index 00000000..d7c0381f --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/types/index.ts @@ -0,0 +1,192 @@ +// 基础数据模型 +export interface Location { + lat: number + lng: number +} + +// --- 核心业务模型 --- + +// 与后端 Attraction 模型对齐 +export interface Attraction { + name: string + type: string + rating: number | string + suggested_duration_hours?: number | null + description: string + address: string + location?: Location + image_urls: string[] + ticket_price: number | string + // 前端扩展字段(用于编辑) + notes?: string // 用户备注 + actual_cost?: number // 实际花费 +} + +// 与后端 Hotel 模型对齐 +export interface Hotel { + name: string + address: string + location?: Location + price: number | string + rating: number | string + distance_to_main_attraction_km?: number | null +} + +// 与后端 Dining 模型对齐 +export interface Dining { + name: string + address: string + location?: Location + cost_per_person: number | string + rating: number | string +} + +export interface Weather { + date: string + day_weather: string + night_weather: string + day_temp: string + night_temp: string + day_wind?: string | null + night_wind?: string | null +} + +// 行程规划请求模型 +export interface TripPlanRequest { + destination: string + start_date: string + end_date: string + preferences: string[] + hotel_preferences: string[] + budget: string +} + +// 预算拆分(与后端 BudgetBreakdown 对齐) +export interface BudgetBreakdown { + transport_cost: number + dining_cost: number + hotel_cost: number + attraction_ticket_cost: number + total: number +} + +// 单日预算(与后端 DailyBudget 对齐) +export interface DailyBudget { + transport_cost: number + dining_cost: number + hotel_cost: number + attraction_ticket_cost: number + total: number +} + +// 每日行程计划(与后端 DailyPlan 对齐) +export interface DailyPlan { + day: number + theme: string + weather?: Weather + recommended_hotel?: Hotel | null + attractions: Attraction[] + dinings: Dining[] + budget: DailyBudget +} + +// 行程规划响应模型(与后端 TripPlanResponse 对齐) +export interface TripPlanResponse { + trip_title: string + total_budget: BudgetBreakdown + hotels: Hotel[] + days: DailyPlan[] +} + +// 表单数据类型 +export interface TripFormData { + destination: string + dateRange: [string, string] + preferences: string[] + hotelPreferences: string[] + budget: string +} + +// 预算明细类型(前端展示用) +export interface BudgetDetail { + category: string + amount: number + items: { + name: string + cost: number + }[] +} + +// 地图点位类型(前端内部使用,用于 MapView 展示行程) +export interface MapPoint { + name: string + type: 'attraction' | 'dining' | 'hotel' | 'transport' | string + description?: string + cost?: number + location?: Location +} + +// 导出选项类型 +export interface ExportOptions { + format: 'pdf' | 'image' + includeBudget: boolean + includeMap: boolean +} + +// === 用户认证相关类型 === + +// 用户登录请求 +export interface LoginRequest { + username: string + password: string +} + +// 用户注册请求 +export interface RegisterRequest { + username: string + password: string +} + +// 用户信息 +export interface User { + user_id: string + username: string + user_type: string // 'registered' + phone?: string + gender?: 'male' | 'female' | 'other' + birthday?: string + bio?: string + travel_preferences?: string[] + avatar_url?: string +} + +// 用户资料更新请求 +export interface UpdateProfileRequest { + username?: string + phone?: string + gender?: 'male' | 'female' | 'other' + birthday?: string + bio?: string + travel_preferences?: string[] + avatar_url?: string +} + +// 修改密码请求 +export interface ChangePasswordRequest { + old_password: string + new_password: string +} + +// 认证令牌响应 +export interface AuthResponse { + access_token: string + token_type: string + user: User +} + +// 认证状态 +export interface AuthState { + isAuthenticated: boolean + user: User | null + token: string | null +} diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/views/EditPlan.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/views/EditPlan.vue new file mode 100644 index 00000000..dbcf88aa --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/views/EditPlan.vue @@ -0,0 +1,764 @@ + + + + + + diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/views/Home.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/views/Home.vue new file mode 100644 index 00000000..e5adf650 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/views/Home.vue @@ -0,0 +1,779 @@ + + + + + diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/views/Login.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/views/Login.vue new file mode 100644 index 00000000..a6257f07 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/views/Login.vue @@ -0,0 +1,401 @@ + + + + + \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/views/MyTrips.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/views/MyTrips.vue new file mode 100644 index 00000000..154b2206 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/views/MyTrips.vue @@ -0,0 +1,499 @@ + + + + + \ No newline at end of file diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/views/Profile.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/views/Profile.vue new file mode 100644 index 00000000..217ba42d --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/views/Profile.vue @@ -0,0 +1,744 @@ + + + + + diff --git a/Co-creation-projects/275145-TripPlanner/frontend/src/views/Result.vue b/Co-creation-projects/275145-TripPlanner/frontend/src/views/Result.vue new file mode 100644 index 00000000..a97e7ed4 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/src/views/Result.vue @@ -0,0 +1,1051 @@ + + + + + diff --git a/Co-creation-projects/275145-TripPlanner/frontend/tsconfig.json b/Co-creation-projects/275145-TripPlanner/frontend/tsconfig.json new file mode 100644 index 00000000..cfc40124 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/Co-creation-projects/275145-TripPlanner/frontend/tsconfig.node.json b/Co-creation-projects/275145-TripPlanner/frontend/tsconfig.node.json new file mode 100644 index 00000000..42872c59 --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/Co-creation-projects/275145-TripPlanner/frontend/vite.config.ts b/Co-creation-projects/275145-TripPlanner/frontend/vite.config.ts new file mode 100644 index 00000000..240efccb --- /dev/null +++ b/Co-creation-projects/275145-TripPlanner/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src') + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + }, + '/uploads': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + } +})