chandrashekar8 commited on
Commit
fdceecd
·
1 Parent(s): cd86c42

major changes

Browse files
Files changed (42) hide show
  1. COPILOT_MASTER_PROMPT.md +1568 -0
  2. hf_spaces/embeddings/app.py +91 -0
  3. hf_spaces/llm/app.py +136 -0
  4. hf_spaces/stt/app.py +101 -0
  5. rasa/actions/__init__.py +2 -0
  6. rasa/actions/actions_brain.py +243 -0
  7. rasa/config.yml +38 -0
  8. rasa/credentials.yml +12 -0
  9. rasa/data/nlu/nlu_en.yml +207 -0
  10. rasa/domain.yml +177 -0
  11. rasa/endpoints.yml +24 -0
  12. scripts/hotel_onboarding.py +89 -0
  13. scripts/setup_elasticsearch_index.py +85 -0
  14. scripts/setup_messenger_menu.py +62 -0
  15. scripts/setup_qdrant_collection.py +43 -0
  16. services/analytics_service/clickhouse_schema.sql +55 -0
  17. services/analytics_service/kafka_consumer.py +116 -0
  18. services/auth_service/main.py +135 -0
  19. services/corporate_service/approval_engine.py +96 -0
  20. services/corporate_service/main.py +69 -0
  21. services/corporate_service/rate_lookup.py +83 -0
  22. services/force_majeure_service/main.py +54 -0
  23. services/force_majeure_service/news_monitor.py +100 -0
  24. services/force_majeure_service/relocation.py +76 -0
  25. services/group_service/deposit_scheduler.py +56 -0
  26. services/group_service/main.py +76 -0
  27. services/group_service/room_block.py +99 -0
  28. services/loyalty_service/gamification.py +71 -0
  29. services/loyalty_service/main.py +81 -0
  30. services/loyalty_service/points_engine.py +138 -0
  31. services/loyalty_service/tier_engine.py +54 -0
  32. services/maps_service/nominatim.py +97 -0
  33. services/notification_service/main.py +84 -0
  34. services/review_service/celery_tasks.py +75 -0
  35. services/review_service/main.py +90 -0
  36. services/review_service/sentiment.py +78 -0
  37. services/voice_service/audio_utils.py +55 -0
  38. services/voice_service/main.py +60 -0
  39. services/voice_service/stt.py +74 -0
  40. services/voice_service/tts.py +121 -0
  41. tasks/celery_app.py +50 -0
  42. tasks/scheduled_tasks.py +100 -0
COPILOT_MASTER_PROMPT.md ADDED
@@ -0,0 +1,1568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🤖 GITHUB COPILOT MASTER PROMPT
2
+ # Hotel Booking AI — Universal Smart Booking System
3
+ # Paste this ENTIRE file into Copilot Chat before starting any task.
4
+ # Then use the TASK PROMPTS below for specific coding work.
5
+
6
+ ---
7
+
8
+ ## ═══════════════════════════════════════════════
9
+ ## SECTION 1 — SYSTEM CONTEXT (READ BEFORE ANY TASK)
10
+ ## ═══════════════════════════════════════════════
11
+
12
+ ```
13
+ You are a senior full-stack AI engineer working on a production Hotel Booking AI
14
+ Chatbot delivered exclusively through Facebook Messenger. The system is ALREADY
15
+ PARTIALLY BUILT. Module 1 (Language Detection + Greeting) is COMPLETE and working.
16
+
17
+ Your job is to CONTINUE building from the existing codebase — never rewrite what
18
+ already works, only extend and upgrade it.
19
+
20
+ ARCHITECTURE RULE (NON-NEGOTIABLE):
21
+ - ONE HuggingFace Space hosts: ALL Python backend logic + ALL AI inference +
22
+ ALL database calls (Supabase + Redis + Qdrant + Elasticsearch)
23
+ - Render hosts: ONLY the Rasa webhook receiver + messenger channel connector
24
+ (Render has limited RAM — keep it a thin relay, nothing more)
25
+ - Facebook Messenger is the ONLY user interface
26
+ - No local development environment — all code runs on HF Space or Render
27
+
28
+ EXISTING COMPLETED FILES (DO NOT REWRITE):
29
+ ✅ services/language_service/detector.py — langdetect + XLM-RoBERTa
30
+ ✅ services/language_service/translator.py — Helsinki-NLP translation
31
+ ✅ services/language_service/main.py — FastAPI /detect endpoint
32
+ ✅ actions/actions_brain.py::ActionGreetUser — multilingual greeting action
33
+ ✅ data/nlu/nlu_en.yml (intent: greet) — 25 training examples
34
+ ✅ db/supabase_client.py — connection pool setup
35
+ ✅ db/redis_client.py — async + sync clients
36
+ ✅ channels/messenger_connector.py — FB webhook verified
37
+ ✅ config.yml — Rasa pipeline configured
38
+ ✅ credentials.yml — Messenger credentials
39
+ ✅ endpoints.yml — Redis tracker store
40
+ ✅ domain.yml (partial) — base slots + greet response
41
+
42
+ WHEN I SAY "continue from existing files" I mean:
43
+ 1. Import from the completed files above — never duplicate their logic
44
+ 2. Check existing slot names in domain.yml before adding new ones
45
+ 3. Append to actions files — never replace existing action classes
46
+ 4. Append to nlu_en.yml — never replace existing intents
47
+ 5. Check Redis key patterns in SKILL.md before creating new keys
48
+ ```
49
+
50
+ ---
51
+
52
+ ## ═══════════════════════════════════════════════
53
+ ## SECTION 2 — UX INTERACTION RULES (CRITICAL)
54
+ ## ═══════════════════════════════════════════════
55
+
56
+ ```
57
+ INTERACTION DESIGN LAW:
58
+ > 50% of all user interactions MUST be button clicks (Messenger quick replies
59
+ or generic template buttons) — not free text typing.
60
+
61
+ BUTTON-FIRST PRINCIPLE:
62
+ Every bot message that presents choices MUST show buttons. Never ask the user
63
+ to type something they could click.
64
+
65
+ WHEN TO USE QUICK REPLIES (max 13, text max 20 chars):
66
+ - Yes/No confirmations
67
+ - Language selection
68
+ - Trip purpose selection
69
+ - Star rating filter
70
+ - Meal plan selection
71
+ - Add-on accept/decline
72
+ - Review star rating (1-5 stars as emoji buttons)
73
+ - "Show more" / "Next" / "Back" navigation
74
+ - Post-booking options (Directions / Weather / Share)
75
+
76
+ WHEN TO USE GENERIC TEMPLATE CARDS (carousel, max 10 cards):
77
+ - Hotel search results (1 card per hotel)
78
+ - Room type options (1 card per room)
79
+ - Add-on catalogue items
80
+ - Booking summary before payment
81
+ Each card MUST have:
82
+ - image_url (hotel/room thumbnail from Supabase Storage)
83
+ - title (hotel/room name, max 80 chars)
84
+ - subtitle (key details: stars, price, key feature)
85
+ - buttons: max 3 per card [View Details | Select This | Save for Later]
86
+
87
+ WHEN TO USE LIST TEMPLATE:
88
+ - Booking modification options
89
+ - Loyalty tier benefits
90
+ - FAQ category selection
91
+ - Post-booking service menu
92
+
93
+ WHEN TO ACCEPT FREE TEXT (and ONLY then):
94
+ - Guest name input
95
+ - Email address input
96
+ - Phone number input
97
+ - Special requests / dietary notes
98
+ - Review free-text comment
99
+ - Any FAQ question (open search)
100
+
101
+ VOICE INTERACTION RULE:
102
+ - Voice input is ALWAYS transcribed to text first (STT on HF Space)
103
+ - After transcription, the same button-first response logic applies
104
+ - Bot voice responses (TTS) are offered as an audio attachment option
105
+ - Voice is activated by user tapping a 🎙️ button in Messenger Webview
106
+
107
+ PROGRESSIVE DISCLOSURE RULE:
108
+ - Never show all options at once �� show top 3 with a "Show more →" button
109
+ - Never ask more than ONE question per message
110
+ - Never combine a question and a statement in the same bubble — split into 2 messages
111
+ - Use typing indicators (sender_action: typing_on) before every substantive reply
112
+
113
+ SMART AUTO-DETECTION RULE (Universal System):
114
+ - Auto-detect: language, timezone (from FB profile locale), currency (from country)
115
+ - Never ask for what can be inferred — only confirm if confidence < 0.85
116
+ - Pre-fill: returning user's last search city, last guest details (with "Use saved?" button)
117
+ - Show prices in user's local currency (convert via open exchange rates)
118
+ - Date format matches user's locale (MM/DD vs DD/MM vs YYYY/MM/DD)
119
+ ```
120
+
121
+ ---
122
+
123
+ ## ═══════════════════════════════════════════════
124
+ ## SECTION 3 — HUGGINGFACE SPACE ARCHITECTURE
125
+ ## ═══════════════════════════════════════════════
126
+
127
+ ```
128
+ ONE SPACE RULES — hf_space/app.py is the SINGLE entry point for everything.
129
+
130
+ HuggingFace Space structure:
131
+ hf_space/
132
+ ├── app.py ← FastAPI app, mounts ALL routers, starts ALL services
133
+ ├── routers/
134
+ │ ├── language.py ← /api/language/* endpoints
135
+ │ ├── search.py ← /api/search/*
136
+ │ ├── rag.py ← /api/rag/*
137
+ │ ├── booking.py ← /api/booking/*
138
+ │ ├── payment.py ← /api/payment/*
139
+ │ ├── voice.py ← /api/voice/* + WebSocket
140
+ │ ├── loyalty.py ← /api/loyalty/*
141
+ │ ├── notification.py ← /api/notify/*
142
+ │ └── analytics.py ← /api/analytics/*
143
+ ├── models/
144
+ │ ├── labse_model.py ← LaBSE loaded ONCE at startup (module-level singleton)
145
+ │ ├── whisper_model.py ← Faster-Whisper loaded ONCE at startup
146
+ │ └── llm_client.py ← Groq API client (free tier) for Llama 3 — NOT local model
147
+ ├── db/
148
+ │ ├── supabase.py ← Supabase client singleton
149
+ │ └── redis.py ← Redis client singleton (Upstash Redis — HTTP-based, HF compatible)
150
+ └── requirements.txt
151
+
152
+ CRITICAL: Use Groq API (free tier) for LLM — NOT a local Llama model.
153
+ Groq gives Llama 3 70B for free with fast inference.
154
+ This avoids GPU memory issues on HF Space.
155
+ GROQ_API_KEY from environment — model: "llama3-70b-8192"
156
+
157
+ CRITICAL: Use Upstash Redis (HTTP REST API) — NOT a TCP Redis connection.
158
+ HF Spaces blocks outbound TCP on non-standard ports.
159
+ Upstash Redis works over HTTPS — fully compatible.
160
+ from upstash_redis import Redis
161
+ redis = Redis(url=os.environ["UPSTASH_REDIS_URL"], token=os.environ["UPSTASH_REDIS_TOKEN"])
162
+
163
+ CRITICAL: All AI models load at HF Space STARTUP — not per-request.
164
+ Use @asynccontextmanager lifespan in FastAPI app.py
165
+ Store models as module-level globals: labse_model = None, whisper_model = None
166
+ This prevents cold-start timeouts on first user message.
167
+
168
+ HF Space startup sequence (app.py lifespan):
169
+ 1. Connect Supabase (verify with SELECT 1)
170
+ 2. Connect Upstash Redis (verify with PING)
171
+ 3. Load LaBSE model → verify with test embed
172
+ 4. Load Faster-Whisper model → keep in memory
173
+ 5. Test Groq API → verify connection
174
+ 6. Connect Qdrant Cloud → verify collection exists
175
+ 7. Connect Elasticsearch → verify index exists
176
+ 8. Log "✅ All systems ready" to HF Space logs
177
+ 9. FastAPI begins accepting requests
178
+
179
+ RENDER (THIN WEBHOOK ONLY):
180
+ render_webhook/
181
+ ├── main.py ← FastAPI, receives Messenger webhook, forwards to HF Space
182
+ ├── rasa_relay.py ← Rasa action server that calls HF Space endpoints
183
+ └── requirements.txt ← ONLY: fastapi, uvicorn, rasa, httpx (nothing heavy)
184
+
185
+ Render main.py logic:
186
+ POST /webhooks/messenger/webhook
187
+ → verify FB signature
188
+ → extract sender_psid + message text/postback
189
+ → POST to HF_SPACE_URL/api/process_message
190
+ → receive response (text + buttons + cards)
191
+ → format for Messenger Graph API
192
+ → POST reply to graph.facebook.com/v18.0/me/messages
193
+ → return 200 OK immediately (< 200ms, before FB 5s timeout)
194
+ ```
195
+
196
+ ---
197
+
198
+ ## ═══════════════════════════════════════════════
199
+ ## SECTION 4 — UNIVERSAL SYSTEM RULES
200
+ ## ═══════════════════════════════════════════════
201
+
202
+ ```
203
+ This system serves ANYONE from ANYWHERE automatically. These rules are mandatory:
204
+
205
+ 1. AUTO-LOCALE DETECTION
206
+ On first message from new user:
207
+ - Call GET https://graph.facebook.com/v18.0/{psid}?fields=locale,timezone,name
208
+ - Parse locale: "en_US" → language="en", country="US", currency="USD"
209
+ - Parse locale: "ar_SA" → language="ar", country="SA", currency="SAR", rtl=True
210
+ - Never ask "what language?" unless confidence < 0.75
211
+ - Store in Upstash Redis: user:{psid}:profile for 30 days
212
+
213
+ 2. AUTO-CURRENCY
214
+ - All prices stored in USD in Supabase
215
+ - Display converted to user's local currency using Open Exchange Rates (free API)
216
+ - Cache rates in Redis: fx:{currency} EX 3600 (refresh hourly)
217
+ - Show: "¥28,000 / night (~$187 USD)" for transparency
218
+
219
+ 3. AUTO-TIMEZONE
220
+ - All Supabase datetimes stored as UTC
221
+ - Display times converted to user's Facebook timezone
222
+ - Date pickers show in user's local date format
223
+
224
+ 4. RETURNING USER INTELLIGENCE
225
+ On every message, before responding:
226
+ - Check Redis user:{psid}:profile for existing profile
227
+ - If returning: load last_search_city, last_booking, preferred_language, tier
228
+ - If booking in progress: resume from last state (show "Continue booking?" button)
229
+ - Pre-fill forms with saved data, show "Use saved details?" button
230
+
231
+ 5. GLOBAL PAYMENT SUPPORT
232
+ Stripe supports 135+ currencies — always charge in user's local currency if supported
233
+ Fallback: charge in USD with conversion shown
234
+ Show accepted payment methods icons as quick-reply buttons:
235
+ 💳 Card | 🍎 Apple Pay | 🤖 Google Pay | 🏦 Bank Transfer
236
+
237
+ 6. ACCESSIBILITY (non-negotiable)
238
+ - RTL languages (ar, he, fa, ur): all text labels marked with dir="rtl" in templates
239
+ - All button labels max 20 chars (Messenger limit, also aids screen readers)
240
+ - All image attachments include accessible_title field
241
+ - Audio responses always offered alongside text (never audio-only)
242
+ - Simple language mode: user can say "speak simply" → bot uses shorter sentences
243
+
244
+ 7. GRACEFUL DEGRADATION
245
+ If HF Space is cold-starting (first request after idle):
246
+ - Render webhook immediately replies: "One moment, connecting you to our AI... 🔄"
247
+ - Polls HF Space health endpoint every 2s for up to 30s
248
+ - Once ready, sends the actual response
249
+ - If HF Space fails after 30s: send fallback menu with pre-built button options
250
+
251
+ 8. SESSION CONTINUITY
252
+ User can switch devices, come back days later:
253
+ - All state in Upstash Redis keyed by Facebook PSID (not session UUID)
254
+ - Incomplete bookings preserved 48 hours with "Resume booking?" offer
255
+ - Completed bookings accessible anytime via "My Bookings" menu button
256
+ ```
257
+
258
+ ---
259
+
260
+ ## ═══════════════════════════════════════════════
261
+ ## SECTION 5 — MESSENGER RESPONSE BUILDER
262
+ ## ═══════════════════════════════════════════════
263
+
264
+ ```
265
+ ALWAYS use this builder pattern — never construct raw Messenger JSON manually.
266
+
267
+ File: render_webhook/messenger_builder.py
268
+ This file must be created and used by ALL response-sending code.
269
+
270
+ class MessengerResponse:
271
+ def __init__(self, recipient_psid: str):
272
+ self.psid = recipient_psid
273
+
274
+ def typing(self) -> dict:
275
+ """Send before every substantive message"""
276
+ return {"recipient": {"id": self.psid}, "sender_action": "typing_on"}
277
+
278
+ def text(self, message: str) -> dict:
279
+ """Plain text — auto-split if > 2000 chars"""
280
+ # Split at sentence boundaries if > 2000 chars
281
+ # Never truncate mid-word
282
+ return {"recipient": {"id": self.psid}, "message": {"text": message[:2000]}}
283
+
284
+ def quick_replies(self, text: str, options: list[dict]) -> dict:
285
+ """
286
+ options = [
287
+ {"title": "Paris 🗼", "payload": "CITY_PARIS"},
288
+ {"title": "Tokyo 🗾", "payload": "CITY_TOKYO"},
289
+ ]
290
+ Max 13 options. Title max 20 chars.
291
+ Auto-truncate titles to 20 chars with ellipsis.
292
+ """
293
+ qr = [
294
+ {
295
+ "content_type": "text",
296
+ "title": opt["title"][:20],
297
+ "payload": opt["payload"]
298
+ }
299
+ for opt in options[:13]
300
+ ]
301
+ return {
302
+ "recipient": {"id": self.psid},
303
+ "message": {"text": text, "quick_replies": qr}
304
+ }
305
+
306
+ def hotel_cards(self, hotels: list[dict]) -> dict:
307
+ """
308
+ hotels = [{
309
+ "name": "Grand Tokyo Hotel",
310
+ "stars": 5,
311
+ "price_from": 28000,
312
+ "currency": "JPY",
313
+ "price_usd": 187,
314
+ "thumbnail_url": "https://...",
315
+ "hotel_id": "h_123",
316
+ "distance_km": 1.2,
317
+ "top_feature": "Free breakfast"
318
+ }]
319
+ Max 10 hotels. Always show top 3 first with "Show 4 more →" button.
320
+ """
321
+ elements = []
322
+ for h in hotels[:10]:
323
+ stars_emoji = "⭐" * h["stars"]
324
+ elements.append({
325
+ "title": f"{h['name']} {stars_emoji}"[:80],
326
+ "subtitle": f"From {h['currency']} {h['price_from']:,}/night · {h['top_feature']}"[:80],
327
+ "image_url": h["thumbnail_url"],
328
+ "buttons": [
329
+ {"type": "postback", "title": "📋 View Details", "payload": f"HOTEL_DETAILS_{h['hotel_id']}"},
330
+ {"type": "postback", "title": "✅ Select Hotel", "payload": f"HOTEL_SELECT_{h['hotel_id']}"},
331
+ {"type": "postback", "title": "🔖 Save for Later", "payload": f"HOTEL_SAVE_{h['hotel_id']}"},
332
+ ]
333
+ })
334
+ return {
335
+ "recipient": {"id": self.psid},
336
+ "message": {
337
+ "attachment": {
338
+ "type": "template",
339
+ "payload": {"template_type": "generic", "elements": elements}
340
+ }
341
+ }
342
+ }
343
+
344
+ def room_cards(self, rooms: list[dict]) -> dict:
345
+ """Same pattern as hotel_cards but for room types"""
346
+ # buttons: View Photos | Select Room | Compare
347
+ ...
348
+
349
+ def booking_summary_card(self, booking: dict) -> dict:
350
+ """
351
+ Single card with full booking summary before payment.
352
+ Button: Confirm & Pay | Modify | Cancel
353
+ """
354
+ ...
355
+
356
+ def list_template(self, title: str, items: list[dict], cta_button: dict = None) -> dict:
357
+ """
358
+ items = [{"title": "...", "subtitle": "...", "payload": "..."}]
359
+ Max 4 items. Optional global CTA button at bottom.
360
+ Used for: modification options, loyalty info, FAQ categories
361
+ """
362
+ ...
363
+
364
+ def image(self, url: str, caption: str = None) -> dict:
365
+ """Send hotel photo, QR code, or map screenshot"""
366
+ payload = {"url": url, "is_reusable": True}
367
+ msg = {"attachment": {"type": "image", "payload": payload}}
368
+ if caption:
369
+ # Send caption as separate text message after image
370
+ pass
371
+ return {"recipient": {"id": self.psid}, "message": msg}
372
+
373
+ def file(self, url: str, filename: str) -> dict:
374
+ """Send PDF booking voucher"""
375
+ return {
376
+ "recipient": {"id": self.psid},
377
+ "message": {
378
+ "attachment": {
379
+ "type": "file",
380
+ "payload": {"url": url, "is_reusable": False}
381
+ }
382
+ }
383
+ }
384
+
385
+ def send_sequence(self, messages: list[dict], delay_ms: int = 600) -> list[dict]:
386
+ """
387
+ Send multiple messages in sequence with typing indicators.
388
+ Always: typing → message → typing → message
389
+ delay_ms between messages prevents Messenger from reordering them.
390
+ Returns ordered list to be sent one by one.
391
+ """
392
+ sequence = []
393
+ for msg in messages:
394
+ sequence.append(self.typing())
395
+ sequence.append(msg)
396
+ return sequence
397
+
398
+ # USAGE EXAMPLE in render_webhook/main.py:
399
+ async def send_to_messenger(messages: list[dict], access_token: str):
400
+ async with httpx.AsyncClient() as client:
401
+ for msg in messages:
402
+ await client.post(
403
+ f"https://graph.facebook.com/v18.0/me/messages?access_token={access_token}",
404
+ json=msg
405
+ )
406
+ if msg.get("sender_action") != "typing_on":
407
+ await asyncio.sleep(0.6) # 600ms between messages
408
+ ```
409
+
410
+ ---
411
+
412
+ ## ═══════════════════════════════════════════════
413
+ ## SECTION 6 — COMPLETE TASK PROMPTS
414
+ ## ═══════════════════════════════════════════════
415
+
416
+ ### COPY ONE PROMPT AT A TIME INTO COPILOT CHAT
417
+
418
+ ---
419
+
420
+ ### 🔵 TASK PROMPT 1 — HF SPACE UNIFIED APP SETUP
421
+
422
+ ```
423
+ TASK: Create the unified HuggingFace Space entry point.
424
+
425
+ Context: Module 1 is done. I need the single hf_space/app.py that:
426
+ 1. Loads ALL AI models at startup (LaBSE, Faster-Whisper, Groq client)
427
+ 2. Connects to ALL databases (Supabase, Upstash Redis, Qdrant, Elasticsearch)
428
+ 3. Mounts ALL routers (language, search, rag, booking, payment, voice, loyalty)
429
+ 4. Exposes GET /health that returns {"status": "ready", "models": [...], "dbs": [...]}
430
+ 5. Exposes POST /api/process_message — the MAIN endpoint called by Render webhook
431
+
432
+ The /api/process_message endpoint:
433
+ Input: {
434
+ "psid": "facebook_page_scoped_id",
435
+ "message_type": "text" | "postback" | "audio",
436
+ "text": "user message or postback payload",
437
+ "audio_url": "messenger audio url (if voice message)",
438
+ "fb_locale": "en_US", # from FB profile
439
+ "fb_timezone": -5, # from FB profile
440
+ "timestamp": 1234567890
441
+ }
442
+
443
+ Processing flow:
444
+ 1. Get/create user profile from Upstash Redis (user:{psid}:profile)
445
+ 2. If audio_url: call whisper_model.transcribe(audio_url) first
446
+ 3. If first message: auto-detect language from fb_locale, fetch FB profile
447
+ 4. Route message to correct handler based on user's current_state in Redis
448
+ 5. Return: {
449
+ "messages": [array of Messenger-formatted message objects],
450
+ "new_state": "searching" | "selecting_room" | "filling_form" | "paying" | etc.
451
+ }
452
+
453
+ Use existing: db/supabase_client.py and db/redis_client.py (import them)
454
+ UPSTASH_REDIS_URL and UPSTASH_REDIS_TOKEN replace standard Redis env vars.
455
+ Use fastapi lifespan for model loading, NOT @app.on_event (deprecated).
456
+ ```
457
+
458
+ ---
459
+
460
+ ### 🔵 TASK PROMPT 2 — MODULE 2: HOTEL SEARCH WITH BUTTON-FIRST UX
461
+
462
+ ```
463
+ TASK: Build Module 2 — Hotel Search & Discovery with full button-first UX.
464
+
465
+ Continuing from existing: ActionGreetUser is done, language is detected.
466
+ Build: hf_space/routers/search.py + render_webhook state handler for search.
467
+
468
+ CONVERSATION FLOW (button-first design):
469
+
470
+ STEP 1 — Destination (after greeting)
471
+ Bot sends TWO messages:
472
+ Message 1: "Where would you like to stay? 🌍"
473
+ Message 2: Quick replies showing:
474
+ [🗼 Paris] [🗾 Tokyo] [🏙️ Dubai] [🌴 Bali] [🎭 London] [🗽 New York] [✏️ Type city...]
475
+ If user clicks "Type city..." → bot asks for free text
476
+ If user types directly → accept free text too
477
+
478
+ STEP 2 — Dates
479
+ Bot sends:
480
+ Message 1: "When are you checking in? 📅"
481
+ Message 2: Quick replies:
482
+ [Tonight] [Tomorrow] [This weekend] [Next week] [Next month] [📅 Pick date]
483
+ "Pick date" opens Messenger Webview with a mini date-picker
484
+ After check-in: same pattern for check-out
485
+
486
+ STEP 3 — Guests
487
+ Bot sends:
488
+ Message 1: "How many guests? 👥"
489
+ Message 2: Quick replies: [1 Guest] [2 Guests] [3 Guests] [4 Guests] [4+ Guests]
490
+ If "4+ Guests": ask for number (free text)
491
+
492
+ STEP 4 — Optional filters (show as collapsible)
493
+ Bot sends:
494
+ Message 1: "Any preferences? (optional)"
495
+ Message 2: Quick replies:
496
+ [⭐ 3 Stars] [⭐⭐ 4 Stars] [⭐⭐⭐ 5 Stars] [🍳 Breakfast incl.]
497
+ [🏊 Pool] [💼 Business] [Skip filters →]
498
+
499
+ STEP 5 — Results (hotel cards carousel)
500
+ Show top 3 hotel cards first.
501
+ After cards, send quick replies:
502
+ [Show 4 more →] [Change filters 🔧] [New search 🔄] [Sort by price ↕]
503
+
504
+ SEARCH BACKEND (hf_space/routers/search.py):
505
+ POST /api/search/hotels
506
+ Input: {psid, city, check_in, check_out, num_guests, filters{stars, max_price, amenities}}
507
+
508
+ 1. Auto-detect currency from user profile → convert prices for display
509
+ 2. Auto-detect timezone → validate dates are in the future for user's timezone
510
+ 3. Call Elasticsearch with bool query + geo-distance + availability filter
511
+ 4. Call ranker.py: score = 0.4*ES + 0.3*rating + 0.2*availability + 0.1*personalization
512
+ 5. For returning users: fetch preference vector from Qdrant, boost personalization
513
+ 6. Return top 10 hotels formatted for hotel_cards() builder
514
+
515
+ GET /api/search/suggest?query={partial_city}&lang={lang}
516
+ Returns city autocomplete suggestions (from pre-loaded city list in Redis)
517
+ Used by Webview mini search input
518
+
519
+ SMART FEATURES:
520
+ - If city not found in ES: suggest closest alternative ("Did you mean Dubai? 🤔")
521
+ - If no availability for dates: show "sold out" message + suggest ±3 days alternatives
522
+ - If < 3 results: automatically expand star rating filter by ±1 and notify user
523
+ - Cache search results in Redis: search:{city}:{check_in}:{check_out} EX 300 (5 min)
524
+
525
+ State after search: set Redis user:{psid}:state = "viewing_hotels"
526
+ set Redis user:{psid}:last_search = {city, dates, guests}
527
+ ```
528
+
529
+ ---
530
+
531
+ ### 🔵 TASK PROMPT 3 — MODULE 3: ROOM SELECTION WITH SMART CARDS
532
+
533
+ ```
534
+ TASK: Build Module 3 — Room Selection.
535
+
536
+ Triggered when user clicks "✅ Select Hotel" button from hotel card (postback: HOTEL_SELECT_{id})
537
+
538
+ CONVERSATION FLOW:
539
+
540
+ TRIGGER: postback payload = "HOTEL_SELECT_{hotel_id}"
541
+
542
+ Bot immediately sends sequence:
543
+ Message 1: typing...
544
+ Message 2: "Great choice! Let me check available rooms at {hotel_name} 🏨"
545
+ Message 3: typing...
546
+ Message 4: Room cards carousel (top 3 rooms)
547
+ Message 5: Quick replies: [Show all rooms] [Back to hotels ←]
548
+
549
+ ROOM CARD FORMAT (per card):
550
+ Image: room thumbnail from Supabase Storage
551
+ Title: "Deluxe King Room • 42m²"
552
+ Subtitle: "🛏 King bed • 🛁 Bathtub • 🌆 City view • from ¥28,000/night"
553
+ Buttons:
554
+ [📸 See Photos] → postback: ROOM_PHOTOS_{room_id}
555
+ [✅ Choose Room] → postback: ROOM_SELECT_{room_id}
556
+ [ℹ️ Full Details] → postback: ROOM_DETAILS_{room_id}
557
+
558
+ ON "See Photos" (postback: ROOM_PHOTOS_{room_id}):
559
+ Send up to 5 room images one by one
560
+ After last image: quick replies [✅ Choose This Room] [← Other Rooms]
561
+
562
+ ON "Full Details" (postback: ROOM_DETAILS_{room_id}):
563
+ Send list template with:
564
+ - Size, bed type, max guests
565
+ - Amenities (wifi, minibar, AC, safe, etc.)
566
+ - Cancellation policy for this room
567
+ Then quick replies: [✅ Choose Room] [← Back to Rooms]
568
+
569
+ ON "Choose Room" (postback: ROOM_SELECT_{room_id}):
570
+ STEP 1 — Rate plan selection
571
+ Message: "Which rate plan suits you? 🍽️"
572
+ Quick replies:
573
+ [🛏 Room Only ¥28,000]
574
+ [☕ With Breakfast ¥32,000]
575
+ [🌮 Half Board ¥38,000]
576
+ [🍽 Full Board ¥45,000]
577
+
578
+ STEP 2 — After rate selected: ACQUIRE SOFT LOCK immediately
579
+ Call hf_space POST /api/rooms/soft_lock
580
+ If lock fails (race condition): "Room just got reserved by someone else!
581
+ Let me find the next best option..." → show next available room
582
+
583
+ STEP 3 — Show price summary before proceeding
584
+ Message: "📋 Your selection:"
585
+ List template:
586
+ Hotel: Grand Tokyo Hotel ⭐⭐⭐⭐⭐
587
+ Room: Deluxe King Room • City View
588
+ Plan: Breakfast Included
589
+ Check-in: Fri, 15 Mar 2026
590
+ Check-out: Sun, 17 Mar 2026
591
+ Total: ¥64,000 (~$427 USD)
592
+ Quick replies: [✅ Looks Good!] [✏️ Change Room] [❌ Start Over]
593
+
594
+ BACKEND (hf_space/routers/rooms.py):
595
+ GET /api/rooms/{hotel_id}?check_in=&check_out=&guests=
596
+ Returns available rooms with real-time availability from Supabase
597
+
598
+ POST /api/rooms/soft_lock
599
+ Input: {room_type_id, date_range, psid, lock_minutes=15}
600
+ Upstash Redis SET with NX flag (atomic)
601
+ Lua script for multi-date atomic lock
602
+ Returns: {locked: bool, expires_at, lock_id}
603
+
604
+ POST /api/rooms/refresh_lock
605
+ Called every 5 min during payment to extend lock
606
+ Input: {lock_id, psid}
607
+
608
+ State: user:{psid}:state = "rate_selected"
609
+ user:{psid}:booking_draft = {hotel_id, room_type_id, rate_plan, check_in, check_out, total}
610
+ ```
611
+
612
+ ---
613
+
614
+ ### 🔵 TASK PROMPT 4 — MODULE 4: GUEST FORM (CONVERSATIONAL, BUTTON-ASSISTED)
615
+
616
+ ```
617
+ TASK: Build Module 4 — Guest Information Collection, conversational with max button use.
618
+
619
+ NEVER show a web form. Collect everything through chat conversation.
620
+ One question per message. Validate immediately. Show friendly error, re-ask once.
621
+
622
+ CONVERSATION FLOW:
623
+
624
+ Triggered after rate_selected state confirmed.
625
+
626
+ Q1 — Name
627
+ "What's your name? 👤"
628
+ [Free text — no buttons here]
629
+ Validation: min 2 chars each part, no numbers, strip whitespace
630
+
631
+ Q2 — Email
632
+ "What email should I send your booking confirmation to? 📧"
633
+ [Free text]
634
+ Validation: RFC-5322 regex, detect common typos (gmail.con → gmail.com)
635
+ On typo: "Did you mean {corrected}? 🤔"
636
+ Quick replies: [Yes, use {corrected}] [No, let me retype]
637
+
638
+ Q3 — Phone
639
+ "Your phone number? (with country code) 📱"
640
+ [Free text]
641
+ Auto-detect country code from user's FB locale if possible
642
+ Show hint: "e.g. +1 555 123 4567 (US) or +44 7700 900123 (UK)"
643
+ Validation: phonenumbers library → E.164 format
644
+
645
+ Q4 — Trip Purpose (ALL BUTTONS)
646
+ "What brings you to {city}? ✈️"
647
+ Quick replies:
648
+ [🏖 Leisure] [💍 Honeymoon] [👨‍👩‍👧 Family Trip]
649
+ [💼 Business] [🎓 Study/Conference] [🏥 Medical]
650
+
651
+ Q5 — Dietary needs (ALL BUTTONS)
652
+ "Any dietary requirements? 🍽️"
653
+ Quick replies:
654
+ [🚫 None] [🥗 Vegetarian] [🌱 Vegan] [🕌 Halal]
655
+ [✡️ Kosher] [🚫🥜 Nut Allergy] [✏️ Other...]
656
+
657
+ Q6 — Accessibility (ALL BUTTONS)
658
+ "Any accessibility needs? ♿"
659
+ Quick replies:
660
+ [None needed ✅] [Wheelchair access ♿] [Ground floor room 🔑]
661
+ [Visual assistance 👁️] [Hearing assistance 👂] [✏️ Specify...]
662
+
663
+ RETURNING USER SHORTCUT:
664
+ If user has booked before, show at start:
665
+ Message: "Use your saved details? 💾"
666
+ List template showing saved: Name, Email, Phone
667
+ Quick replies: [✅ Yes, use saved] [✏️ Update details]
668
+
669
+ PASSPORT OCR SHORTCUT (optional):
670
+ After name collected:
671
+ "Would you like to scan your passport for faster check-in? 📷"
672
+ Quick replies: [📷 Scan Passport] [Skip for now →]
673
+ If scan: open Messenger camera / image picker
674
+ Process OCR in hf_space, pre-fill remaining fields
675
+
676
+ BACKEND (hf_space/routers/guest.py):
677
+ POST /api/guest/validate
678
+ Input: {field: "email", value: "test@gmail.con", context: {}}
679
+ Returns: {valid: bool, normalized: "test@gmail.com", suggestion: "gmail.com", error: null}
680
+
681
+ POST /api/guest/ocr_passport
682
+ Input: {image_url: "messenger CDN URL", psid}
683
+ Fetch image from Messenger CDN → pre-process → Tesseract MRZ
684
+ Returns extracted fields with confidence scores
685
+
686
+ POST /api/guest/save_profile
687
+ Input: all validated guest fields + psid
688
+ Upsert Supabase guests table WHERE messenger_psid = psid
689
+ Update Redis user:{psid}:profile with name, email, phone
690
+
691
+ State: user:{psid}:state = "guest_complete"
692
+ user:{psid}:guest_data = {validated guest fields JSON}
693
+ ```
694
+
695
+ ---
696
+
697
+ ### 🔵 TASK PROMPT 5 — MODULE 5: ADD-ONS WITH SMART RECOMMENDATIONS
698
+
699
+ ```
700
+ TASK: Build Module 5 — Add-Ons & Upsell, personalised and button-driven.
701
+
702
+ Triggered after guest_complete state.
703
+
704
+ CONVERSATION FLOW:
705
+
706
+ SMART OPENER (personalised by trip_purpose):
707
+
708
+ IF honeymoon:
709
+ Message 1: "Since it's a special occasion 💍, I've picked these for you:"
710
+ Message 2: Generic cards (3 items):
711
+ Card 1: 🥂 Champagne Welcome — $45
712
+ "Chilled champagne + chocolates waiting in your room"
713
+ [Add to Booking ✅] [Tell me more ���]
714
+ Card 2: 🛁 Rose Petal Turndown — $35
715
+ "Romantic rose petal room setup at evening turndown"
716
+ [Add to Booking ✅] [Tell me more 💬]
717
+ Card 3: 💆 Couples Spa 60min — $120
718
+ "Private couples treatment at the hotel spa"
719
+ [Add to Booking ✅] [Tell me more 💬]
720
+ After cards:
721
+ Quick replies: [See all add-ons 📋] [Skip add-ons →] [Add all 3! 🎁]
722
+
723
+ IF family:
724
+ Show: Extra bed, Kids club, Airport family transfer, Babysitting
725
+
726
+ IF business:
727
+ Show: Late checkout (2pm), Meeting room (2h), Airport sedan transfer, Laundry
728
+
729
+ ON "Tell me more":
730
+ Send 2-3 line description from addons table
731
+ Quick replies: [Add this ✅] [No thanks ❌]
732
+
733
+ ON "See all add-ons":
734
+ Show categories as list template:
735
+ 🧖 Spa & Wellness
736
+ 🍽️ Dining & Drinks
737
+ 🚗 Transport
738
+ 🎯 Activities
739
+ 🛏️ Room Extras
740
+ User taps category → carousel of items in that category
741
+
742
+ CART SUMMARY (after each add):
743
+ Message: "✅ Added! Your extras so far:"
744
+ List template showing cart items with prices
745
+ Quick replies: [Continue adding 🛍️] [Proceed to payment →]
746
+
747
+ BACKEND (hf_space/routers/addons.py):
748
+ GET /api/addons/recommend
749
+ Input: {hotel_id, trip_purpose, num_children, psid}
750
+ Query Supabase addons by hotel + trip_purpose_tags match
751
+ Return top 3 with score multiplier applied
752
+
753
+ POST /api/addons/cart/add
754
+ Input: {psid, addon_id}
755
+ Append to Redis user:{psid}:addon_cart (JSON list)
756
+
757
+ POST /api/addons/cart/remove
758
+ Input: {psid, addon_id}
759
+ Remove from Redis list
760
+
761
+ GET /api/addons/cart/{psid}
762
+ Returns full cart with live prices and subtotal
763
+
764
+ State: user:{psid}:state = "addons_complete"
765
+ user:{psid}:addon_cart = [{addon_id, name, price, currency}]
766
+ ```
767
+
768
+ ---
769
+
770
+ ### 🔵 TASK PROMPT 6 — MODULE 6: PAYMENT FLOW (SECURE, BUTTON-DRIVEN)
771
+
772
+ ```
773
+ TASK: Build Module 6 — Payment, fully secure with Stripe, button-driven UX.
774
+
775
+ CRITICAL SECURITY: Card data NEVER touches Render or HF Space.
776
+ Stripe.js tokenizes on client side inside a Messenger Webview popup.
777
+
778
+ CONVERSATION FLOW:
779
+
780
+ STEP 1 — Full booking summary BEFORE payment
781
+ Bot sends sequence:
782
+ Message 1: "📋 Let's review your booking before payment:"
783
+ Message 2: Generic template (1 card — booking summary card):
784
+ Image: hotel main photo
785
+ Title: "Grand Tokyo Hotel ⭐⭐⭐⭐⭐"
786
+ Subtitle: "Deluxe King · 15-17 Mar · 2 guests · Breakfast"
787
+ Button: [📋 View Full Details] → postback BOOKING_SUMMARY_FULL
788
+ Message 3: Text: "💰 Total: ¥64,000 (~$427 USD) for 2 nights"
789
+ Message 4: Quick replies:
790
+ [💳 Pay Now] [✏️ Change something] [❌ Cancel]
791
+
792
+ IF "Change something":
793
+ Quick replies:
794
+ [📅 Change Dates] [🛏 Change Room] [👤 Edit Guest Info] [🛍 Edit Add-ons]
795
+ Route to appropriate state based on selection
796
+
797
+ STEP 2 — Payment method selection
798
+ After "Pay Now":
799
+ Message: "Choose payment method 💳"
800
+ Quick replies:
801
+ [💳 Credit / Debit Card]
802
+ [🍎 Apple Pay]
803
+ [🤖 Google Pay]
804
+
805
+ STEP 3 — Stripe Webview (for card payment)
806
+ Send Messenger button template:
807
+ Message: "Tap below to enter your card securely 🔒"
808
+ Button: [Open Secure Payment →] (web_url type, opens Webview)
809
+ URL: https://{hf_space_url}/pay_webview?booking_draft_id={id}&psid={psid}
810
+
811
+ hf_space/static/pay_webview.html:
812
+ - Loads Stripe.js
813
+ - Shows hotel name + amount (from booking_draft in Redis)
814
+ - Stripe Elements card input (tokenizes client-side)
815
+ - On submit: POST stripe_token to HF Space /api/payment/process
816
+ - On success: closes Webview, sends postback PAYMENT_SUCCESS to Render webhook
817
+ - On fail: shows error in Webview, allows retry (max 3 attempts)
818
+
819
+ STEP 4 — Fraud check + charge (server-side in HF Space)
820
+ POST /api/payment/process:
821
+ 1. fraud_check.py: calculate risk score
822
+ 2. If risk >= 71: block, message user "Payment flagged. Contact support."
823
+ 3. If risk 31-70: require Stripe 3DS (return client_secret to Webview)
824
+ 4. If risk < 31: charge immediately
825
+ 5. On Stripe success:
826
+ - Save to Supabase payments table
827
+ - Generate booking_reference: HTL-{YEAR}-{6-char random uppercase}
828
+ - Update bookings table status='confirmed'
829
+ - Publish Kafka 'payment.completed' event
830
+ 6. Return {success: true, booking_reference: "HTL-2026-XYZABC"}
831
+
832
+ STEP 5 — Post-payment (after PAYMENT_SUCCESS postback received by Render)
833
+ Bot sends sequence (all within 30 seconds):
834
+ Message 1: "🎉 Booking confirmed! Reference: HTL-2026-XYZABC"
835
+ Message 2: QR code image (generated in HF Space, stored in Supabase Storage)
836
+ Message 3: "📄 Your booking voucher is ready:"
837
+ Message 4: File attachment — PDF voucher from Supabase Storage
838
+ Message 5: Quick replies:
839
+ [📅 Add to Calendar] [🗺 Get Directions] [🌤 Weather Forecast]
840
+ [🏨 Hotel Contact] [📤 Share Booking] [🏠 Back to Menu]
841
+
842
+ CONFIRMATION EMAIL: trigger async via hf_space/routers/notification.py
843
+
844
+ BACKEND (hf_space/routers/payment.py):
845
+ POST /api/payment/process
846
+ Full fraud check + Stripe charge as described above
847
+
848
+ POST /api/payment/webhook (Stripe webhooks)
849
+ Verify HMAC signature FIRST — reject if invalid
850
+ Handle: payment_intent.succeeded, payment_intent.payment_failed
851
+
852
+ GET /api/payment/pay_webview
853
+ Render static HTML page with Stripe Elements
854
+ Loads booking draft from Redis by booking_draft_id
855
+ Shows amount in user's currency
856
+ ```
857
+
858
+ ---
859
+
860
+ ### 🔵 TASK PROMPT 7 — MODULES 8 & 9: MODIFY + CANCEL WITH SMART POLICY
861
+
862
+ ```
863
+ TASK: Build Modules 8 & 9 — Booking Modification and Cancellation.
864
+
865
+ Triggered from "My Bookings" menu or user saying "change/cancel my booking".
866
+
867
+ MY BOOKINGS FLOW:
868
+ User taps "📋 My Bookings" from Persistent Menu
869
+
870
+ If no bookings: "You don't have any bookings yet. Start searching? 🔍"
871
+ Quick replies: [🔍 Search Hotels] [🏠 Main Menu]
872
+
873
+ If 1 booking: Show it directly as a card
874
+
875
+ If multiple: Show list template with up to 4 recent bookings
876
+ Each item: "Grand Tokyo · 15 Mar - 17 Mar · HTL-2026-XYZ"
877
+ Button: [View & Manage]
878
+
879
+ BOOKING DETAIL VIEW (single booking card):
880
+ Card buttons:
881
+ [✏️ Modify Booking] → postback BOOKING_MODIFY_{booking_id}
882
+ [❌ Cancel Booking] → postback BOOKING_CANCEL_{booking_id}
883
+ [📞 Contact Hotel] → postback BOOKING_CONTACT_{hotel_id}
884
+
885
+ MODIFICATION FLOW:
886
+
887
+ ON postback BOOKING_MODIFY_{booking_id}:
888
+ 1. Fetch booking from Supabase
889
+ 2. Call policy_engine.py → get allowed changes
890
+ 3. Message: "What would you like to change? ✏️"
891
+ 4. Quick replies (show only allowed options based on policy):
892
+ [📅 Change Dates] [🛏 Change Room Type] [👥 Change Guest Count]
893
+ [🍽 Change Meal Plan] [🛍 Edit Add-ons] [← Back]
894
+
895
+ ON "Change Dates":
896
+ Show current dates, ask for new check-in (same button flow as Module 2 Step 2)
897
+ After new dates: call rebooking.py
898
+ If price_diff > 0: "New dates cost ¥12,000 more. Proceed?"
899
+ Quick replies: [✅ Pay Difference ¥12,000] [❌ Keep Original Dates]
900
+ If price_diff < 0: "New dates are ¥8,000 cheaper! You'll get a refund."
901
+ Quick replies: [✅ Confirm Change] [❌ Keep Original]
902
+ If not available: "Sorry, {room_type} is sold out for new dates."
903
+ Quick replies: [🔄 Try Other Dates] [🛏 Try Different Room]
904
+
905
+ ON fee applicable:
906
+ ALWAYS show: "⚠️ A modification fee of ${fee} applies for changes within {days} days."
907
+ Quick replies: [✅ Accept Fee & Continue] [❌ Keep Booking As Is]
908
+
909
+ CANCELLATION FLOW:
910
+
911
+ ON postback BOOKING_CANCEL_{booking_id}:
912
+ 1. Call refund_engine.py → calculate refund FIRST
913
+ 2. Bot ALWAYS shows refund amount BEFORE asking to confirm:
914
+
915
+ Full refund case:
916
+ "Your booking qualifies for a full refund ✅
917
+ 💰 Refund: ¥64,000 → back to your card in 5-10 days
918
+ Reference: HTL-2026-XYZ"
919
+ Quick replies: [✅ Confirm Cancellation] [❌ Keep Booking]
920
+
921
+ Partial refund case:
922
+ "⚠️ Cancellation within {days} days:
923
+ 💰 Refund: ¥32,000 (50%) — ¥32,000 is non-refundable"
924
+ Quick replies: [✅ Confirm & Get ¥32,000 Back] [❌ Keep Booking]
925
+
926
+ No refund case:
927
+ "⚠️ Non-refundable booking:
928
+ 💰 Refund: ¥0 (0%) — rate plan is non-refundable"
929
+ Quick replies: [✅ Cancel Anyway (No Refund)] [❌ Keep Booking]
930
+
931
+ ON confirm: ask reason (for analytics)
932
+ Quick replies: [🗓 Date changed] [✈️ Flight issue] [💸 Price concern]
933
+ [😷 Health/Emergency] [🔄 Found better hotel] [❌ Skip]
934
+
935
+ BACKEND (hf_space/routers/modification.py + cancellation.py):
936
+ Already defined in SKILL.md — implement policy_engine, rebooking, refund_engine, stripe_refund.
937
+ All policy rules fetch from Supabase hotels.cancellation_policy JSONB column.
938
+ ```
939
+
940
+ ---
941
+
942
+ ### 🔵 TASK PROMPT 8 — MODULE 11: FAQ RAG WITH SMART ROUTING
943
+
944
+ ```
945
+ TASK: Build Module 11 — FAQ & RAG pipeline with smart question routing.
946
+
947
+ Triggered when: user asks a question about a specific hotel (any language).
948
+ Context needed: user must have a hotel_id in their current session.
949
+
950
+ CONVERSATION FLOW:
951
+
952
+ FAQ CATEGORY SHORTCUT (proactive, after hotel selection):
953
+ After user selects a hotel (before room selection):
954
+ Bot sends: "Got any questions about {hotel_name}? I can help! 💬"
955
+ List template with FAQ categories:
956
+ 🏊 Facilities (pool, gym, spa)
957
+ 🍽️ Dining (restaurants, breakfast, room service)
958
+ 🚗 Transport (airport transfer, parking)
959
+ 🛎️ Services (check-in, concierge, late checkout)
960
+ 💳 Policies (cancellation, pets, children)
961
+ Global button: [No questions, continue booking →]
962
+
963
+ ON category tap OR free text question:
964
+ 1. Embed question via LaBSE (HF Space model — already loaded)
965
+ 2. Search Qdrant hotel_faqs with hotel_id filter, top_k=3
966
+ 3. If Qdrant score > 0.85: return direct answer (no LLM needed, fast)
967
+ 4. If 0.6 < score < 0.85: send to Groq Llama 3 with context
968
+ 5. If score < 0.6: "I don't have that info, but here's the hotel's contact:"
969
+ + contact buttons
970
+ 6. Translate answer to user's detected language via translator.py
971
+
972
+ ANSWER FORMAT:
973
+ Message 1: Answer text (in user's language)
974
+ Message 2: Quick replies:
975
+ [🙋 Another question] [✅ Continue booking] [📞 Ask hotel directly]
976
+
977
+ VOICE QUESTION SUPPORT:
978
+ If user sends voice message while browsing hotel:
979
+ 1. Transcribe via Whisper (already loaded in HF Space)
980
+ 2. Show transcription: "You asked: '{transcript}'"
981
+ 3. Process through RAG as normal
982
+ 4. Offer TTS response: quick reply [🔊 Hear answer]
983
+
984
+ BACKEND (hf_space/routers/rag.py):
985
+ POST /api/rag/ask
986
+ Input: {question, hotel_id, psid, language}
987
+ 1. embed question: labse_model.encode([question])
988
+ 2. qdrant_client.search(collection="hotel_faqs", vector=embedding,
989
+ filter={"hotel_id": hotel_id}, limit=3)
990
+ 3. If best_score > 0.85: return top result payload.answer directly
991
+ 4. Else: groq_client.chat.completions.create(
992
+ model="llama3-70b-8192",
993
+ messages=[{"role": "system", "content": concierge_prompt},
994
+ {"role": "user", "content": f"Context: {chunks}\n\nQuestion: {question}"}]
995
+ )
996
+ 5. translator.translate(answer, target_lang=language) if language != "en"
997
+ 6. Return {answer, source: "direct"|"rag"|"fallback", confidence}
998
+
999
+ POST /api/rag/onboard_hotel
1000
+ Input: {hotel_id, faqs: [{question, answer, category}]}
1001
+ Batch embed all FAQs with LaBSE
1002
+ Upsert to Qdrant hotel_faqs collection with hotel_id filter
1003
+ Called by hotel_onboarding.py script
1004
+ ```
1005
+
1006
+ ---
1007
+
1008
+ ### 🔵 TASK PROMPT 9 — MODULE 10: LOYALTY WITH GAMIFICATION BUTTONS
1009
+
1010
+ ```
1011
+ TASK: Build Module 10 — Loyalty & Gamification, fully surfaced through Messenger buttons.
1012
+
1013
+ LOYALTY CHECK FLOW (triggered by "My Rewards" menu or asking about points):
1014
+
1015
+ Bot sends sequence:
1016
+ Message 1: "🏆 Your Loyalty Status"
1017
+ Message 2: Generic template card:
1018
+ Image: tier badge image from Supabase Storage (bronze/silver/gold/platinum/black)
1019
+ Title: "Ahmed Al-Rashidi • Gold Member"
1020
+ Subtitle: "⭐ 14,392 points · {points_needed} to Platinum"
1021
+ Buttons:
1022
+ [🎁 Redeem Points] → postback LOYALTY_REDEEM
1023
+ [📊 Points History] → postback LOYALTY_HISTORY
1024
+ [🎖 My Badges] → postback LOYALTY_BADGES
1025
+ Message 3: Progress bar text:
1026
+ "Gold ████████░░ Platinum — 3,608 pts to go!"
1027
+ Message 4: Quick replies:
1028
+ [🎁 Redeem Points] [👥 Refer a Friend] [📜 Tier Benefits] [🏠 Main Menu]
1029
+
1030
+ ON "Tier Benefits":
1031
+ List template with benefits for current + next tier
1032
+ Global button: [How to earn more points →]
1033
+
1034
+ ON "My Badges" (postback LOYALTY_BADGES):
1035
+ Carousel showing earned badges as cards
1036
+ Each card: badge image, name, how it was earned, date
1037
+ Unearned badges shown as "locked" with earn conditions
1038
+ Quick replies: [🏠 Main Menu] [🔍 Search Hotels]
1039
+
1040
+ ON "Refer a Friend":
1041
+ Message: "Share this link and earn 200 points when they book! 🎉"
1042
+ Send referral link + quick reply: [📋 Copy Link] [📤 Share]
1043
+
1044
+ PROACTIVE LOYALTY (after booking confirmed):
1045
+ Automatically send:
1046
+ "🎉 You earned 842 points for this booking!
1047
+ New balance: 14,392 points (Gold tier)
1048
+ You're 3,608 points from Platinum — 2 more bookings could get you there! 🚀"
1049
+ Quick replies: [🏆 View Rewards] [🏠 Main Menu]
1050
+
1051
+ TIER UPGRADE NOTIFICATION:
1052
+ On Kafka 'loyalty.tier_upgraded' event:
1053
+ Send proactive message to user:
1054
+ "🌟 CONGRATULATIONS! You've reached {new_tier} status!
1055
+ {tier_specific_welcome_message}"
1056
+ Send tier badge image
1057
+ Quick replies: [🎁 See New Benefits] [🏠 Main Menu]
1058
+
1059
+ BACKEND (hf_space/routers/loyalty.py):
1060
+ POST /api/loyalty/award
1061
+ Input: {psid, booking_id, booking_total, currency}
1062
+ Calculate points via points_engine.py
1063
+ Update Supabase loyalty_accounts + loyalty_transactions
1064
+ Check tier upgrade via tier_engine.py
1065
+ Award badges via gamification.py
1066
+ Return {points_earned, new_balance, tier_changed, badges_awarded}
1067
+
1068
+ GET /api/loyalty/profile/{psid}
1069
+ Returns full loyalty profile for display
1070
+
1071
+ POST /api/loyalty/redeem
1072
+ Input: {psid, points_to_redeem, booking_id}
1073
+ Validate points balance
1074
+ Apply discount to booking
1075
+ Update loyalty_accounts
1076
+ ```
1077
+
1078
+ ---
1079
+
1080
+ ### 🔵 TASK PROMPT 10 — MODULE 13: HUMAN HANDOFF + MODULE 12: REVIEWS
1081
+
1082
+ ```
1083
+ TASK: Build Module 13 (Human Handoff via Chatwoot) and Module 12 (Post-Stay Reviews).
1084
+
1085
+ HANDOFF FLOW:
1086
+
1087
+ TRIGGERS (any of these):
1088
+ - User types: "talk to human", "real agent", "help me please" (intent: intent_human_handoff)
1089
+ - FallbackClassifier fires 2 consecutive times
1090
+ - User sends angry sentiment detected by sentiment.py (score < 0.2)
1091
+ - User asks same question 3 times without booking progressing
1092
+
1093
+ Bot sends:
1094
+ Message 1: "I'll connect you with a live agent right away! 🙋"
1095
+ Message 2: "Estimated wait: ~{wait_time} minutes"
1096
+ Message 3: Quick replies:
1097
+ [📞 Connect Now] [💬 Keep trying with AI] [📩 Email instead]
1098
+
1099
+ ON "Connect Now":
1100
+ 1. Call /api/handoff/create with full conversation history
1101
+ 2. Create Chatwoot conversation with language-matched agent queue
1102
+ 3. Post all Messenger messages as initial Chatwoot note
1103
+ 4. Set Redis user:{psid}:state = "handoff_active"
1104
+ 5. Set Redis user:{psid}:handoff_active = "true"
1105
+ 6. Message: "✅ Connected! Reference: CHAT-{id}
1106
+ Agent {name} will be with you shortly.
1107
+ I'll be quiet until they're done helping you."
1108
+
1109
+ WHILE handoff_active:
1110
+ All incoming Messenger messages relay to Chatwoot conversation via API
1111
+ All Chatwoot agent messages relay back to Messenger
1112
+ Bot does NOT process any NLU during handoff
1113
+
1114
+ ON Chatwoot resolved webhook:
1115
+ 1. Clear Redis user:{psid}:handoff_active
1116
+ 2. Set user:{psid}:state = "post_handoff"
1117
+ 3. Message: "Welcome back! Your issue has been resolved. 😊"
1118
+ 4. Quick replies: [🔍 Search Hotels] [📋 My Bookings] [🏠 Main Menu]
1119
+
1120
+ REVIEW FLOW (automated, 24h after checkout):
1121
+
1122
+ Celery beat task triggers:
1123
+ 1. Query Supabase: bookings where check_out + 24h <= now AND review_requested = false
1124
+ 2. For each: call /api/review/send_request
1125
+
1126
+ Review Request Message:
1127
+ Message 1: "How was your stay at {hotel_name}? We'd love your feedback! 🌟"
1128
+ Message 2: "Rate your overall experience:"
1129
+ Quick replies (star ratings):
1130
+ [⭐ Poor] [⭐⭐ Fair] [⭐⭐⭐ Good] [⭐⭐⭐⭐ Great] [⭐⭐⭐⭐⭐ Excellent!]
1131
+ [Skip review →]
1132
+
1133
+ AFTER STAR RATING (e.g., user clicks ⭐⭐⭐⭐ Great):
1134
+ Message: "Thanks! Rate specific aspects:"
1135
+ Multiple quick reply sequences (one per aspect):
1136
+ "🛏️ Room quality?" → [⭐ Poor] [⭐⭐⭐ OK] [⭐⭐⭐⭐⭐ Great]
1137
+ "🍽️ Dining?" → [⭐ Poor] [⭐⭐⭐ OK] [⭐⭐⭐⭐⭐ Great] [N/A]
1138
+ "🤝 Staff service?" → [⭐ Poor] [⭐⭐⭐ OK] [⭐⭐⭐⭐⭐ Great]
1139
+ "📍 Location?" → [⭐ Poor] [⭐⭐⭐ OK] [⭐⭐⭐⭐⭐ Great]
1140
+
1141
+ Then: "Any comments? (optional)"
1142
+ Quick replies: [Skip ✓] OR user types free text
1143
+
1144
+ AFTER REVIEW SUBMITTED:
1145
+ 1. Run sentiment.py on review text (if provided)
1146
+ 2. If negative (score < 0.3): Kafka 'review.negative_alert' → hotel partner
1147
+ 3. Award 50 loyalty points: "🎉 +50 loyalty points for your review!"
1148
+ 4. Message: "Thanks {name}! Your feedback helps millions of travelers. 🙏"
1149
+ Quick replies: [🔍 Search Hotels] [🏆 My Rewards] [🏠 Main Menu]
1150
+
1151
+ BACKEND (hf_space/routers/reviews.py + handoff.py):
1152
+ POST /api/review/submit
1153
+ Input: {psid, booking_id, overall_score, aspect_scores, review_text}
1154
+ Insert into Supabase reviews table
1155
+ Run sentiment.py
1156
+ Award points via loyalty service
1157
+
1158
+ POST /api/handoff/create
1159
+ Create Chatwoot conversation
1160
+ Post history
1161
+ Return {conversation_id, agent_name, wait_time}
1162
+
1163
+ POST /api/handoff/relay_message
1164
+ Input: {chatwoot_conversation_id, message_text, from_agent: bool}
1165
+ If from_agent: forward to Messenger via Graph API
1166
+ If from_user: forward to Chatwoot via API
1167
+ ```
1168
+
1169
+ ---
1170
+
1171
+ ### 🔵 TASK PROMPT 11 — VOICE INTERACTION COMPLETE SETUP
1172
+
1173
+ ```
1174
+ TASK: Add full voice interaction support within the single HuggingFace Space.
1175
+
1176
+ Voice is delivered via Messenger's native audio message support + a Webview for recording.
1177
+
1178
+ HOW MESSENGER VOICE WORKS:
1179
+ Option A — User sends voice note in Messenger:
1180
+ Messenger delivers audio as: {"message": {"attachments": [{"type": "audio", "payload": {"url": "..."}}]}}
1181
+ Render webhook detects audio attachment → sends audio_url to HF Space /api/process_message
1182
+ HF Space fetches audio from Messenger CDN → feeds to Faster-Whisper → processes as text
1183
+
1184
+ Option B — User taps 🎙️ button to open voice Webview:
1185
+ Opens hf_space/static/voice_webview.html
1186
+ MediaRecorder API captures audio in browser
1187
+ Sends audio blob to HF Space /api/voice/transcribe in real-time
1188
+ Returns transcript → shown in webview → auto-submitted to main flow
1189
+
1190
+ VOICE INPUT (hf_space/routers/voice.py):
1191
+ POST /api/voice/transcribe
1192
+ Input: audio file (multipart) OR {audio_url: "messenger CDN URL"}
1193
+
1194
+ If audio_url:
1195
+ async download with httpx (Messenger CDN requires no auth)
1196
+ Convert to 16kHz mono WAV using pydub in-memory (NO ffmpeg subprocess — use pydub.AudioSegment)
1197
+
1198
+ transcribe with faster_whisper model (loaded at startup)
1199
+ segments, info = whisper_model.transcribe(wav_bytes, beam_size=5, language=None)
1200
+
1201
+ If info.language_probability < 0.6:
1202
+ Return {success: false, message: "Could not understand audio. Please try again."}
1203
+
1204
+ Store detected language in Upstash Redis (override if different from profile lang)
1205
+ Return {transcript, language, confidence}
1206
+
1207
+ VOICE OUTPUT (TTS via Groq/external):
1208
+ DO NOT load local TTS model (too heavy for HF Space).
1209
+ Use: Edge-TTS (Microsoft Azure free tier, no API key needed)
1210
+ import edge_tts
1211
+ voice map by language:
1212
+ en → en-US-JennyNeural (female) or en-US-GuyNeural (male)
1213
+ ar → ar-SA-ZariyahNeural
1214
+ fr → fr-FR-DeniseNeural
1215
+ es → es-ES-ElviraNeural
1216
+ zh → zh-CN-XiaoxiaoNeural
1217
+ ja → ja-JP-NanamiNeural
1218
+ hi → hi-IN-SwaraNeural
1219
+ (add all 15 Tier 1 languages)
1220
+
1221
+ async def text_to_speech(text: str, language: str, gender: str = "female") -> bytes:
1222
+ voice = VOICE_MAP.get(f"{language}_{gender}", "en-US-JennyNeural")
1223
+ communicate = edge_tts.Communicate(text, voice)
1224
+ audio_bytes = b""
1225
+ async for chunk in communicate.stream():
1226
+ if chunk["type"] == "audio":
1227
+ audio_bytes += chunk["data"]
1228
+ return audio_bytes # MP3 bytes
1229
+
1230
+ Cache TTS in Upstash Redis:
1231
+ key: tts:{lang}:{gender}:{sha256(text)[:16]}
1232
+ Store base64-encoded MP3, EX 86400
1233
+ Check cache before generating
1234
+
1235
+ VOICE UX RULE:
1236
+ - Never send ONLY audio — always include text first
1237
+ - After text response, add [🔊 Hear this] quick reply
1238
+ - When user clicks: generate TTS → upload to Supabase Storage → send as Messenger audio attachment
1239
+ - Voice input always shows transcript back to user: "I heard: '{transcript}'"
1240
+
1241
+ POST /api/voice/speak
1242
+ Input: {text, language, gender, psid}
1243
+ Check TTS cache
1244
+ Generate via edge_tts if cache miss
1245
+ Upload MP3 to Supabase Storage bucket "tts-audio" with 1-hour expiry
1246
+ Return {audio_url} to Render webhook which sends as Messenger audio attachment
1247
+ ```
1248
+
1249
+ ---
1250
+
1251
+ ### 🔵 TASK PROMPT 12 — RENDER WEBHOOK (THIN RELAY ONLY)
1252
+
1253
+ ```
1254
+ TASK: Build the Render webhook — a thin relay with NO heavy processing.
1255
+
1256
+ Render has limited RAM. This service does EXACTLY 3 things:
1257
+ 1. Receive Facebook Messenger webhook events
1258
+ 2. Forward to HF Space for processing
1259
+ 3. Send HF Space response back to Messenger
1260
+
1261
+ File: render_webhook/main.py
1262
+
1263
+ from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
1264
+ import httpx, hmac, hashlib, asyncio, os
1265
+
1266
+ app = FastAPI()
1267
+ HF_SPACE_URL = os.environ["HF_SPACE_URL"] # full HF Space URL
1268
+ PAGE_ACCESS_TOKEN = os.environ["FACEBOOK_PAGE_ACCESS_TOKEN"]
1269
+ APP_SECRET = os.environ["FACEBOOK_APP_SECRET"]
1270
+ VERIFY_TOKEN = os.environ["FACEBOOK_VERIFY_TOKEN"]
1271
+ GRAPH_API = "https://graph.facebook.com/v18.0/me/messages"
1272
+
1273
+ @app.get("/webhooks/messenger/webhook")
1274
+ async def verify_webhook(request: Request):
1275
+ """Facebook webhook verification — one-time setup"""
1276
+ params = dict(request.query_params)
1277
+ if params.get("hub.mode") == "subscribe" and params.get("hub.verify_token") == VERIFY_TOKEN:
1278
+ return int(params["hub.challenge"])
1279
+ raise HTTPException(status_code=403)
1280
+
1281
+ @app.post("/webhooks/messenger/webhook")
1282
+ async def receive_message(request: Request, background_tasks: BackgroundTasks):
1283
+ """
1284
+ Receive Messenger events.
1285
+ MUST return 200 OK within 200ms (before FB's 5-second timeout).
1286
+ All processing happens in background task.
1287
+ """
1288
+ # Verify Facebook signature
1289
+ signature = request.headers.get("X-Hub-Signature-256", "")
1290
+ body = await request.body()
1291
+ expected = "sha256=" + hmac.new(APP_SECRET.encode(), body, hashlib.sha256).hexdigest()
1292
+ if not hmac.compare_digest(signature, expected):
1293
+ raise HTTPException(status_code=403, detail="Invalid signature")
1294
+
1295
+ data = await request.json()
1296
+ background_tasks.add_task(process_webhook_event, data)
1297
+ return {"status": "ok"} # Return immediately
1298
+
1299
+ async def process_webhook_event(data: dict):
1300
+ """Run in background — no time pressure"""
1301
+ for entry in data.get("entry", []):
1302
+ for messaging in entry.get("messaging", []):
1303
+ psid = messaging["sender"]["id"]
1304
+
1305
+ # Build HF Space request payload
1306
+ hf_payload = {
1307
+ "psid": psid,
1308
+ "message_type": "text",
1309
+ "timestamp": messaging.get("timestamp", 0)
1310
+ }
1311
+
1312
+ if "message" in messaging:
1313
+ msg = messaging["message"]
1314
+ if msg.get("text"):
1315
+ hf_payload["message_type"] = "text"
1316
+ hf_payload["text"] = msg["text"]
1317
+ elif msg.get("attachments"):
1318
+ att = msg["attachments"][0]
1319
+ if att["type"] == "audio":
1320
+ hf_payload["message_type"] = "audio"
1321
+ hf_payload["audio_url"] = att["payload"]["url"]
1322
+ elif att["type"] == "image":
1323
+ hf_payload["message_type"] = "image"
1324
+ hf_payload["image_url"] = att["payload"]["url"]
1325
+
1326
+ elif "postback" in messaging:
1327
+ hf_payload["message_type"] = "postback"
1328
+ hf_payload["text"] = messaging["postback"]["payload"]
1329
+ else:
1330
+ return # Skip delivery receipts, read receipts
1331
+
1332
+ # Send typing indicator immediately (shows user bot is working)
1333
+ await send_to_messenger([{
1334
+ "recipient": {"id": psid},
1335
+ "sender_action": "typing_on"
1336
+ }])
1337
+
1338
+ # Call HF Space
1339
+ try:
1340
+ async with httpx.AsyncClient(timeout=55.0) as client:
1341
+ resp = await client.post(f"{HF_SPACE_URL}/api/process_message", json=hf_payload)
1342
+ result = resp.json()
1343
+ messages = result.get("messages", [])
1344
+ except httpx.TimeoutException:
1345
+ messages = [{
1346
+ "recipient": {"id": psid},
1347
+ "message": {"text": "Sorry, I'm experiencing a delay. Please try again in a moment. 🔄"}
1348
+ }]
1349
+
1350
+ # Send all messages to Messenger in sequence
1351
+ await send_to_messenger(messages)
1352
+
1353
+ async def send_to_messenger(messages: list):
1354
+ async with httpx.AsyncClient(timeout=10.0) as client:
1355
+ for msg in messages:
1356
+ try:
1357
+ await client.post(
1358
+ f"{GRAPH_API}?access_token={PAGE_ACCESS_TOKEN}",
1359
+ json=msg
1360
+ )
1361
+ if msg.get("sender_action") != "typing_on":
1362
+ await asyncio.sleep(0.6)
1363
+ except Exception as e:
1364
+ print(f"Messenger send error: {e}")
1365
+
1366
+ # Health check endpoint
1367
+ @app.get("/health")
1368
+ async def health():
1369
+ return {"status": "ok", "service": "messenger_relay"}
1370
+
1371
+ # requirements.txt for Render (MINIMAL — low RAM):
1372
+ # fastapi==0.111.0
1373
+ # uvicorn==0.29.0
1374
+ # httpx==0.27.0
1375
+ # python-multipart==0.0.9
1376
+ # (nothing else — no ML libraries, no database drivers)
1377
+ ```
1378
+
1379
+ ---
1380
+
1381
+ ### 🔵 TASK PROMPT 13 — CICD + ENVIRONMENT SETUP
1382
+
1383
+ ```
1384
+ TASK: Create complete CI/CD pipeline and environment configuration.
1385
+
1386
+ File: .github/workflows/deploy.yml
1387
+ Triggers:
1388
+ - Push to main → deploy HF Space + Render webhook
1389
+ - Push to develop → run tests only, no deploy
1390
+ - PR to main → run tests + lint, block merge if fail
1391
+
1392
+ Jobs:
1393
+ 1. lint:
1394
+ - ruff check . (Python linting)
1395
+ - ruff format --check .
1396
+
1397
+ 2. test:
1398
+ - pytest tests/ --cov=hf_space --cov-report=xml --cov-fail-under=70
1399
+ - Test against real Supabase test project (SUPABASE_URL_TEST secret)
1400
+ - Test against Stripe test mode (STRIPE_SECRET_KEY_TEST secret)
1401
+
1402
+ 3. deploy_hf_space (on main only, after test passes):
1403
+ - Uses HuggingFace CLI to push to Space
1404
+ - huggingface-cli upload {HF_USERNAME}/hotel-booking-ai ./hf_space .
1405
+ - Wait for Space build to succeed (poll /health endpoint)
1406
+
1407
+ 4. deploy_render (on main only, after hf_space deploy):
1408
+ - Trigger Render deploy hook (POST to Render deploy URL)
1409
+ - Wait for health check at Render webhook /health
1410
+ - Run smoke test: send test Messenger message, verify response
1411
+
1412
+ File: .env.example (ALL variables documented):
1413
+ # HuggingFace Space
1414
+ HF_SPACE_URL=https://{username}-hotel-booking-ai.hf.space
1415
+ HF_TOKEN=hf_xxxxxxxxxxxx
1416
+
1417
+ # Upstash Redis (HTTPS-based, HF Space compatible)
1418
+ UPSTASH_REDIS_URL=https://xxxx.upstash.io
1419
+ UPSTASH_REDIS_TOKEN=xxxxxxxxxxxx
1420
+
1421
+ # Supabase
1422
+ SUPABASE_URL=https://xxxx.supabase.co
1423
+ SUPABASE_ANON_KEY=eyJ...
1424
+ SUPABASE_SERVICE_ROLE_KEY=eyJ... # Server-side only
1425
+ SUPABASE_DATABASE_URL=postgresql://...
1426
+
1427
+ # Facebook / Messenger
1428
+ FACEBOOK_PAGE_ACCESS_TOKEN=EAAxxxx
1429
+ FACEBOOK_APP_SECRET=xxxx
1430
+ FACEBOOK_VERIFY_TOKEN=your_chosen_string
1431
+ WHATSAPP_PHONE_NUMBER_ID=xxxx
1432
+
1433
+ # Groq (free LLM API)
1434
+ GROQ_API_KEY=gsk_xxxx
1435
+
1436
+ # Stripe
1437
+ STRIPE_SECRET_KEY=sk_live_xxxx # sk_test_ for development
1438
+ STRIPE_PUBLISHABLE_KEY=pk_live_xxxx
1439
+ STRIPE_WEBHOOK_SECRET=whsec_xxxx
1440
+
1441
+ # Qdrant Cloud
1442
+ QDRANT_URL=https://xxxx.qdrant.io
1443
+ QDRANT_API_KEY=xxxx
1444
+
1445
+ # Elasticsearch (Bonsai.io free tier)
1446
+ ELASTICSEARCH_URL=https://xxxx.bonsai.io
1447
+ ELASTICSEARCH_USER=xxxx
1448
+ ELASTICSEARCH_PASS=xxxx
1449
+
1450
+ # Notifications
1451
+ SENDGRID_API_KEY=SG.xxxx
1452
+
1453
+ # Chatwoot
1454
+ CHATWOOT_API_URL=https://your-chatwoot.onrender.com
1455
+ CHATWOOT_API_TOKEN=xxxx
1456
+ CHATWOOT_ACCOUNT_ID=1
1457
+
1458
+ # Kafka (Confluent Cloud free tier)
1459
+ KAFKA_BOOTSTRAP_SERVERS=pkc-xxxx.confluent.cloud:9092
1460
+ KAFKA_API_KEY=xxxx
1461
+ KAFKA_API_SECRET=xxxx
1462
+
1463
+ # ClickHouse Cloud
1464
+ CLICKHOUSE_URL=https://xxxx.clickhouse.cloud:8443
1465
+ CLICKHOUSE_USER=default
1466
+ CLICKHOUSE_PASSWORD=xxxx
1467
+
1468
+ # Open Exchange Rates (free tier)
1469
+ OPEN_EXCHANGE_RATES_APP_ID=xxxx
1470
+
1471
+ # HF Space URL (used by Render)
1472
+ HF_SPACE_URL=https://{hf_username}-hotel-booking-ai.hf.space
1473
+ ```
1474
+
1475
+ ---
1476
+
1477
+ ## ═══════════════════════════════════════════════
1478
+ ## SECTION 7 — CODE QUALITY RULES (ALWAYS APPLY)
1479
+ ## ═══════════════════════════════════════════════
1480
+
1481
+ ```
1482
+ When Copilot generates ANY code for this project, enforce these rules:
1483
+
1484
+ 1. ASYNC EVERYWHERE
1485
+ All FastAPI endpoints and database calls MUST be async.
1486
+ Use httpx.AsyncClient (never requests).
1487
+ Use asyncpg or supabase-py async client.
1488
+ Never use time.sleep() — use asyncio.sleep().
1489
+
1490
+ 2. TYPE HINTS ALWAYS
1491
+ Every function signature must have complete type hints.
1492
+ Return types must be specified.
1493
+ Use Pydantic models for all FastAPI request/response bodies.
1494
+
1495
+ 3. ERROR HANDLING PATTERN
1496
+ Every service call must have try/except with:
1497
+ - Specific exception type (not bare Exception)
1498
+ - Structured error response: {"error": "...", "code": "...", "fallback": "..."}
1499
+ - Fallback behavior (never let an error crash the conversation)
1500
+
1501
+ 4. LOGGING STANDARD
1502
+ import structlog
1503
+ log = structlog.get_logger()
1504
+ Every endpoint logs: psid (hashed), intent, response_time_ms, success/fail
1505
+ Never log: full card numbers, passwords, passport numbers, raw personal data
1506
+
1507
+ 5. ENVIRONMENT VARIABLES
1508
+ Never hardcode ANY value that could differ between environments.
1509
+ Never hardcode model names — always from os.environ with defaults.
1510
+ Use pydantic-settings for config management.
1511
+
1512
+ 6. MESSENGER RESPONSE VALIDATION
1513
+ Before returning ANY messages list:
1514
+ - Verify no text > 2000 chars
1515
+ - Verify no carousel > 10 cards
1516
+ - Verify no quick replies > 13
1517
+ - Verify all button titles <= 20 chars
1518
+ Use: from render_webhook.messenger_builder import validate_messages
1519
+
1520
+ 7. IDEMPOTENCY
1521
+ All payment operations: idempotency_key = f"booking-{booking_id}"
1522
+ All notification sends: check Redis "sent:{type}:{booking_id}" before sending
1523
+ All Celery tasks: check idempotency key in Upstash Redis before executing
1524
+
1525
+ 8. STATE MACHINE
1526
+ User conversation state MUST always be one of:
1527
+ new | greeting | searching | viewing_hotels | selecting_room |
1528
+ choosing_rate | filling_guest_form | selecting_addons | reviewing_booking |
1529
+ paying | booking_confirmed | modifying | cancelling | faq_browsing |
1530
+ handoff_active | post_stay_review | loyalty_browsing
1531
+
1532
+ Every state transition must update: Upstash Redis user:{psid}:state
1533
+ Invalid state transitions must log warning and reset to appropriate state.
1534
+ ```
1535
+
1536
+ ---
1537
+
1538
+ ## ═══════════════════════════════════════════════
1539
+ ## HOW TO USE THIS FILE WITH GITHUB COPILOT
1540
+ ## ═══════════════════════════════════════════════
1541
+
1542
+ ```
1543
+ STEP 1 — Attach context before every session:
1544
+ Open this file in VS Code.
1545
+ In Copilot Chat, type:
1546
+ "@workspace Using COPILOT_MASTER_PROMPT.md as full context, [paste task prompt]"
1547
+
1548
+ STEP 2 — For new module work, paste ONE task prompt at a time:
1549
+ Copy the exact text from "TASK PROMPT X" section above.
1550
+ Paste into Copilot Chat.
1551
+ Review generated code against SKILL.md rules before accepting.
1552
+
1553
+ STEP 3 — For debugging / upgrading existing code:
1554
+ "Using COPILOT_MASTER_PROMPT.md context, review [filename] and:
1555
+ 1. Check it follows all Code Quality Rules from Section 7
1556
+ 2. Ensure all Messenger responses use MessengerResponse builder
1557
+ 3. Verify button-first UX law (>50% interactions via buttons)
1558
+ 4. Confirm state transitions update Upstash Redis correctly"
1559
+
1560
+ STEP 4 — For adding a feature not in this file:
1561
+ "Using COPILOT_MASTER_PROMPT.md context, add [feature].
1562
+ Constraints:
1563
+ - Must run in HF Space (not Render)
1564
+ - Must use Upstash Redis for state (not local Redis)
1565
+ - Must return Messenger-formatted message objects
1566
+ - Must follow button-first UX law
1567
+ - Must handle users from any country/language automatically"
1568
+ ```
hf_spaces/embeddings/app.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hf_spaces/embeddings/app.py
3
+ ------------------------------
4
+ HuggingFace Space: LaBSE sentence embedding service.
5
+ POST /embed {"texts": ["..."]} → {"embeddings": [[float, ...]]}
6
+ POST /embed_one {"text": "..."} → {"embedding": [float, ...]}
7
+ """
8
+
9
+ import os
10
+ import logging
11
+ from typing import List
12
+
13
+ from fastapi import FastAPI
14
+ from pydantic import BaseModel
15
+
16
+ log = logging.getLogger(__name__)
17
+ app = FastAPI(title="Embeddings Space")
18
+
19
+ _model = None
20
+
21
+
22
+ def _get_model():
23
+ global _model
24
+ if _model is None:
25
+ try:
26
+ from sentence_transformers import SentenceTransformer
27
+ _model = SentenceTransformer("sentence-transformers/LaBSE")
28
+ log.info("LaBSE model loaded")
29
+ except Exception as exc:
30
+ log.error("Could not load LaBSE: %s", exc)
31
+ return _model
32
+
33
+
34
+ class EmbedRequest(BaseModel):
35
+ texts: List[str]
36
+
37
+
38
+ class EmbedOneRequest(BaseModel):
39
+ text: str
40
+
41
+
42
+ @app.on_event("startup")
43
+ async def startup():
44
+ import threading
45
+ threading.Thread(target=_get_model, daemon=True).start()
46
+
47
+
48
+ @app.post("/embed")
49
+ def embed(req: EmbedRequest):
50
+ model = _get_model()
51
+ if model is None:
52
+ return {"embeddings": [], "error": "model_loading"}
53
+ embeddings = model.encode(req.texts, normalize_embeddings=True).tolist()
54
+ return {"embeddings": embeddings}
55
+
56
+
57
+ @app.post("/embed_one")
58
+ def embed_one(req: EmbedOneRequest):
59
+ model = _get_model()
60
+ if model is None:
61
+ return {"embedding": [], "error": "model_loading"}
62
+ vec = model.encode([req.text], normalize_embeddings=True)[0].tolist()
63
+ return {"embedding": vec}
64
+
65
+
66
+ @app.get("/health")
67
+ def health():
68
+ return {"status": "ok", "model": "LaBSE", "loaded": _model is not None}
69
+
70
+
71
+ # Gradio interface (for HF Space UI)
72
+ import gradio as gr
73
+
74
+ def gradio_embed(texts_str: str) -> str:
75
+ texts = [t.strip() for t in texts_str.split("\n") if t.strip()]
76
+ model = _get_model()
77
+ if not model:
78
+ return "Model loading..."
79
+ vecs = model.encode(texts, normalize_embeddings=True)
80
+ return str(vecs.tolist())
81
+
82
+ demo = gr.Interface(
83
+ fn=gradio_embed,
84
+ inputs=gr.Textbox(lines=5, label="Enter texts (one per line)"),
85
+ outputs=gr.Textbox(label="Embeddings (JSON)"),
86
+ title="LaBSE Embeddings",
87
+ )
88
+
89
+ if __name__ == "__main__":
90
+ import uvicorn
91
+ uvicorn.run(app, host="0.0.0.0", port=7860)
hf_spaces/llm/app.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hf_spaces/llm/app.py
3
+ -----------------------
4
+ HuggingFace Space: LLM inference for RAG (Llama 3 via transformers).
5
+ Falls back to Groq API if local model OOMs.
6
+
7
+ POST /generate {"prompt": "...", "max_tokens": 256}
8
+ → {"text": "...", "source": "local|groq"}
9
+ """
10
+
11
+ import os
12
+ import logging
13
+ import threading
14
+ from typing import Optional
15
+
16
+ from fastapi import FastAPI
17
+ from pydantic import BaseModel
18
+
19
+ log = logging.getLogger(__name__)
20
+ app = FastAPI(title="LLM Space")
21
+
22
+ MODEL_ID = os.environ.get("LLM_MODEL_ID", "meta-llama/Meta-Llama-3-8B-Instruct")
23
+ GROQ_KEY = os.environ.get("GROQ_API_KEY", "")
24
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
25
+ _pipeline = None
26
+ _loading = False
27
+
28
+
29
+ def _load_model():
30
+ global _pipeline, _loading
31
+ _loading = True
32
+ try:
33
+ import torch
34
+ from transformers import pipeline as hf_pipeline, AutoTokenizer
35
+ tok = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN)
36
+ _pipeline = hf_pipeline(
37
+ "text-generation",
38
+ model=MODEL_ID,
39
+ tokenizer=tok,
40
+ torch_dtype=torch.float16,
41
+ device_map="auto",
42
+ token=HF_TOKEN,
43
+ )
44
+ log.info("LLM %s loaded", MODEL_ID)
45
+ except Exception as exc:
46
+ log.warning("Local LLM unavailable: %s (Groq fallback will be used)", exc)
47
+ finally:
48
+ _loading = False
49
+
50
+
51
+ @app.on_event("startup")
52
+ async def startup():
53
+ threading.Thread(target=_load_model, daemon=True).start()
54
+
55
+
56
+ class GenerateRequest(BaseModel):
57
+ prompt: str
58
+ max_tokens: Optional[int] = 256
59
+ temperature: Optional[float] = 0.7
60
+
61
+
62
+ def _groq_generate(prompt: str, max_tokens: int, temperature: float) -> str:
63
+ import json, urllib.request
64
+ payload = json.dumps({
65
+ "model": "llama3-8b-8192",
66
+ "messages": [{"role": "user", "content": prompt}],
67
+ "max_tokens": max_tokens,
68
+ "temperature": temperature,
69
+ }).encode()
70
+ req = urllib.request.Request(
71
+ "https://api.groq.com/openai/v1/chat/completions",
72
+ data=payload,
73
+ headers={"Authorization": f"Bearer {GROQ_KEY}", "Content-Type": "application/json"},
74
+ method="POST",
75
+ )
76
+ with urllib.request.urlopen(req, timeout=30) as resp:
77
+ data = json.loads(resp.read())
78
+ return data["choices"][0]["message"]["content"]
79
+
80
+
81
+ @app.post("/generate")
82
+ def generate(req: GenerateRequest):
83
+ if _loading:
84
+ return {"text": "Model is warming up, please retry shortly.", "source": "loading"}
85
+
86
+ if _pipeline is not None:
87
+ try:
88
+ outputs = _pipeline(
89
+ req.prompt,
90
+ max_new_tokens=req.max_tokens,
91
+ temperature=req.temperature,
92
+ do_sample=True,
93
+ pad_token_id=_pipeline.tokenizer.eos_token_id,
94
+ )
95
+ text = outputs[0]["generated_text"][len(req.prompt):].strip()
96
+ return {"text": text, "source": "local"}
97
+ except Exception as exc:
98
+ log.warning("Local generation failed: %s", exc)
99
+
100
+ if GROQ_KEY:
101
+ try:
102
+ text = _groq_generate(req.prompt, req.max_tokens, req.temperature)
103
+ return {"text": text, "source": "groq"}
104
+ except Exception as exc:
105
+ log.error("Groq fallback failed: %s", exc)
106
+
107
+ return {"text": "I apologize, the AI service is temporarily unavailable.", "source": "fallback"}
108
+
109
+
110
+ @app.get("/health")
111
+ def health():
112
+ return {"status": "ok", "loaded": _pipeline is not None, "loading": _loading,
113
+ "groq_available": bool(GROQ_KEY)}
114
+
115
+
116
+ # Gradio UI
117
+ import gradio as gr
118
+
119
+ def gradio_generate(prompt_text, max_tokens, temperature):
120
+ result = generate(GenerateRequest(prompt=prompt_text, max_tokens=int(max_tokens), temperature=float(temperature)))
121
+ return result.get("text", "")
122
+
123
+ demo = gr.Interface(
124
+ fn=gradio_generate,
125
+ inputs=[
126
+ gr.Textbox(lines=6, label="Prompt"),
127
+ gr.Slider(64, 1024, value=256, step=64, label="Max Tokens"),
128
+ gr.Slider(0.1, 1.5, value=0.7, step=0.1, label="Temperature"),
129
+ ],
130
+ outputs=gr.Textbox(label="Response"),
131
+ title="Llama 3 RAG Generator",
132
+ )
133
+
134
+ if __name__ == "__main__":
135
+ import uvicorn
136
+ uvicorn.run(app, host="0.0.0.0", port=7860)
hf_spaces/stt/app.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hf_spaces/stt/app.py
3
+ -----------------------
4
+ HuggingFace Space: Whisper Speech-to-Text service.
5
+ POST /transcribe {"audio_b64": "...", "language": "en"}
6
+ → {"text": "...", "language": "en", "confidence": 0.99}
7
+ """
8
+
9
+ import os
10
+ import io
11
+ import base64
12
+ import logging
13
+ import threading
14
+
15
+ from fastapi import FastAPI
16
+ from pydantic import BaseModel
17
+ from typing import Optional
18
+
19
+ log = logging.getLogger(__name__)
20
+ app = FastAPI(title="STT Space")
21
+
22
+ WHISPER_SIZE = os.environ.get("WHISPER_MODEL_SIZE", "small")
23
+ _model = None
24
+
25
+
26
+ def _load_model():
27
+ global _model
28
+ try:
29
+ from faster_whisper import WhisperModel
30
+ _model = WhisperModel(WHISPER_SIZE, device="cpu", compute_type="int8")
31
+ log.info("Whisper %s loaded", WHISPER_SIZE)
32
+ except Exception as exc:
33
+ log.error("Whisper load failed: %s", exc)
34
+
35
+
36
+ @app.on_event("startup")
37
+ async def startup():
38
+ threading.Thread(target=_load_model, daemon=True).start()
39
+
40
+
41
+ class TranscribeRequest(BaseModel):
42
+ audio_b64: str
43
+ language: Optional[str] = None
44
+
45
+
46
+ @app.post("/transcribe")
47
+ def transcribe(req: TranscribeRequest):
48
+ if _model is None:
49
+ return {"text": "", "language": "en", "confidence": 0.0, "status": "warming_up"}
50
+
51
+ try:
52
+ import numpy as np
53
+ import scipy.io.wavfile as wav_io
54
+
55
+ audio_bytes = base64.b64decode(req.audio_b64)
56
+ sr, data = wav_io.read(io.BytesIO(audio_bytes))
57
+ audio_np = data.astype(np.float32) / 32768.0
58
+
59
+ segs, info = _model.transcribe(
60
+ audio_np,
61
+ beam_size=5,
62
+ language=req.language,
63
+ vad_filter=True,
64
+ )
65
+ text = " ".join(s.text for s in segs).strip()
66
+ return {
67
+ "text": text,
68
+ "language": info.language,
69
+ "confidence": round(info.language_probability, 3),
70
+ }
71
+ except Exception as exc:
72
+ log.error("Transcription error: %s", exc)
73
+ return {"text": "", "language": "en", "confidence": 0.0, "error": str(exc)}
74
+
75
+
76
+ @app.get("/health")
77
+ def health():
78
+ return {"status": "ok", "model": f"whisper-{WHISPER_SIZE}", "loaded": _model is not None}
79
+
80
+
81
+ # Gradio UI
82
+ import gradio as gr
83
+
84
+ def gradio_transcribe(audio_path, language):
85
+ if _model is None:
86
+ return "Model loading, please wait..."
87
+ with open(audio_path, "rb") as f:
88
+ audio_b64 = base64.b64encode(f.read()).decode()
89
+ result = transcribe(TranscribeRequest(audio_b64=audio_b64, language=language or None))
90
+ return result.get("text", "")
91
+
92
+ demo = gr.Interface(
93
+ fn=gradio_transcribe,
94
+ inputs=[gr.Audio(type="filepath", label="Upload Audio"), gr.Textbox(label="Language (optional)")],
95
+ outputs=gr.Textbox(label="Transcription"),
96
+ title=f"Whisper STT ({WHISPER_SIZE})",
97
+ )
98
+
99
+ if __name__ == "__main__":
100
+ import uvicorn
101
+ uvicorn.run(app, host="0.0.0.0", port=7860)
rasa/actions/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # rasa/actions/__init__.py
2
+ # Rasa custom actions package
rasa/actions/actions_brain.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rasa/actions/actions_brain.py
3
+ ------------------------------
4
+ Custom Rasa actions — BRAIN module.
5
+
6
+ Module 1: Language Detection + Multilingual Greeting (COMPLETE)
7
+ ActionGreetUser — detects user language, responds in that language,
8
+ sets the `language` slot, sends quick-reply buttons.
9
+
10
+ Do NOT modify existing action classes. Append new actions below.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import sys
18
+ from typing import Any, Text, Dict, List
19
+
20
+ from rasa_sdk import Action, Tracker
21
+ from rasa_sdk.executor import CollectingDispatcher
22
+ from rasa_sdk.events import SlotSet
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Path bootstrap — allows importing from services/ when actions run as a
28
+ # standalone server (rasa run actions). The actions server starts from the
29
+ # rasa/ directory, so we walk two levels up to the repo root.
30
+ # ---------------------------------------------------------------------------
31
+ _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
32
+ if _REPO_ROOT not in sys.path:
33
+ sys.path.insert(0, _REPO_ROOT)
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Multilingual greeting strings (mirrors services/language_service/main.py).
38
+ # Kept here as a local copy so ActionGreetUser never needs an HTTP call at
39
+ # greet time — zero latency, zero external dependency on the language service.
40
+ # ---------------------------------------------------------------------------
41
+ _GREETINGS: Dict[str, str] = {
42
+ "en": "Hello! Welcome to BookBot. 👋 How can I help you today?",
43
+ "ar": "مرحباً! أهلاً بك في BookBot. 👋 كيف يمكنني مساعدتك اليوم؟",
44
+ "fr": "Bonjour! Bienvenue sur BookBot. 👋 Comment puis-je vous aider?",
45
+ "es": "¡Hola! Bienvenido a BookBot. 👋 ¿Cómo puedo ayudarte hoy?",
46
+ "de": "Hallo! Willkommen bei BookBot. 👋 Wie kann ich Ihnen heute helfen?",
47
+ "hi": "नमस्ते! BookBot में आपका स्वागत है। 👋 आज मैं आपकी कैसे मदद कर सकता हूँ?",
48
+ "zh": "您好!欢迎来到 BookBot。👋 今天我能帮您什么?",
49
+ "ja": "こんにちは!BookBot へようこそ。👋 本日はどのようなご用件でしょうか?",
50
+ "ko": "안녕하세요! BookBot 에 오신 것을 환영합니다. 👋 오늘 어떻게 도와드릴까요?",
51
+ "pt": "Olá! Bem-vindo ao BookBot. 👋 Como posso ajudá-lo hoje?",
52
+ "ru": "Здравствуйте! Добро пожаловать в BookBot. 👋 Чем могу помочь?",
53
+ "id": "Halo! Selamat datang di BookBot. 👋 Bagaimana saya bisa membantu Anda?",
54
+ "tr": "Merhaba! BookBot'a hoş geldiniz. 👋 Bugün size nasıl yardımcı olabilirim?",
55
+ "th": "สวัสดี! ยินดีต้อนรับสู่ BookBot! 👋 วันนี้ฉันจะช่วยคุณได้อย่างไร?",
56
+ "vi": "Xin chào! Chào mừng đến với BookBot. 👋 Tôi có thể giúp gì cho bạn hôm nay?",
57
+ "te": "హలో! BookBot కి స్వాగతం. 👋 నేను మీకు ఎలా సహాయపడగలను?",
58
+ "ta": "வணக்கம்! BookBot-க்கு வரவேற்கிறோம். 👋 இன்று நான் உங்களுக்கு எவ்வாறு உதவலாம்?",
59
+ }
60
+
61
+ # Quick-reply button definitions sent with every greeting.
62
+ # Payload prefix LANG_ is stripped by main.py before forwarding to the processor.
63
+ _LANG_BUTTONS: List[Dict[str, str]] = [
64
+ {"content_type": "text", "title": "🇬🇧 English", "payload": "LANG_en"},
65
+ {"content_type": "text", "title": "🇸🇦 عربي", "payload": "LANG_ar"},
66
+ {"content_type": "text", "title": "🇫🇷 Français", "payload": "LANG_fr"},
67
+ {"content_type": "text", "title": "🇪🇸 Español", "payload": "LANG_es"},
68
+ {"content_type": "text", "title": "🇩🇪 Deutsch", "payload": "LANG_de"},
69
+ {"content_type": "text", "title": "🇮🇳 हिन्दी", "payload": "LANG_hi"},
70
+ {"content_type": "text", "title": "🇨🇳 中文", "payload": "LANG_zh"},
71
+ {"content_type": "text", "title": "🇯🇵 日本語", "payload": "LANG_ja"},
72
+ {"content_type": "text", "title": "🇧🇷 Português", "payload": "LANG_pt"},
73
+ {"content_type": "text", "title": "🇷🇺 Русский", "payload": "LANG_ru"},
74
+ ]
75
+
76
+ # Languages that are written right-to-left.
77
+ _RTL_LANGS = {"ar", "he", "fa", "ur", "yi"}
78
+
79
+
80
+ def _detect_lang(text: str) -> tuple[str, float]:
81
+ """
82
+ Detect the language of *text* using the project's language detector.
83
+
84
+ Returns (language_code, confidence). Falls back to ("en", 0.0) on any
85
+ error so the greeting always succeeds.
86
+ """
87
+ if not text or not text.strip():
88
+ return "en", 0.0
89
+ try:
90
+ from services.language_service.detector import detect
91
+ result = detect(text)
92
+ return result.get("language_code", "en"), result.get("confidence", 0.0)
93
+ except Exception as exc:
94
+ logger.warning("Language detection failed: %s — defaulting to 'en'", exc)
95
+ return "en", 0.0
96
+
97
+
98
+ def _translate_greeting(text: str, target_lang: str) -> str:
99
+ """
100
+ Translate the English greeting to *target_lang* via the translator service.
101
+ Returns the original English greeting on any error.
102
+ """
103
+ try:
104
+ from services.language_service.translator import translate
105
+ return translate(text, src="en", tgt=target_lang)
106
+ except Exception as exc:
107
+ logger.warning("Translation failed for lang=%s: %s", target_lang, exc)
108
+ return text
109
+
110
+
111
+ # ═══════════════════════════════════════════════════════════════════════════
112
+ # MODULE 1 — ACTION: ActionGreetUser
113
+ # ═══════════════════════════════════════════════════════════════════════════
114
+
115
+ class ActionGreetUser(Action):
116
+ """
117
+ Multilingual greeting action — Module 1, fully complete.
118
+
119
+ Flow:
120
+ 1. Check if `language` slot already set (returning user / language button tap).
121
+ 2. If not set: detect from the user's opening message text.
122
+ 3. If confidence < 0.75 (short greetings like "hi"): keep detected language
123
+ but show language-selection quick replies so the user can confirm.
124
+ 4. Return greeting in the user's language.
125
+ 5. Set `language` slot so all subsequent actions know the preferred language.
126
+ """
127
+
128
+ def name(self) -> Text:
129
+ return "action_greet_user"
130
+
131
+ def run(
132
+ self,
133
+ dispatcher: CollectingDispatcher,
134
+ tracker: Tracker,
135
+ domain: Dict[Text, Any],
136
+ ) -> List[Dict[Text, Any]]:
137
+
138
+ # ── 1. Resolve language ──────────────────────────────────────────────
139
+ lang = tracker.get_slot("language")
140
+ confidence = 1.0
141
+
142
+ if not lang:
143
+ user_text = tracker.latest_message.get("text", "") or ""
144
+ lang, confidence = _detect_lang(user_text)
145
+
146
+ # Sanitise: strip region suffix (e.g. "zh-cn" → "zh")
147
+ lang = lang.lower().split("-")[0].split("_")[0]
148
+
149
+ # ── 2. Build greeting ────────────────────────────────────────────────
150
+ if lang in _GREETINGS:
151
+ greeting = _GREETINGS[lang]
152
+ else:
153
+ # Language known but no pre-written greeting → translate English one
154
+ english = _GREETINGS["en"]
155
+ greeting = _translate_greeting(english, lang)
156
+
157
+ # ── 3. Build follow-up prompt ────────────────────────────────────────
158
+ # Low confidence + Latin-script message could mean we misidentified.
159
+ # Show language buttons so user can pick if needed; also offer "Book now".
160
+ show_lang_buttons = confidence < 0.85
161
+
162
+ if show_lang_buttons:
163
+ dispatcher.utter_message(text=greeting, quick_replies=_LANG_BUTTONS)
164
+ else:
165
+ # High-confidence detection — show action buttons directly.
166
+ action_buttons = [
167
+ {"content_type": "text", "title": "🏨 Book a Hotel", "payload": "ACTION_BOOK"},
168
+ {"content_type": "text", "title": "📋 My Bookings", "payload": "MY_BOOKINGS"},
169
+ {"content_type": "text", "title": "🌐 Change Language", "payload": "ACTION_CHANGE_LANG"},
170
+ ]
171
+ dispatcher.utter_message(text=greeting, quick_replies=action_buttons)
172
+
173
+ # ── 4. Persist language slot ─────────────────────────────────────────
174
+ return [SlotSet("language", lang)]
175
+
176
+
177
+ # ═══════════════════════════════════════════════════════════════════════════
178
+ # MODULE 1 — ACTION: ActionChangeLanguage
179
+ # ═══════════════════════════════════════════════════════════════════════════
180
+
181
+ class ActionChangeLanguage(Action):
182
+ """
183
+ Handles ACTION_CHANGE_LANG postback and `change_language` intent.
184
+
185
+ Sends the full language-selection quick-reply menu and clears the
186
+ current language slot so the user can pick a new one.
187
+ """
188
+
189
+ def name(self) -> Text:
190
+ return "action_change_language"
191
+
192
+ def run(
193
+ self,
194
+ dispatcher: CollectingDispatcher,
195
+ tracker: Tracker,
196
+ domain: Dict[Text, Any],
197
+ ) -> List[Dict[Text, Any]]:
198
+
199
+ dispatcher.utter_message(
200
+ text="Which language would you prefer? 🌐",
201
+ quick_replies=_LANG_BUTTONS,
202
+ )
203
+ return [SlotSet("language", None)]
204
+
205
+
206
+ # ═══════════════════════════════════════════════════════════════════════════
207
+ # MODULE 1 — ACTION: ActionSessionStart
208
+ # ═══════════════════════════════════════════════════════════════════════════
209
+
210
+ class ActionSessionStart(Action):
211
+ """
212
+ Fires on every new conversation session (maps to action_session_start).
213
+
214
+ Restores the `language` slot from the previous session if it was saved
215
+ in Redis, so returning users are greeted in their chosen language.
216
+ """
217
+
218
+ def name(self) -> Text:
219
+ return "action_session_start"
220
+
221
+ def run(
222
+ self,
223
+ dispatcher: CollectingDispatcher,
224
+ tracker: Tracker,
225
+ domain: Dict[Text, Any],
226
+ ) -> List[Dict[Text, Any]]:
227
+
228
+ sender = tracker.sender_id
229
+ saved_lang: str | None = None
230
+
231
+ try:
232
+ from db.redis_client import sync_redis
233
+ r = sync_redis()
234
+ saved_lang = r.get(f"user:{sender}:lang")
235
+ except Exception as exc:
236
+ logger.debug("Could not load language from Redis: %s", exc)
237
+
238
+ events: List[Dict[Text, Any]] = []
239
+ if saved_lang:
240
+ events.append(SlotSet("language", saved_lang))
241
+ logger.info("Restored language=%s for sender=%s", saved_lang, sender)
242
+
243
+ return events
rasa/config.yml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rasa/config.yml
2
+ # ------------------------------------------------
3
+ # Rasa NLU + Core pipeline configuration
4
+ # ------------------------------------------------
5
+
6
+ recipe: default.v1
7
+
8
+ language: en
9
+
10
+ pipeline:
11
+ - name: WhitespaceTokenizer
12
+ - name: RegexFeaturizer
13
+ - name: LexicalSyntacticFeaturizer
14
+ - name: CountVectorsFeaturizer
15
+ - name: CountVectorsFeaturizer
16
+ analyzer: char_wb
17
+ min_ngram: 1
18
+ max_ngram: 4
19
+ - name: DIETClassifier
20
+ epochs: 100
21
+ constrain_similarities: true
22
+ - name: EntitySynonymMapper
23
+ - name: ResponseSelector
24
+ epochs: 100
25
+ - name: FallbackClassifier
26
+ threshold: 0.3
27
+ ambiguity_threshold: 0.1
28
+
29
+ policies:
30
+ - name: MemoizationPolicy
31
+ - name: TEDPolicy
32
+ max_history: 10
33
+ epochs: 100
34
+ constrain_similarities: true
35
+ - name: RulePolicy
36
+ core_fallback_threshold: 0.4
37
+ core_fallback_action_name: action_default_fallback
38
+ enable_fallback_prediction: true
rasa/credentials.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rasa/credentials.yml
2
+ # ------------------------------------------------
3
+ # Channel credentials for Rasa server
4
+ # ------------------------------------------------
5
+
6
+ facebook:
7
+ verify: "${VERIFY_TOKEN}"
8
+ secret: "${FB_APP_SECRET}"
9
+ page-access-token: "${PAGE_ACCESS_TOKEN}"
10
+
11
+ rest:
12
+ # REST channel for testing / internal API calls
rasa/data/nlu/nlu_en.yml ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rasa/data/nlu/nlu_en.yml
2
+ # ----------------------------------------
3
+ # English NLU training data
4
+ # ----------------------------------------
5
+
6
+ version: "3.1"
7
+
8
+ nlu:
9
+
10
+ - intent: greet
11
+ examples: |
12
+ - hi
13
+ - hello
14
+ - hey
15
+ - good morning
16
+ - good evening
17
+ - howdy
18
+ - what's up
19
+ - hiya
20
+ - hi there
21
+ - greetings
22
+ - sup
23
+ - yo
24
+ - good afternoon
25
+ - good day
26
+ - heya
27
+ - hey there
28
+ - hello there
29
+ - hola
30
+ - ahoy
31
+ - bonjour
32
+ - salaam
33
+ - namaste
34
+ - salam
35
+ - start
36
+ - begin
37
+
38
+ - intent: goodbye
39
+ examples: |
40
+ - bye
41
+ - goodbye
42
+ - see you later
43
+ - good night
44
+ - take care
45
+ - ciao
46
+ - see ya
47
+
48
+ - intent: affirm
49
+ examples: |
50
+ - yes
51
+ - yep
52
+ - yeah
53
+ - confirm
54
+ - correct
55
+ - that's right
56
+ - ok
57
+ - okay
58
+ - sure
59
+ - absolutely
60
+ - go ahead
61
+
62
+ - intent: deny
63
+ examples: |
64
+ - no
65
+ - nope
66
+ - not really
67
+ - cancel
68
+ - stop
69
+ - negative
70
+ - I don't want that
71
+ - no thanks
72
+
73
+ - intent: book_hotel
74
+ examples: |
75
+ - I want to book a hotel
76
+ - book a room
77
+ - I need a hotel
78
+ - find me a hotel
79
+ - hotel reservation please
80
+ - make a booking
81
+ - I'd like to stay at a hotel
82
+ - can you book a hotel room for me
83
+ - hotel booking
84
+ - reserve a hotel
85
+ - I want to travel to [Paris](city) and stay somewhere nice
86
+
87
+ - intent: provide_city
88
+ examples: |
89
+ - [Paris](city)
90
+ - I want to go to [London](city)
91
+ - [New York](city) please
92
+ - staying in [Tokyo](city)
93
+ - [Dubai](city)
94
+ - [Mumbai](city)
95
+ - [Bangkok](city)
96
+ - [Singapore](city)
97
+ - [Istanbul](city)
98
+
99
+ - intent: provide_checkin
100
+ examples: |
101
+ - [2025-06-15](date)
102
+ - June 15th
103
+ - next Monday
104
+ - this Friday
105
+ - tomorrow
106
+ - [2025-07-01](date)
107
+ - checking in on [2025-08-10](date)
108
+
109
+ - intent: provide_guests
110
+ examples: |
111
+ - [2](number) guests
112
+ - just [1](number)
113
+ - [3](number) people
114
+ - [4](number) adults
115
+ - a family of [5](number)
116
+ - only me
117
+ - for [2](number)
118
+
119
+ - intent: provide_name
120
+ examples: |
121
+ - my name is John Smith
122
+ - it's Maria Garcia
123
+ - Ravi Kumar
124
+ - Ahmed Hassan
125
+ - for Priya Mehta
126
+
127
+ - intent: provide_email
128
+ examples: |
129
+ - [john@example.com](email)
130
+ - my email is [user@gmail.com](email)
131
+ - [someone@company.org](email)
132
+
133
+ - intent: provide_phone
134
+ examples: |
135
+ - [+1-555-0100](phone_number)
136
+ - my number is [+44-207-946-0958](phone_number)
137
+ - [+91-9876543210](phone_number)
138
+
139
+ - intent: confirm_booking
140
+ examples: |
141
+ - confirm
142
+ - yes please confirm
143
+ - book it
144
+ - proceed
145
+ - finalize
146
+ - yes I confirm
147
+
148
+ - intent: cancel_booking
149
+ examples: |
150
+ - cancel my booking
151
+ - I want to cancel
152
+ - please cancel reservation [BK12345](booking_ref)
153
+ - cancel booking [BK99999](booking_ref)
154
+ - I need to cancel
155
+
156
+ - intent: view_bookings
157
+ examples: |
158
+ - show my bookings
159
+ - my reservations
160
+ - what bookings do I have
161
+ - view my trips
162
+ - list my bookings
163
+ - upcoming stays
164
+
165
+ - intent: modify_booking
166
+ examples: |
167
+ - I want to change my booking
168
+ - modify reservation
169
+ - change dates
170
+ - update my booking
171
+ - I need to change the room
172
+
173
+ - intent: ask_faq
174
+ examples: |
175
+ - what is the check-in time
176
+ - do you have free wifi
177
+ - is breakfast included
178
+ - what's the cancellation policy
179
+ - is parking available
180
+ - are pets allowed
181
+ - what amenities are available
182
+
183
+ - intent: change_language
184
+ examples: |
185
+ - change language
186
+ - speak to me in [Hindi](language)
187
+ - switch to [French](language)
188
+ - [Telugu](language) please
189
+ - I want [Arabic](language)
190
+ - use [Spanish](language)
191
+
192
+ - intent: human_handoff
193
+ examples: |
194
+ - talk to an agent
195
+ - real person please
196
+ - human support
197
+ - live agent
198
+ - I want to speak to someone
199
+ - connect me to staff
200
+
201
+ - intent: out_of_scope
202
+ examples: |
203
+ - what is the weather
204
+ - tell me a joke
205
+ - who won the match
206
+ - what's 2 + 2
207
+ - call a taxi
rasa/domain.yml ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rasa/domain.yml
2
+ # ------------------------------------------------
3
+ # Rasa domain: intents, entities, slots, responses, actions
4
+ # ------------------------------------------------
5
+
6
+ version: "3.1"
7
+
8
+ intents:
9
+ - greet
10
+ - goodbye
11
+ - affirm
12
+ - deny
13
+ - book_hotel
14
+ - provide_city
15
+ - provide_checkin
16
+ - provide_checkout
17
+ - provide_guests
18
+ - provide_hotel_choice
19
+ - provide_room_choice
20
+ - provide_meal_plan
21
+ - provide_name
22
+ - provide_email
23
+ - provide_phone
24
+ - confirm_booking
25
+ - cancel_booking
26
+ - view_bookings
27
+ - modify_booking
28
+ - ask_faq
29
+ - change_language
30
+ - human_handoff
31
+ - out_of_scope
32
+ - nlu_fallback
33
+
34
+ entities:
35
+ - city
36
+ - date
37
+ - number
38
+ - email
39
+ - phone_number
40
+ - hotel_name
41
+ - room_type
42
+ - meal_plan
43
+ - language
44
+ - booking_ref
45
+
46
+ slots:
47
+ city:
48
+ type: text
49
+ mappings:
50
+ - type: from_entity
51
+ entity: city
52
+ check_in:
53
+ type: text
54
+ mappings:
55
+ - type: from_entity
56
+ entity: date
57
+ check_out:
58
+ type: text
59
+ mappings:
60
+ - type: custom
61
+ guests:
62
+ type: float
63
+ mappings:
64
+ - type: from_entity
65
+ entity: number
66
+ hotel_id:
67
+ type: text
68
+ mappings:
69
+ - type: custom
70
+ room_type_id:
71
+ type: text
72
+ mappings:
73
+ - type: custom
74
+ meal_plan:
75
+ type: text
76
+ mappings:
77
+ - type: from_entity
78
+ entity: meal_plan
79
+ guest_name:
80
+ type: text
81
+ mappings:
82
+ - type: custom
83
+ guest_email:
84
+ type: text
85
+ mappings:
86
+ - type: from_entity
87
+ entity: email
88
+ guest_phone:
89
+ type: text
90
+ mappings:
91
+ - type: from_entity
92
+ entity: phone_number
93
+ language:
94
+ type: text
95
+ mappings:
96
+ - type: custom
97
+ booking_ref:
98
+ type: text
99
+ mappings:
100
+ - type: from_entity
101
+ entity: booking_ref
102
+ booking_step:
103
+ type: text
104
+ mappings:
105
+ - type: custom
106
+
107
+ responses:
108
+ # ── Module 1 responses ──────────────────────────────────────────────────
109
+ # utter_greet is a static fallback only; ActionGreetUser handles the real
110
+ # multilingual greeting dynamically via action_greet_user.
111
+ utter_greet:
112
+ - text: "Hello! I'm your hotel booking assistant. How can I help you today?"
113
+ utter_ask_language_choice:
114
+ - text: "Which language would you prefer? 🌐"
115
+ utter_goodbye:
116
+ - text: "Thank you for using our service. Have a great day! 👋"
117
+ utter_affirm:
118
+ - text: "Great!"
119
+ utter_ask_city:
120
+ - text: "Which city would you like to stay in?"
121
+ utter_ask_checkin:
122
+ - text: "What's your check-in date? (e.g. 2025-06-15)"
123
+ utter_ask_checkout:
124
+ - text: "And what's your check-out date?"
125
+ utter_ask_guests:
126
+ - text: "How many guests will be staying?"
127
+ utter_ask_name:
128
+ - text: "Please provide the full name for the booking."
129
+ utter_ask_email:
130
+ - text: "What's the email address for the confirmation?"
131
+ utter_ask_phone:
132
+ - text: "And your phone number with country code? (e.g. +1-555-0100)"
133
+ utter_fallback:
134
+ - text: "I didn't quite understand that. Could you rephrase? Or type MENU for options."
135
+ utter_handoff:
136
+ - text: "Connecting you to a live agent now, please hold on..."
137
+ utter_out_of_scope:
138
+ - text: "I can only help with hotel bookings. Type MENU to see what I can do."
139
+
140
+ actions:
141
+ # ── Module 1: Language + Greeting (COMPLETE) ────────────────────────────
142
+ - action_greet_user
143
+ - action_change_language
144
+ - action_session_start
145
+ # ── Module 2+: Booking flow ──────────────────────────────────────────────
146
+ - action_search_hotels
147
+ - action_show_hotels
148
+ - action_select_hotel
149
+ - action_show_rooms
150
+ - action_select_room
151
+ - action_collect_guest_details
152
+ - action_create_booking
153
+ - action_show_my_bookings
154
+ - action_modify_booking
155
+ - action_cancel_booking
156
+ - action_answer_faq
157
+ - action_human_handoff
158
+ - action_default_fallback
159
+ - validate_booking_form
160
+
161
+ forms:
162
+ booking_form:
163
+ required_slots:
164
+ - city
165
+ - check_in
166
+ - check_out
167
+ - guests
168
+ - hotel_id
169
+ - room_type_id
170
+ - meal_plan
171
+ - guest_name
172
+ - guest_email
173
+ - guest_phone
174
+
175
+ session_config:
176
+ session_expiration_time: 60 # minutes
177
+ carry_over_slots_to_new_session: false
rasa/endpoints.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rasa/endpoints.yml
2
+ # ------------------------------------------------
3
+ # External service endpoints for Rasa
4
+ # ------------------------------------------------
5
+
6
+ action_endpoint:
7
+ url: "${RASA_ACTIONS_URL}/webhook"
8
+
9
+ tracker_store:
10
+ type: redis
11
+ url: "${REDIS_HOST}"
12
+ port: ${REDIS_PORT:-6379}
13
+ password: "${REDIS_PASSWORD}"
14
+ db: 0
15
+ key_prefix: "rasa:"
16
+
17
+ event_broker:
18
+ type: kafka
19
+ url: "${KAFKA_BOOTSTRAP_SERVERS}"
20
+ topic: rasa.events
21
+ security_protocol: SASL_SSL
22
+ sasl_mechanism: PLAIN
23
+ sasl_plain_username: "${KAFKA_API_KEY}"
24
+ sasl_plain_password: "${KAFKA_API_SECRET}"
scripts/hotel_onboarding.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ scripts/hotel_onboarding.py
4
+ -----------------------------
5
+ Onboards a new hotel partner:
6
+ 1. Inserts hotel record into Supabase
7
+ 2. Indexes hotel into Elasticsearch
8
+ 3. Embeds and upserts FAQ entries into Qdrant
9
+
10
+ Usage:
11
+ python scripts/hotel_onboarding.py --file data/hotel_sample.json
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import sys
18
+
19
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
20
+ SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
21
+
22
+
23
+ def _sb_headers():
24
+ return {
25
+ "apikey": SUPABASE_KEY,
26
+ "Authorization": f"Bearer {SUPABASE_KEY}",
27
+ "Content-Type": "application/json",
28
+ "Prefer": "return=representation",
29
+ }
30
+
31
+
32
+ def insert_supabase(hotel: dict) -> dict:
33
+ import urllib.request
34
+ url = f"{SUPABASE_URL}/rest/v1/hotels"
35
+ data = json.dumps(hotel).encode()
36
+ req = urllib.request.Request(url, data=data, headers=_sb_headers(), method="POST")
37
+ with urllib.request.urlopen(req) as resp:
38
+ result = json.loads(resp.read())
39
+ return result[0] if isinstance(result, list) else result
40
+
41
+
42
+ def index_elasticsearch(hotel: dict) -> None:
43
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
44
+ from services.search_service.elasticsearch_client import index_hotel
45
+ index_hotel(hotel)
46
+ print(f" ✓ Elasticsearch indexed hotel '{hotel.get('name')}'")
47
+
48
+
49
+ def embed_faqs(hotel_id: str, faqs: list[dict]) -> None:
50
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
51
+ from services.rag_service.qdrant_client import upsert_faqs
52
+ upsert_faqs(hotel_id, faqs)
53
+ print(f" ✓ Qdrant upserted {len(faqs)} FAQ entries for hotel {hotel_id}")
54
+
55
+
56
+ def onboard(hotel_data: dict) -> None:
57
+ print(f"\nOnboarding hotel: {hotel_data.get('name', '?')}")
58
+
59
+ # 1. Supabase
60
+ record = insert_supabase({k: v for k, v in hotel_data.items() if k != "faqs"})
61
+ hotel_id = record.get("id")
62
+ print(f" ✓ Supabase inserted hotel id={hotel_id}")
63
+
64
+ # 2. Elasticsearch
65
+ index_elasticsearch({**hotel_data, "id": hotel_id})
66
+
67
+ # 3. Qdrant FAQs
68
+ faqs = hotel_data.get("faqs", [])
69
+ if faqs:
70
+ embed_faqs(hotel_id, faqs)
71
+ else:
72
+ print(" ℹ️ No FAQs provided; skipping Qdrant upsert")
73
+
74
+ print(f"\n✅ Hotel '{hotel_data.get('name')}' onboarded successfully (id={hotel_id})\n")
75
+
76
+
77
+ if __name__ == "__main__":
78
+ parser = argparse.ArgumentParser()
79
+ parser.add_argument("--file", required=True, help="Path to hotel JSON file")
80
+ args = parser.parse_args()
81
+
82
+ with open(args.file, "r", encoding="utf-8") as f:
83
+ data = json.load(f)
84
+
85
+ if isinstance(data, list):
86
+ for hotel in data:
87
+ onboard(hotel)
88
+ else:
89
+ onboard(data)
scripts/setup_elasticsearch_index.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ scripts/setup_elasticsearch_index.py
4
+ --------------------------------------
5
+ Creates (or re-creates) the Elasticsearch hotel index with
6
+ the correct mappings and settings.
7
+
8
+ Usage:
9
+ ES_URL=https://... python scripts/setup_elasticsearch_index.py
10
+ """
11
+
12
+ import os
13
+ import json
14
+ import urllib.request
15
+
16
+ ES_URL = os.environ.get("ELASTICSEARCH_URL", "http://localhost:9200")
17
+ ES_USER = os.environ.get("ELASTICSEARCH_USERNAME", "elastic")
18
+ ES_PASS = os.environ.get("ELASTICSEARCH_PASSWORD", "")
19
+ INDEX = "hotels"
20
+
21
+ MAPPING = {
22
+ "settings": {
23
+ "number_of_shards": 1,
24
+ "number_of_replicas": 1,
25
+ "analysis": {
26
+ "analyzer": {
27
+ "hotel_analyzer": {
28
+ "type": "custom",
29
+ "tokenizer": "standard",
30
+ "filter": ["lowercase", "asciifolding", "stop"],
31
+ }
32
+ }
33
+ },
34
+ },
35
+ "mappings": {
36
+ "properties": {
37
+ "id": {"type": "keyword"},
38
+ "name": {"type": "text", "analyzer": "hotel_analyzer"},
39
+ "city": {"type": "text", "analyzer": "hotel_analyzer",
40
+ "fields": {"keyword": {"type": "keyword"}}},
41
+ "country": {"type": "keyword"},
42
+ "description": {"type": "text", "analyzer": "hotel_analyzer"},
43
+ "stars": {"type": "integer"},
44
+ "rating": {"type": "float"},
45
+ "price_per_night": {"type": "float"},
46
+ "amenities": {"type": "keyword"},
47
+ "location": {
48
+ "type": "geo_point",
49
+ },
50
+ "is_active": {"type": "boolean"},
51
+ "images": {"type": "keyword", "index": False},
52
+ "updated_at": {"type": "date"},
53
+ }
54
+ },
55
+ }
56
+
57
+
58
+ def _request(method: str, path: str, body: dict = None):
59
+ url = f"{ES_URL}{path}"
60
+ data = json.dumps(body).encode() if body else None
61
+ import base64
62
+ creds = base64.b64encode(f"{ES_USER}:{ES_PASS}".encode()).decode()
63
+ headers = {
64
+ "Content-Type": "application/json",
65
+ "Authorization": f"Basic {creds}",
66
+ }
67
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
68
+ with urllib.request.urlopen(req) as resp:
69
+ return json.loads(resp.read())
70
+
71
+
72
+ def setup():
73
+ # Delete existing index if present
74
+ try:
75
+ _request("DELETE", f"/{INDEX}")
76
+ print(f"Deleted existing index '{INDEX}'")
77
+ except Exception:
78
+ pass
79
+
80
+ result = _request("PUT", f"/{INDEX}", MAPPING)
81
+ print(f"Index '{INDEX}' created:", result)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ setup()
scripts/setup_messenger_menu.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ scripts/setup_messenger_menu.py
4
+ ---------------------------------
5
+ Sets up the Facebook Messenger Persistent Menu for the bot page.
6
+ Run once per page or whenever menu items change.
7
+
8
+ Usage:
9
+ python scripts/setup_messenger_menu.py
10
+ """
11
+
12
+ import os
13
+ import json
14
+ import urllib.request
15
+
16
+ PAGE_ACCESS_TOKEN = os.environ["PAGE_ACCESS_TOKEN"]
17
+ META_GRAPH_URL = "https://graph.facebook.com/v18.0/me/messenger_profile"
18
+
19
+ MENU = {
20
+ "persistent_menu": [
21
+ {
22
+ "locale": "default",
23
+ "composer_input_disabled": False,
24
+ "call_to_actions": [
25
+ {"type": "postback", "title": "🏨 Book a Hotel", "payload": "BOOK_HOTEL"},
26
+ {"type": "postback", "title": "📋 My Bookings", "payload": "MY_BOOKINGS"},
27
+ {
28
+ "type": "nested",
29
+ "title": "⚙️ Manage Booking",
30
+ "call_to_actions": [
31
+ {"type": "postback", "title": "✏️ Modify Booking", "payload": "MODIFY_BOOKING"},
32
+ {"type": "postback", "title": "❌ Cancel Booking", "payload": "CANCEL_BOOKING"},
33
+ {"type": "postback", "title": "📧 Resend Confirmation","payload": "RESEND_CONFIRM"},
34
+ ],
35
+ },
36
+ {"type": "postback", "title": "🌐 Change Language", "payload": "CHANGE_LANGUAGE"},
37
+ {"type": "postback", "title": "🤝 Talk to Agent", "payload": "HUMAN_HANDOFF"},
38
+ ],
39
+ }
40
+ ],
41
+ "get_started": {"payload": "GET_STARTED"},
42
+ "greeting": [
43
+ {"locale": "default", "text": "Hello {{user_first_name}}! I'm your hotel booking assistant. How can I help you today?"},
44
+ {"locale": "hi_IN", "text": "नमस्ते {{user_first_name}}! मैं आपका होटल बुकिंग सहायक हूं।"},
45
+ ],
46
+ }
47
+
48
+
49
+ def setup():
50
+ data = json.dumps(MENU).encode()
51
+ headers = {
52
+ "Content-Type": "application/json",
53
+ }
54
+ url = f"{META_GRAPH_URL}?access_token={PAGE_ACCESS_TOKEN}"
55
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
56
+ with urllib.request.urlopen(req) as resp:
57
+ result = json.loads(resp.read())
58
+ print("Messenger menu set:", result)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ setup()
scripts/setup_qdrant_collection.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ scripts/setup_qdrant_collection.py
4
+ ------------------------------------
5
+ Creates the Qdrant collection for hotel FAQ embeddings (LaBSE 768-dim).
6
+
7
+ Usage:
8
+ QDRANT_URL=https://... QDRANT_API_KEY=xxx python scripts/setup_qdrant_collection.py
9
+ """
10
+
11
+ import os
12
+
13
+ QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
14
+ QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", "")
15
+ COLLECTION = "hotel_faqs"
16
+ VECTOR_SIZE = 768 # LaBSE
17
+
18
+
19
+ def setup():
20
+ try:
21
+ from qdrant_client import QdrantClient
22
+ from qdrant_client.models import Distance, VectorParams, HnswConfigDiff
23
+ except ImportError:
24
+ raise SystemExit("qdrant-client is required: pip install qdrant-client")
25
+
26
+ client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY or None)
27
+
28
+ # Delete if exists
29
+ collections = [c.name for c in client.get_collections().collections]
30
+ if COLLECTION in collections:
31
+ client.delete_collection(COLLECTION)
32
+ print(f"Deleted existing collection '{COLLECTION}'")
33
+
34
+ client.create_collection(
35
+ collection_name=COLLECTION,
36
+ vectors_config=VectorParams(size=VECTOR_SIZE, distance=Distance.COSINE),
37
+ hnsw_config=HnswConfigDiff(m=16, ef_construct=100),
38
+ )
39
+ print(f"Collection '{COLLECTION}' created (size={VECTOR_SIZE}, metric=COSINE)")
40
+
41
+
42
+ if __name__ == "__main__":
43
+ setup()
services/analytics_service/clickhouse_schema.sql ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- ============================================================
2
+ -- ClickHouse schema for BookHotel analytics
3
+ -- ============================================================
4
+
5
+ CREATE DATABASE IF NOT EXISTS bookhotel;
6
+
7
+ -- ---------------------------------------------------------------
8
+ -- Booking Events
9
+ -- ---------------------------------------------------------------
10
+ CREATE TABLE IF NOT EXISTS bookhotel.booking_events
11
+ (
12
+ event_type LowCardinality(String), -- created | succeeded | cancelled | modified
13
+ booking_id String,
14
+ hotel_id String,
15
+ guest_id String,
16
+ amount_usd Float64,
17
+ check_in String,
18
+ check_out String,
19
+ created_at DateTime
20
+ )
21
+ ENGINE = MergeTree()
22
+ PARTITION BY toYYYYMM(created_at)
23
+ ORDER BY (event_type, created_at);
24
+
25
+ -- ---------------------------------------------------------------
26
+ -- NLU Logs
27
+ -- ---------------------------------------------------------------
28
+ CREATE TABLE IF NOT EXISTS bookhotel.nlu_logs
29
+ (
30
+ sender_id String,
31
+ intent LowCardinality(String),
32
+ confidence Float32,
33
+ language LowCardinality(String),
34
+ created_at DateTime
35
+ )
36
+ ENGINE = MergeTree()
37
+ PARTITION BY toYYYYMM(created_at)
38
+ ORDER BY (intent, created_at);
39
+
40
+ -- ---------------------------------------------------------------
41
+ -- Revenue Summary (materialized view refreshed hourly)
42
+ -- ---------------------------------------------------------------
43
+ CREATE MATERIALIZED VIEW IF NOT EXISTS bookhotel.revenue_daily
44
+ ENGINE = SummingMergeTree()
45
+ PARTITION BY toYYYYMM(day)
46
+ ORDER BY (hotel_id, day)
47
+ AS
48
+ SELECT
49
+ hotel_id,
50
+ toDate(created_at) AS day,
51
+ count() AS bookings,
52
+ sum(amount_usd) AS revenue_usd
53
+ FROM bookhotel.booking_events
54
+ WHERE event_type = 'succeeded'
55
+ GROUP BY hotel_id, day;
services/analytics_service/kafka_consumer.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/analytics_service/kafka_consumer.py
3
+ ----------------------------------------------
4
+ Consumes booking events from Kafka and inserts them into ClickHouse.
5
+
6
+ Topics consumed:
7
+ booking.created
8
+ booking.payment.succeeded
9
+ booking.cancelled
10
+ booking.modified
11
+ nlu.intent.logged
12
+
13
+ Runs as a standalone worker process (not a FastAPI route).
14
+ """
15
+
16
+ import os
17
+ import json
18
+ import logging
19
+ from datetime import datetime, timezone
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ KAFKA_BOOTSTRAP = os.environ.get("KAFKA_BOOTSTRAP_SERVERS", "localhost:9092")
24
+ KAFKA_GROUP_ID = os.environ.get("KAFKA_CONSUMER_GROUP", "analytics-consumer")
25
+ KAFKA_TOPICS = ["booking.created", "booking.payment.succeeded",
26
+ "booking.cancelled", "booking.modified", "nlu.intent.logged"]
27
+
28
+ CLICKHOUSE_HOST = os.environ.get("CLICKHOUSE_HOST", "localhost")
29
+ CLICKHOUSE_PORT = int(os.environ.get("CLICKHOUSE_PORT", "9000"))
30
+ CLICKHOUSE_DB = os.environ.get("CLICKHOUSE_DB", "bookhotel")
31
+ CLICKHOUSE_USER = os.environ.get("CLICKHOUSE_USER", "default")
32
+ CLICKHOUSE_PASS = os.environ.get("CLICKHOUSE_PASSWORD", "")
33
+
34
+
35
+ def _get_ch_client():
36
+ try:
37
+ from clickhouse_driver import Client
38
+ return Client(
39
+ host=CLICKHOUSE_HOST,
40
+ port=CLICKHOUSE_PORT,
41
+ database=CLICKHOUSE_DB,
42
+ user=CLICKHOUSE_USER,
43
+ password=CLICKHOUSE_PASS,
44
+ )
45
+ except ImportError:
46
+ raise RuntimeError("clickhouse-driver is required: pip install clickhouse-driver")
47
+
48
+
49
+ def _insert_booking_event(ch, event: dict) -> None:
50
+ ch.execute(
51
+ "INSERT INTO booking_events (event_type, booking_id, hotel_id, guest_id, "
52
+ "amount_usd, check_in, check_out, created_at) VALUES",
53
+ [{
54
+ "event_type": event.get("type", "unknown"),
55
+ "booking_id": event.get("booking_id", ""),
56
+ "hotel_id": event.get("hotel_id", ""),
57
+ "guest_id": event.get("guest_id", ""),
58
+ "amount_usd": float(event.get("amount_usd", 0)),
59
+ "check_in": event.get("check_in", ""),
60
+ "check_out": event.get("check_out", ""),
61
+ "created_at": datetime.now(timezone.utc),
62
+ }],
63
+ )
64
+
65
+
66
+ def _insert_nlu_event(ch, event: dict) -> None:
67
+ ch.execute(
68
+ "INSERT INTO nlu_logs (sender_id, intent, confidence, language, created_at) VALUES",
69
+ [{
70
+ "sender_id": event.get("sender_id", ""),
71
+ "intent": event.get("intent", ""),
72
+ "confidence": float(event.get("confidence", 0)),
73
+ "language": event.get("language", "en"),
74
+ "created_at": datetime.now(timezone.utc),
75
+ }],
76
+ )
77
+
78
+
79
+ def run() -> None:
80
+ """
81
+ Blocking Kafka consumer loop.
82
+ Call from a Celery worker or a dedicated Render background worker.
83
+ """
84
+ try:
85
+ from kafka import KafkaConsumer
86
+ except ImportError:
87
+ raise RuntimeError("kafka-python is required: pip install kafka-python")
88
+
89
+ consumer = KafkaConsumer(
90
+ *KAFKA_TOPICS,
91
+ bootstrap_servers=KAFKA_BOOTSTRAP.split(","),
92
+ group_id=KAFKA_GROUP_ID,
93
+ auto_offset_reset="earliest",
94
+ enable_auto_commit=True,
95
+ value_deserializer=lambda m: json.loads(m.decode("utf-8")),
96
+ )
97
+ ch = _get_ch_client()
98
+ log.info("Kafka consumer started; topics=%s", KAFKA_TOPICS)
99
+
100
+ for message in consumer:
101
+ try:
102
+ event = message.value
103
+ topic = message.topic
104
+
105
+ if topic == "nlu.intent.logged":
106
+ _insert_nlu_event(ch, event)
107
+ else:
108
+ event["type"] = topic.split(".")[-1]
109
+ _insert_booking_event(ch, event)
110
+ except Exception as exc:
111
+ log.error("Error processing Kafka message: %s", exc)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ logging.basicConfig(level=logging.INFO)
116
+ run()
services/auth_service/main.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/auth_service/main.py
3
+ --------------------------------
4
+ Lightweight JWT-based auth microservice for hotel staff dashboard.
5
+ Issues short-lived access tokens + long-lived refresh tokens.
6
+ Passwords are hashed with bcrypt. Never stores plain text.
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import logging
12
+ from datetime import datetime, timezone, timedelta
13
+
14
+ from fastapi import FastAPI, HTTPException, Depends
15
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
16
+ from pydantic import BaseModel, EmailStr
17
+ from typing import Optional
18
+
19
+ log = logging.getLogger(__name__)
20
+ app = FastAPI(title="Auth Service")
21
+
22
+ JWT_SECRET = os.environ.get("JWT_SECRET", "CHANGE_ME_IN_PRODUCTION")
23
+ ACCESS_EXPIRE = int(os.environ.get("JWT_ACCESS_EXPIRE_MINUTES", "60"))
24
+ REFRESH_EXPIRE = int(os.environ.get("JWT_REFRESH_EXPIRE_DAYS", "30"))
25
+
26
+ bearer_scheme = HTTPBearer()
27
+
28
+
29
+ def _encode(payload: dict) -> str:
30
+ try:
31
+ import jwt
32
+ return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
33
+ except ImportError:
34
+ raise RuntimeError("PyJWT is required: pip install PyJWT")
35
+
36
+
37
+ def _decode(token: str) -> dict:
38
+ try:
39
+ import jwt
40
+ return jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
41
+ except Exception as exc:
42
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
43
+
44
+
45
+ def _hash_password(password: str) -> str:
46
+ try:
47
+ import bcrypt
48
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
49
+ except ImportError:
50
+ raise RuntimeError("bcrypt is required: pip install bcrypt")
51
+
52
+
53
+ def _verify_password(plain: str, hashed: str) -> bool:
54
+ try:
55
+ import bcrypt
56
+ return bcrypt.checkpw(plain.encode(), hashed.encode())
57
+ except ImportError:
58
+ raise RuntimeError("bcrypt is required")
59
+
60
+
61
+ class LoginRequest(BaseModel):
62
+ email: str
63
+ password: str
64
+
65
+
66
+ class RefreshRequest(BaseModel):
67
+ refresh_token: str
68
+
69
+
70
+ def _get_staff_from_db(email: str) -> Optional[dict]:
71
+ """Fetch staff record from Supabase."""
72
+ SUPA = os.environ.get("SUPABASE_URL", "")
73
+ KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
74
+ import urllib.request
75
+ url = f"{SUPA}/rest/v1/staff_users?email=eq.{email}&select=*&limit=1"
76
+ req = urllib.request.Request(url, headers={
77
+ "apikey": KEY, "Authorization": f"Bearer {KEY}"
78
+ })
79
+ with urllib.request.urlopen(req) as resp:
80
+ rows = json.loads(resp.read())
81
+ return rows[0] if rows else None
82
+
83
+
84
+ @app.post("/login")
85
+ def login(req: LoginRequest):
86
+ staff = _get_staff_from_db(req.email)
87
+ if not staff or not _verify_password(req.password, staff["password_hash"]):
88
+ raise HTTPException(status_code=401, detail="Invalid credentials")
89
+
90
+ now = datetime.now(timezone.utc)
91
+ access_payload = {
92
+ "sub": staff["id"],
93
+ "email": staff["email"],
94
+ "role": staff.get("role", "staff"),
95
+ "exp": now + timedelta(minutes=ACCESS_EXPIRE),
96
+ "iat": now,
97
+ }
98
+ refresh_payload = {
99
+ "sub": staff["id"],
100
+ "type": "refresh",
101
+ "exp": now + timedelta(days=REFRESH_EXPIRE),
102
+ "iat": now,
103
+ }
104
+ return {
105
+ "access_token": _encode(access_payload),
106
+ "refresh_token": _encode(refresh_payload),
107
+ "token_type": "bearer",
108
+ "expires_in": ACCESS_EXPIRE * 60,
109
+ }
110
+
111
+
112
+ @app.post("/refresh")
113
+ def refresh(req: RefreshRequest):
114
+ payload = _decode(req.refresh_token)
115
+ if payload.get("type") != "refresh":
116
+ raise HTTPException(status_code=401, detail="Not a refresh token")
117
+
118
+ now = datetime.now(timezone.utc)
119
+ new_access = {
120
+ "sub": payload["sub"],
121
+ "exp": now + timedelta(minutes=ACCESS_EXPIRE),
122
+ "iat": now,
123
+ }
124
+ return {"access_token": _encode(new_access), "token_type": "bearer"}
125
+
126
+
127
+ @app.get("/me")
128
+ def me(creds: HTTPAuthorizationCredentials = Depends(bearer_scheme)):
129
+ payload = _decode(creds.credentials)
130
+ return {"sub": payload.get("sub"), "email": payload.get("email"), "role": payload.get("role")}
131
+
132
+
133
+ @app.get("/health")
134
+ def health():
135
+ return {"status": "ok", "service": "auth_service"}
services/corporate_service/approval_engine.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/corporate_service/approval_engine.py
3
+ -----------------------------------------------
4
+ Routes high-value corporate bookings through an approval workflow.
5
+
6
+ Rules:
7
+ amount < APPROVAL_THRESHOLD → auto-approve
8
+ amount >= APPROVAL_THRESHOLD → create pending approval request,
9
+ email approver, return approval_id
10
+ """
11
+
12
+ import os
13
+ import json
14
+ import logging
15
+ from datetime import datetime, timezone
16
+ import urllib.request
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
21
+ SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
22
+ APPROVAL_THRESHOLD = float(os.environ.get("CORPORATE_APPROVAL_THRESHOLD_USD", "1000"))
23
+ NOTIFY_URL = os.environ.get("NOTIFICATION_SERVICE_URL", "http://localhost:8007")
24
+
25
+
26
+ def _headers():
27
+ return {
28
+ "apikey": SUPABASE_KEY,
29
+ "Authorization": f"Bearer {SUPABASE_KEY}",
30
+ "Content-Type": "application/json",
31
+ "Prefer": "return=representation",
32
+ }
33
+
34
+
35
+ def _post(table: str, payload: dict) -> dict:
36
+ url = f"{SUPABASE_URL}/rest/v1/{table}"
37
+ data = json.dumps(payload).encode()
38
+ req = urllib.request.Request(url, data=data, headers=_headers(), method="POST")
39
+ with urllib.request.urlopen(req) as resp:
40
+ result = json.loads(resp.read())
41
+ return result[0] if isinstance(result, list) else result
42
+
43
+
44
+ def evaluate(
45
+ corporate_account_id: str,
46
+ booking_id: str,
47
+ amount_usd: float,
48
+ approver_email: str,
49
+ ) -> dict:
50
+ """
51
+ Returns {status, approval_id, message}.
52
+ status: "approved" | "pending_approval"
53
+ """
54
+ if amount_usd < APPROVAL_THRESHOLD:
55
+ return {"status": "approved", "approval_id": None,
56
+ "message": "Auto-approved (below threshold)"}
57
+
58
+ record = _post("approval_requests", {
59
+ "corporate_account_id": corporate_account_id,
60
+ "booking_id": booking_id,
61
+ "amount_usd": amount_usd,
62
+ "approver_email": approver_email,
63
+ "status": "pending",
64
+ "requested_at": datetime.now(timezone.utc).isoformat(),
65
+ })
66
+ approval_id = record.get("id")
67
+
68
+ # Notify approver via notification service
69
+ try:
70
+ payload = json.dumps({
71
+ "channel": "email",
72
+ "to_email": approver_email,
73
+ "email_subject": f"Approval Required: Corporate Booking ${amount_usd:.0f}",
74
+ "email_template": "approval_request.html",
75
+ "template_ctx": {
76
+ "booking_id": booking_id,
77
+ "amount_usd": amount_usd,
78
+ "approval_id": approval_id,
79
+ "approve_url": f"https://bookhotel.ai/approve/{approval_id}",
80
+ },
81
+ }).encode()
82
+ notify_req = urllib.request.Request(
83
+ f"{NOTIFY_URL}/notify",
84
+ data=payload,
85
+ headers={"Content-Type": "application/json"},
86
+ method="POST",
87
+ )
88
+ urllib.request.urlopen(notify_req)
89
+ except Exception as exc:
90
+ log.warning("Could not send approval email: %s", exc)
91
+
92
+ return {
93
+ "status": "pending_approval",
94
+ "approval_id": approval_id,
95
+ "message": f"Approval required (≥${APPROVAL_THRESHOLD:.0f}). Email sent to {approver_email}.",
96
+ }
services/corporate_service/main.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/corporate_service/main.py
3
+ ------------------------------------
4
+ FastAPI corporate booking microservice.
5
+ """
6
+
7
+ from fastapi import FastAPI, HTTPException
8
+ from pydantic import BaseModel
9
+ from typing import Optional
10
+
11
+ from .rate_lookup import get_corporate_rate
12
+ from .approval_engine import evaluate as check_approval
13
+
14
+ app = FastAPI(title="Corporate Service")
15
+
16
+
17
+ class RateRequest(BaseModel):
18
+ corporate_account_id: str
19
+ hotel_id: str
20
+ room_type_id: str
21
+ check_in: str # YYYY-MM-DD
22
+
23
+
24
+ class ApprovalRequest(BaseModel):
25
+ corporate_account_id: str
26
+ booking_id: str
27
+ amount_usd: float
28
+ approver_email: str
29
+
30
+
31
+ @app.post("/rate")
32
+ def corporate_rate(req: RateRequest):
33
+ return get_corporate_rate(
34
+ corporate_account_id=req.corporate_account_id,
35
+ hotel_id=req.hotel_id,
36
+ room_type_id=req.room_type_id,
37
+ check_in=req.check_in,
38
+ )
39
+
40
+
41
+ @app.post("/approval")
42
+ def approval(req: ApprovalRequest):
43
+ return check_approval(
44
+ corporate_account_id=req.corporate_account_id,
45
+ booking_id=req.booking_id,
46
+ amount_usd=req.amount_usd,
47
+ approver_email=req.approver_email,
48
+ )
49
+
50
+
51
+ @app.get("/approval/{approval_id}")
52
+ def get_approval(approval_id: str):
53
+ import os, json, urllib.request
54
+ SUPA = os.environ.get("SUPABASE_URL", "")
55
+ KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
56
+ url = f"{SUPA}/rest/v1/approval_requests?id=eq.{approval_id}"
57
+ req = urllib.request.Request(url, headers={
58
+ "apikey": KEY, "Authorization": f"Bearer {KEY}"
59
+ })
60
+ with urllib.request.urlopen(req) as resp:
61
+ rows = json.loads(resp.read())
62
+ if not rows:
63
+ raise HTTPException(status_code=404, detail="Approval not found")
64
+ return rows[0]
65
+
66
+
67
+ @app.get("/health")
68
+ def health():
69
+ return {"status": "ok", "service": "corporate_service"}
services/corporate_service/rate_lookup.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/corporate_service/rate_lookup.py
3
+ --------------------------------------------
4
+ Retrieves negotiated corporate rates from Supabase.
5
+ Falls back to the standard best-available rate if no contract exists.
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import logging
11
+ import urllib.request
12
+ from datetime import datetime
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
17
+ SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
18
+
19
+
20
+ def _headers():
21
+ return {
22
+ "apikey": SUPABASE_KEY,
23
+ "Authorization": f"Bearer {SUPABASE_KEY}",
24
+ "Content-Type": "application/json",
25
+ }
26
+
27
+
28
+ def get_corporate_rate(
29
+ corporate_account_id: str,
30
+ hotel_id: str,
31
+ room_type_id: str,
32
+ check_in: str,
33
+ ) -> dict:
34
+ """
35
+ Returns:
36
+ rate_per_night float
37
+ currency str
38
+ contract_id str | None
39
+ is_negotiated bool
40
+ """
41
+ url = (
42
+ f"{SUPABASE_URL}/rest/v1/corporate_rates"
43
+ f"?corporate_account_id=eq.{corporate_account_id}"
44
+ f"&hotel_id=eq.{hotel_id}"
45
+ f"&room_type_id=eq.{room_type_id}"
46
+ f"&valid_from=lte.{check_in}"
47
+ f"&valid_to=gte.{check_in}"
48
+ f"&is_active=eq.true"
49
+ f"&select=*&limit=1"
50
+ )
51
+ req = urllib.request.Request(url, headers=_headers())
52
+ with urllib.request.urlopen(req) as resp:
53
+ rows = json.loads(resp.read())
54
+
55
+ if rows:
56
+ r = rows[0]
57
+ return {
58
+ "rate_per_night": float(r["rate_per_night"]),
59
+ "currency": r.get("currency", "USD"),
60
+ "contract_id": r["id"],
61
+ "is_negotiated": True,
62
+ "discount_label": f"Corporate rate – contract {r['id'][:8]}",
63
+ }
64
+
65
+ # Fallback: fetch standard room rate
66
+ url2 = (
67
+ f"{SUPABASE_URL}/rest/v1/room_rates"
68
+ f"?room_type_id=eq.{room_type_id}"
69
+ f"&rate_type=eq.standard"
70
+ f"&select=rate_per_night,currency&limit=1"
71
+ )
72
+ req2 = urllib.request.Request(url2, headers=_headers())
73
+ with urllib.request.urlopen(req2) as resp:
74
+ rows2 = json.loads(resp.read())
75
+
76
+ std_rate = float(rows2[0]["rate_per_night"]) if rows2 else 100.0
77
+ return {
78
+ "rate_per_night": std_rate,
79
+ "currency": "USD",
80
+ "contract_id": None,
81
+ "is_negotiated": False,
82
+ "discount_label": "Best available rate",
83
+ }
services/force_majeure_service/main.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/force_majeure_service/main.py
3
+ ----------------------------------------
4
+ FastAPI force-majeure detection & relocation microservice.
5
+ """
6
+
7
+ from fastapi import FastAPI
8
+ from pydantic import BaseModel
9
+ from typing import Optional
10
+
11
+ from .news_monitor import assess
12
+ from .relocation import find_alternatives, build_relocation_message
13
+
14
+ app = FastAPI(title="Force Majeure Service")
15
+
16
+
17
+ class AssessRequest(BaseModel):
18
+ city: str
19
+ country: str
20
+ country_iso2: Optional[str] = ""
21
+
22
+
23
+ class RelocationRequest(BaseModel):
24
+ original_city: str
25
+ original_ref: str
26
+ check_in: str
27
+ check_out: str
28
+ guests: Optional[int] = 1
29
+ budget: Optional[float] = 500.0
30
+ stars: Optional[int] = 3
31
+
32
+
33
+ @app.post("/assess")
34
+ def assess_risk(req: AssessRequest):
35
+ return assess(req.city, req.country, req.country_iso2)
36
+
37
+
38
+ @app.post("/relocate")
39
+ def relocate(req: RelocationRequest):
40
+ alternatives = find_alternatives(
41
+ original_city=req.original_city,
42
+ check_in=req.check_in,
43
+ check_out=req.check_out,
44
+ guests=req.guests,
45
+ budget=req.budget,
46
+ stars=req.stars,
47
+ )
48
+ message = build_relocation_message(alternatives, req.original_ref)
49
+ return {"alternatives": alternatives, "message": message}
50
+
51
+
52
+ @app.get("/health")
53
+ def health():
54
+ return {"status": "ok", "service": "force_majeure_service"}
services/force_majeure_service/news_monitor.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/force_majeure_service/news_monitor.py
3
+ ------------------------------------------------
4
+ Monitors news feeds for force-majeure events (severe weather, natural disasters,
5
+ political unrest, pandemics) near hotel locations.
6
+
7
+ Sources:
8
+ 1. GNews API (free tier, configurable)
9
+ 2. ReliefWeb API (UN OCHA humanitarian events, free / no key)
10
+
11
+ Returns a severity level: "none" | "watch" | "warning" | "critical"
12
+ """
13
+
14
+ import os
15
+ import json
16
+ import logging
17
+ import urllib.request
18
+ import urllib.parse
19
+ from typing import Optional
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ GNEWS_KEY = os.environ.get("GNEWS_API_KEY", "")
24
+ FM_KEYWORDS = [
25
+ "earthquake", "hurricane", "typhoon", "cyclone", "tsunami", "volcano",
26
+ "flood", "wildfire", "war", "conflict", "martial law", "airport closed",
27
+ "travel ban", "pandemic", "lockdown", "curfew",
28
+ ]
29
+
30
+
31
+ def _gnews_search(city: str, country: str) -> list[dict]:
32
+ if not GNEWS_KEY:
33
+ return []
34
+ query = urllib.parse.quote(f"{city} {country} disaster OR emergency OR warning")
35
+ url = (
36
+ f"https://gnews.io/api/v4/search?q={query}"
37
+ f"&lang=en&max=5&apikey={GNEWS_KEY}"
38
+ )
39
+ try:
40
+ with urllib.request.urlopen(url, timeout=5) as resp:
41
+ return json.loads(resp.read()).get("articles", [])
42
+ except Exception as exc:
43
+ log.debug("GNews unavailable: %s", exc)
44
+ return []
45
+
46
+
47
+ def _reliefweb_search(country_iso2: str) -> list[dict]:
48
+ url = (
49
+ "https://api.reliefweb.int/v1/disasters?appname=bookhotel"
50
+ f"&filter[field]=country.iso3&filter[value]={country_iso2}"
51
+ "&filter[field]=status&filter[value]=current&limit=5"
52
+ )
53
+ try:
54
+ with urllib.request.urlopen(url, timeout=5) as resp:
55
+ data = json.loads(resp.read())
56
+ return data.get("data", [])
57
+ except Exception as exc:
58
+ log.debug("ReliefWeb unavailable: %s", exc)
59
+ return []
60
+
61
+
62
+ def assess(
63
+ city: str,
64
+ country: str,
65
+ country_iso2: Optional[str] = "",
66
+ ) -> dict:
67
+ """
68
+ Returns {severity, events, message}.
69
+ severity: "none" | "watch" | "warning" | "critical"
70
+ """
71
+ articles = _gnews_search(city, country)
72
+ rw_events = _reliefweb_search(country_iso2 or country)
73
+
74
+ triggered = []
75
+ for art in articles:
76
+ title = (art.get("title") or "").lower()
77
+ desc = (art.get("description") or "").lower()
78
+ for kw in FM_KEYWORDS:
79
+ if kw in title or kw in desc:
80
+ triggered.append({"source": "gnews", "keyword": kw, "title": art["title"]})
81
+ break
82
+
83
+ for evt in rw_events:
84
+ fields = evt.get("fields", {})
85
+ triggered.append({"source": "reliefweb", "name": fields.get("name"), "type": fields.get("type")})
86
+
87
+ if not triggered:
88
+ severity = "none"
89
+ message = "No active force-majeure events detected."
90
+ elif len(triggered) >= 3:
91
+ severity = "critical"
92
+ message = f"Multiple force-majeure events near {city}. Full waiver recommended."
93
+ elif len(triggered) >= 1:
94
+ severity = "watch"
95
+ message = f"Potential disruption near {city}. Monitor situation."
96
+ else:
97
+ severity = "none"
98
+ message = "No active force-majeure events."
99
+
100
+ return {"severity": severity, "events": triggered, "message": message}
services/force_majeure_service/relocation.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/force_majeure_service/relocation.py
3
+ ----------------------------------------------
4
+ Finds alternative hotels when a force-majeure event affects the original hotel.
5
+ Calls the search service to find available hotels in nearby cities.
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import logging
11
+ import urllib.request
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ SEARCH_SERVICE_URL = os.environ.get("SEARCH_SERVICE_URL", "http://localhost:8002")
16
+
17
+
18
+ def find_alternatives(
19
+ original_city: str,
20
+ check_in: str,
21
+ check_out: str,
22
+ guests: int = 1,
23
+ budget: float = 500.0,
24
+ stars: int = 3,
25
+ radius_km: int = 50,
26
+ ) -> list[dict]:
27
+ """
28
+ Calls the search service for nearby hotels and returns relocation options.
29
+ """
30
+ payload = json.dumps({
31
+ "city": original_city,
32
+ "check_in": check_in,
33
+ "check_out": check_out,
34
+ "guests": guests,
35
+ "max_price": budget,
36
+ "stars": stars,
37
+ "radius_km": radius_km,
38
+ "limit": 5,
39
+ }).encode()
40
+
41
+ try:
42
+ req = urllib.request.Request(
43
+ f"{SEARCH_SERVICE_URL}/search",
44
+ data=payload,
45
+ headers={"Content-Type": "application/json"},
46
+ method="POST",
47
+ )
48
+ with urllib.request.urlopen(req, timeout=5) as resp:
49
+ data = json.loads(resp.read())
50
+ return data.get("hotels", [])
51
+ except Exception as exc:
52
+ log.error("Search service unavailable for relocation: %s", exc)
53
+ return []
54
+
55
+
56
+ def build_relocation_message(
57
+ alternatives: list[dict],
58
+ original_ref: str,
59
+ ) -> str:
60
+ """Returns a human-readable relocation offer message."""
61
+ if not alternatives:
62
+ return (
63
+ f"We're sorry, no suitable alternatives are available right now. "
64
+ f"Your booking {original_ref} has been marked for full refund."
65
+ )
66
+ lines = [
67
+ f"Due to a force-majeure event, we're offering you a free move for booking {original_ref}.\n"
68
+ "Available alternatives:\n"
69
+ ]
70
+ for i, h in enumerate(alternatives[:3], 1):
71
+ name = h.get("name", "Hotel")
72
+ price = h.get("price_per_night", "—")
73
+ stars = "★" * int(h.get("stars", 3))
74
+ lines.append(f"{i}. {name} {stars} — ${price}/night")
75
+ lines.append("\nReply with the number to confirm or type REFUND for a full refund.")
76
+ return "\n".join(lines)
services/group_service/deposit_scheduler.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/group_service/deposit_scheduler.py
3
+ ---------------------------------------------
4
+ Schedules deposit reminders for group bookings via Celery.
5
+ 30% deposit at block creation → 70% balance due 30 days before event.
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime, timezone, timedelta
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ def schedule_deposits(
15
+ block_id: str,
16
+ total_amount: float,
17
+ date_from: str,
18
+ organiser_email: str,
19
+ ) -> dict:
20
+ """
21
+ Schedules Celery tasks to send deposit reminder emails.
22
+ Returns the schedule as a dict for logging/testing.
23
+ """
24
+ try:
25
+ from tasks.celery_app import celery_app
26
+ except ImportError:
27
+ log.warning("Celery not available; deposit schedule logged only")
28
+ celery_app = None
29
+
30
+ deposit_30 = round(total_amount * 0.30, 2)
31
+ balance_70 = round(total_amount * 0.70, 2)
32
+ event_date = datetime.strptime(date_from, "%Y-%m-%d").replace(tzinfo=timezone.utc)
33
+ balance_due = event_date - timedelta(days=30)
34
+ now = datetime.now(timezone.utc)
35
+
36
+ schedule = {
37
+ "deposit_30_usd": deposit_30,
38
+ "deposit_30_due": "immediately",
39
+ "balance_70_usd": balance_70,
40
+ "balance_70_due": balance_due.strftime("%Y-%m-%d"),
41
+ }
42
+
43
+ if celery_app:
44
+ eta_balance = balance_due if balance_due > now else now + timedelta(minutes=1)
45
+ celery_app.send_task(
46
+ "group_service.send_balance_reminder",
47
+ kwargs={
48
+ "block_id": block_id,
49
+ "amount": balance_70,
50
+ "organiser_email": organiser_email,
51
+ },
52
+ eta=eta_balance,
53
+ )
54
+
55
+ log.info("Deposit schedule for block %s: %s", block_id, schedule)
56
+ return schedule
services/group_service/main.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/group_service/main.py
3
+ --------------------------------
4
+ FastAPI group booking microservice.
5
+ """
6
+
7
+ from fastapi import FastAPI, HTTPException
8
+ from pydantic import BaseModel
9
+ from typing import Optional
10
+
11
+ from .room_block import create_block, pickup_room, get_block
12
+ from .deposit_scheduler import schedule_deposits
13
+
14
+ app = FastAPI(title="Group Service")
15
+
16
+
17
+ class BlockRequest(BaseModel):
18
+ hotel_id: str
19
+ room_type_id: str
20
+ rooms_reserved: int
21
+ date_from: str # YYYY-MM-DD
22
+ date_to: str # YYYY-MM-DD
23
+ group_name: str
24
+ pickup_deadline: str # YYYY-MM-DD
25
+ rate_per_night: float
26
+ organiser_email: str
27
+
28
+
29
+ class PickupRequest(BaseModel):
30
+ block_id: str
31
+ booking_id: str
32
+
33
+
34
+ @app.post("/block")
35
+ def create(req: BlockRequest):
36
+ if req.rooms_reserved < 1:
37
+ raise HTTPException(status_code=422, detail="rooms_reserved must be >= 1")
38
+
39
+ block = create_block(**req.dict())
40
+ total = req.rooms_reserved * req.rate_per_night * _nights(req.date_from, req.date_to)
41
+ schedule = schedule_deposits(
42
+ block_id=block["id"],
43
+ total_amount=total,
44
+ date_from=req.date_from,
45
+ organiser_email=req.organiser_email,
46
+ )
47
+ return {"block": block, "deposit_schedule": schedule}
48
+
49
+
50
+ @app.post("/pickup")
51
+ def pickup(req: PickupRequest):
52
+ try:
53
+ result = pickup_room(req.block_id, req.booking_id)
54
+ except ValueError as e:
55
+ raise HTTPException(status_code=409, detail=str(e))
56
+ return result
57
+
58
+
59
+ @app.get("/block/{block_id}")
60
+ def fetch_block(block_id: str):
61
+ try:
62
+ return get_block(block_id)
63
+ except ValueError as e:
64
+ raise HTTPException(status_code=404, detail=str(e))
65
+
66
+
67
+ @app.get("/health")
68
+ def health():
69
+ return {"status": "ok", "service": "group_service"}
70
+
71
+
72
+ def _nights(date_from: str, date_to: str) -> int:
73
+ from datetime import datetime
74
+ d1 = datetime.strptime(date_from, "%Y-%m-%d")
75
+ d2 = datetime.strptime(date_to, "%Y-%m-%d")
76
+ return max((d2 - d1).days, 1)
services/group_service/room_block.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/group_service/room_block.py
3
+ --------------------------------------
4
+ Manages group / rooming-list room blocks.
5
+ A room block reserves N rooms of a type from date_from to date_to,
6
+ with a pickup deadline and optional deposit schedule.
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import logging
12
+ from datetime import datetime, timezone
13
+ import urllib.request
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
18
+ SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
19
+
20
+
21
+ def _headers():
22
+ return {
23
+ "apikey": SUPABASE_KEY,
24
+ "Authorization": f"Bearer {SUPABASE_KEY}",
25
+ "Content-Type": "application/json",
26
+ "Prefer": "return=representation",
27
+ }
28
+
29
+
30
+ def _post(table: str, payload: dict) -> dict:
31
+ url = f"{SUPABASE_URL}/rest/v1/{table}"
32
+ data = json.dumps(payload).encode()
33
+ req = urllib.request.Request(url, data=data, headers=_headers(), method="POST")
34
+ with urllib.request.urlopen(req) as resp:
35
+ result = json.loads(resp.read())
36
+ return result[0] if isinstance(result, list) else result
37
+
38
+
39
+ def _get(path: str) -> list:
40
+ url = f"{SUPABASE_URL}/rest/v1/{path}"
41
+ req = urllib.request.Request(url, headers=_headers())
42
+ with urllib.request.urlopen(req) as resp:
43
+ return json.loads(resp.read())
44
+
45
+
46
+ def create_block(
47
+ hotel_id: str,
48
+ room_type_id: str,
49
+ rooms_reserved: int,
50
+ date_from: str,
51
+ date_to: str,
52
+ group_name: str,
53
+ pickup_deadline: str,
54
+ rate_per_night: float,
55
+ organiser_email: str,
56
+ ) -> dict:
57
+ block = _post("room_blocks", {
58
+ "hotel_id": hotel_id,
59
+ "room_type_id": room_type_id,
60
+ "rooms_reserved": rooms_reserved,
61
+ "rooms_picked_up": 0,
62
+ "date_from": date_from,
63
+ "date_to": date_to,
64
+ "group_name": group_name,
65
+ "pickup_deadline": pickup_deadline,
66
+ "rate_per_night": rate_per_night,
67
+ "organiser_email": organiser_email,
68
+ "status": "active",
69
+ "created_at": datetime.now(timezone.utc).isoformat(),
70
+ })
71
+ log.info("Room block created: %s (%d rooms)", group_name, rooms_reserved)
72
+ return block
73
+
74
+
75
+ def pickup_room(block_id: str, booking_id: str) -> dict:
76
+ """Marks one room from the block as picked up."""
77
+ blocks = _get(f"room_blocks?id=eq.{block_id}")
78
+ if not blocks:
79
+ raise ValueError(f"Block {block_id} not found")
80
+ block = blocks[0]
81
+ if block["rooms_picked_up"] >= block["rooms_reserved"]:
82
+ raise ValueError("All rooms already picked up")
83
+
84
+ url = f"{SUPABASE_URL}/rest/v1/room_blocks?id=eq.{block_id}"
85
+ data = json.dumps({
86
+ "rooms_picked_up": block["rooms_picked_up"] + 1,
87
+ "updated_at": datetime.now(timezone.utc).isoformat(),
88
+ }).encode()
89
+ req = urllib.request.Request(url, data=data, headers=_headers(), method="PATCH")
90
+ with urllib.request.urlopen(req) as resp:
91
+ result = json.loads(resp.read())
92
+ return result[0] if isinstance(result, list) else result
93
+
94
+
95
+ def get_block(block_id: str) -> dict:
96
+ rows = _get(f"room_blocks?id=eq.{block_id}")
97
+ if not rows:
98
+ raise ValueError(f"Block {block_id} not found")
99
+ return rows[0]
services/loyalty_service/gamification.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/loyalty_service/gamification.py
3
+ ------------------------------------------
4
+ Leaderboard and challenge badges using Redis sorted sets.
5
+
6
+ Leaderboard key : loyalty:leaderboard
7
+ Challenge keys : loyalty:challenge:{name}:{guest_id}
8
+ """
9
+
10
+ import os
11
+ import redis
12
+ import logging
13
+ from typing import Optional
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ _r = redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"), decode_responses=True)
18
+
19
+ LB_KEY = "loyalty:leaderboard"
20
+
21
+ BADGES = {
22
+ "first_booking": {"label": "First Booking", "points_bonus": 200},
23
+ "five_stays": {"label": "5 Stays", "points_bonus": 500},
24
+ "ten_stays": {"label": "10 Stays", "points_bonus": 1000},
25
+ "globe_trotter": {"label": "Globe Trotter", "points_bonus": 1500}, # 3+ cities
26
+ "loyalty_fan": {"label": "Loyalty Fan", "points_bonus": 300}, # app review submitted
27
+ "speed_booker": {"label": "Speed Booker", "points_bonus": 100}, # books within 5 min
28
+ }
29
+
30
+
31
+ def update_leaderboard(guest_id: str, total_points: int) -> None:
32
+ """Adds/updates the guest's score in the global leaderboard."""
33
+ _r.zadd(LB_KEY, {guest_id: total_points})
34
+
35
+
36
+ def top_guests(n: int = 10) -> list[dict]:
37
+ """Returns the top-N guests with their scores."""
38
+ entries = _r.zrevrange(LB_KEY, 0, n - 1, withscores=True)
39
+ return [{"guest_id": gid, "points": int(score)} for gid, score in entries]
40
+
41
+
42
+ def get_rank(guest_id: str) -> Optional[int]:
43
+ """Returns 1-based rank of the guest (None if not in leaderboard)."""
44
+ rank = _r.zrevrank(LB_KEY, guest_id)
45
+ return None if rank is None else rank + 1
46
+
47
+
48
+ def award_badge(guest_id: str, badge_name: str) -> dict:
49
+ """
50
+ Marks a badge as earned for the guest.
51
+ Returns {badge, points_bonus, already_earned}.
52
+ """
53
+ key = f"loyalty:badge:{badge_name}:{guest_id}"
54
+ already = _r.exists(key)
55
+ if already:
56
+ return {"badge": badge_name, "points_bonus": 0, "already_earned": True}
57
+
58
+ _r.set(key, "1")
59
+ bonus = BADGES.get(badge_name, {}).get("points_bonus", 0)
60
+ log.info("Badge '%s' awarded to guest %s (+%d pts)", badge_name, guest_id, bonus)
61
+ return {"badge": badge_name, "points_bonus": bonus, "already_earned": False}
62
+
63
+
64
+ def list_badges(guest_id: str) -> list[str]:
65
+ """Returns list of badge names earned by the guest."""
66
+ earned = []
67
+ for badge_name in BADGES:
68
+ key = f"loyalty:badge:{badge_name}:{guest_id}"
69
+ if _r.exists(key):
70
+ earned.append(badge_name)
71
+ return earned
services/loyalty_service/main.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/loyalty_service/main.py
3
+ ----------------------------------
4
+ FastAPI loyalty microservice.
5
+ """
6
+
7
+ from fastapi import FastAPI, HTTPException
8
+ from pydantic import BaseModel
9
+ from typing import Optional
10
+
11
+ from .points_engine import award, redeem, get_balance
12
+ from .tier_engine import evaluate as evaluate_tier, get_perks, next_tier_info
13
+ from .gamification import update_leaderboard, top_guests, get_rank, award_badge, list_badges
14
+
15
+ app = FastAPI(title="Loyalty Service")
16
+
17
+
18
+ class AwardRequest(BaseModel):
19
+ guest_id: str
20
+ amount_usd: float
21
+ booking_id: str
22
+
23
+
24
+ class RedeemRequest(BaseModel):
25
+ guest_id: str
26
+ points_to_redeem: int
27
+ booking_id: str
28
+
29
+
30
+ class BadgeRequest(BaseModel):
31
+ guest_id: str
32
+ badge_name: str
33
+
34
+
35
+ @app.post("/award")
36
+ def award_points(req: AwardRequest):
37
+ result = award(req.guest_id, req.amount_usd, req.booking_id)
38
+ balance = get_balance(req.guest_id)
39
+ tier = evaluate_tier(balance["points_balance"])
40
+ update_leaderboard(req.guest_id, balance["points_balance"])
41
+ return {**result, "balance": balance, "tier": tier}
42
+
43
+
44
+ @app.post("/redeem")
45
+ def redeem_points(req: RedeemRequest):
46
+ try:
47
+ result = redeem(req.guest_id, req.points_to_redeem, req.booking_id)
48
+ except ValueError as e:
49
+ raise HTTPException(status_code=409, detail=str(e))
50
+ update_leaderboard(req.guest_id, result["remaining_balance"])
51
+ return result
52
+
53
+
54
+ @app.get("/balance/{guest_id}")
55
+ def balance(guest_id: str):
56
+ bal = get_balance(guest_id)
57
+ tier = evaluate_tier(bal["points_balance"])
58
+ next_t = next_tier_info(bal["points_balance"])
59
+ badges = list_badges(guest_id)
60
+ rank = get_rank(guest_id)
61
+ return {**bal, "tier": tier, "next_tier_info": next_t, "badges": badges, "leaderboard_rank": rank}
62
+
63
+
64
+ @app.get("/perks/{tier}")
65
+ def perks(tier: str):
66
+ return get_perks(tier)
67
+
68
+
69
+ @app.post("/badge")
70
+ def badge(req: BadgeRequest):
71
+ return award_badge(req.guest_id, req.badge_name)
72
+
73
+
74
+ @app.get("/leaderboard")
75
+ def leaderboard(n: int = 10):
76
+ return {"leaderboard": top_guests(n)}
77
+
78
+
79
+ @app.get("/health")
80
+ def health():
81
+ return {"status": "ok", "service": "loyalty_service"}
services/loyalty_service/points_engine.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/loyalty_service/points_engine.py
3
+ -------------------------------------------
4
+ Awards and redeems loyalty points.
5
+
6
+ Earning rule : 10 points per USD spent
7
+ Redemption : 100 points = $1 discount
8
+ Expiry : Points expire after 365 days (handled by scheduled task)
9
+ """
10
+
11
+ import os
12
+ import json
13
+ import logging
14
+ from datetime import datetime, timezone, timedelta
15
+
16
+ import urllib.request
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
21
+ SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
22
+
23
+ POINTS_PER_USD = 10
24
+ POINTS_PER_DOLLAR = 100 # redemption rate
25
+
26
+
27
+ def _headers():
28
+ return {
29
+ "apikey": SUPABASE_KEY,
30
+ "Authorization": f"Bearer {SUPABASE_KEY}",
31
+ "Content-Type": "application/json",
32
+ "Prefer": "return=representation",
33
+ }
34
+
35
+
36
+ def _rpc(fn: str, args: dict) -> dict:
37
+ url = f"{SUPABASE_URL}/rest/v1/rpc/{fn}"
38
+ data = json.dumps(args).encode()
39
+ req = urllib.request.Request(url, data=data, headers=_headers(), method="POST")
40
+ with urllib.request.urlopen(req) as resp:
41
+ return json.loads(resp.read())
42
+
43
+
44
+ def _get(path: str) -> list:
45
+ url = f"{SUPABASE_URL}/rest/v1/{path}"
46
+ req = urllib.request.Request(url, headers=_headers())
47
+ with urllib.request.urlopen(req) as resp:
48
+ return json.loads(resp.read())
49
+
50
+
51
+ def _patch(path: str, payload: dict) -> list:
52
+ url = f"{SUPABASE_URL}/rest/v1/{path}"
53
+ data = json.dumps(payload).encode()
54
+ req = urllib.request.Request(url, data=data, headers=_headers(), method="PATCH")
55
+ with urllib.request.urlopen(req) as resp:
56
+ return json.loads(resp.read())
57
+
58
+
59
+ def _insert(table: str, payload: dict) -> dict:
60
+ url = f"{SUPABASE_URL}/rest/v1/{table}"
61
+ data = json.dumps(payload).encode()
62
+ req = urllib.request.Request(url, data=data, headers=_headers(), method="POST")
63
+ with urllib.request.urlopen(req) as resp:
64
+ result = json.loads(resp.read())
65
+ return result[0] if isinstance(result, list) else result
66
+
67
+
68
+ def award(guest_id: str, amount_usd: float, booking_id: str) -> dict:
69
+ """Awards points for a completed booking."""
70
+ points_earned = int(amount_usd * POINTS_PER_USD)
71
+ if points_earned <= 0:
72
+ return {"awarded": 0}
73
+
74
+ # Upsert loyalty_accounts
75
+ rows = _get(f"loyalty_accounts?guest_id=eq.{guest_id}")
76
+ expires = (datetime.now(timezone.utc) + timedelta(days=365)).isoformat()
77
+
78
+ if rows:
79
+ acc = rows[0]
80
+ new_pts = acc["points_balance"] + points_earned
81
+ _patch(f"loyalty_accounts?guest_id=eq.{guest_id}", {"points_balance": new_pts})
82
+ else:
83
+ _insert("loyalty_accounts", {
84
+ "guest_id": guest_id,
85
+ "points_balance": points_earned,
86
+ "tier": "bronze",
87
+ })
88
+
89
+ # Record transaction
90
+ _insert("loyalty_transactions", {
91
+ "guest_id": guest_id,
92
+ "booking_id": booking_id,
93
+ "points": points_earned,
94
+ "type": "earn",
95
+ "expires_at": expires,
96
+ "created_at": datetime.now(timezone.utc).isoformat(),
97
+ })
98
+
99
+ log.info("Awarded %d points to guest %s for booking %s", points_earned, guest_id, booking_id)
100
+ return {"awarded": points_earned}
101
+
102
+
103
+ def redeem(guest_id: str, points_to_redeem: int, booking_id: str) -> dict:
104
+ """
105
+ Redeems points for a discount. Returns {discount_usd, remaining_balance}.
106
+ Raises ValueError if insufficient balance.
107
+ """
108
+ rows = _get(f"loyalty_accounts?guest_id=eq.{guest_id}")
109
+ if not rows or rows[0]["points_balance"] < points_to_redeem:
110
+ raise ValueError("Insufficient loyalty points")
111
+
112
+ acc = rows[0]
113
+ new_pts = acc["points_balance"] - points_to_redeem
114
+ discount_usd = round(points_to_redeem / POINTS_PER_DOLLAR, 2)
115
+
116
+ _patch(f"loyalty_accounts?guest_id=eq.{guest_id}", {"points_balance": new_pts})
117
+
118
+ _insert("loyalty_transactions", {
119
+ "guest_id": guest_id,
120
+ "booking_id": booking_id,
121
+ "points": -points_to_redeem,
122
+ "type": "redeem",
123
+ "created_at": datetime.now(timezone.utc).isoformat(),
124
+ })
125
+
126
+ return {"discount_usd": discount_usd, "remaining_balance": new_pts}
127
+
128
+
129
+ def get_balance(guest_id: str) -> dict:
130
+ rows = _get(f"loyalty_accounts?guest_id=eq.{guest_id}")
131
+ if not rows:
132
+ return {"points_balance": 0, "tier": "bronze"}
133
+ acc = rows[0]
134
+ return {
135
+ "points_balance": acc.get("points_balance", 0),
136
+ "tier": acc.get("tier", "bronze"),
137
+ "discount_usd": round(acc.get("points_balance", 0) / POINTS_PER_DOLLAR, 2),
138
+ }
services/loyalty_service/tier_engine.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/loyalty_service/tier_engine.py
3
+ -----------------------------------------
4
+ Upgrades / downgrades loyalty tier based on lifetime points.
5
+
6
+ Tiers:
7
+ Bronze : 0 – 4 999 points
8
+ Silver : 5 000 – 19 999 points
9
+ Gold : 20 000 – 49 999 points
10
+ Platinum: 50 000+ points
11
+ """
12
+
13
+ TIER_THRESHOLDS = [
14
+ ("platinum", 50_000),
15
+ ("gold", 20_000),
16
+ ("silver", 5_000),
17
+ ("bronze", 0),
18
+ ]
19
+
20
+ TIER_PERKS = {
21
+ "bronze": {"late_checkout": False, "lounge_access": False, "discount_pct": 0},
22
+ "silver": {"late_checkout": False, "lounge_access": False, "discount_pct": 5},
23
+ "gold": {"late_checkout": True, "lounge_access": False, "discount_pct": 10},
24
+ "platinum": {"late_checkout": True, "lounge_access": True, "discount_pct": 15},
25
+ }
26
+
27
+
28
+ def evaluate(lifetime_points: int) -> str:
29
+ """Returns the tier name for the given lifetime points total."""
30
+ for tier_name, threshold in TIER_THRESHOLDS:
31
+ if lifetime_points >= threshold:
32
+ return tier_name
33
+ return "bronze"
34
+
35
+
36
+ def get_perks(tier: str) -> dict:
37
+ return TIER_PERKS.get(tier, TIER_PERKS["bronze"])
38
+
39
+
40
+ def next_tier_info(lifetime_points: int) -> dict:
41
+ """Returns how many points away the guest is from the next tier."""
42
+ current = evaluate(lifetime_points)
43
+ tiers = [t for t, _ in TIER_THRESHOLDS]
44
+ idx = tiers.index(current)
45
+
46
+ if idx == 0:
47
+ return {"next_tier": None, "points_needed": 0, "current_tier": current}
48
+
49
+ next_tier_name, next_threshold = TIER_THRESHOLDS[idx - 1]
50
+ return {
51
+ "current_tier": current,
52
+ "next_tier": next_tier_name,
53
+ "points_needed": next_threshold - lifetime_points,
54
+ }
services/maps_service/nominatim.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/maps_service/nominatim.py
3
+ ------------------------------------
4
+ Geocoding + reverse geocoding via OpenStreetMap Nominatim.
5
+ No API key required. Rate limited to 1 req/s per OSM policy.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import time
11
+ import urllib.request
12
+ import urllib.parse
13
+ from typing import Optional
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ NOMINATIM_BASE = "https://nominatim.openstreetmap.org"
18
+ USER_AGENT = "BookHotelBot/1.0 (contact@bookhotel.ai)"
19
+ _last_call = 0.0 # timestamp of last request (rate limit)
20
+
21
+
22
+ def _request(url: str) -> dict:
23
+ global _last_call
24
+ elapsed = time.time() - _last_call
25
+ if elapsed < 1.1:
26
+ time.sleep(1.1 - elapsed)
27
+
28
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
29
+ with urllib.request.urlopen(req, timeout=10) as resp:
30
+ _last_call = time.time()
31
+ return json.loads(resp.read())
32
+
33
+
34
+ def geocode(query: str) -> Optional[dict]:
35
+ """
36
+ Forward geocoding.
37
+ Returns {lat, lon, display_name, country_code} or None.
38
+ """
39
+ params = urllib.parse.urlencode({
40
+ "q": query,
41
+ "format": "json",
42
+ "limit": 1,
43
+ "addressdetails": 1,
44
+ })
45
+ url = f"{NOMINATIM_BASE}/search?{params}"
46
+ try:
47
+ results = _request(url)
48
+ if results:
49
+ r = results[0]
50
+ addr = r.get("address", {})
51
+ return {
52
+ "lat": float(r["lat"]),
53
+ "lon": float(r["lon"]),
54
+ "display_name": r["display_name"],
55
+ "country_code": addr.get("country_code", "").upper(),
56
+ "city": addr.get("city") or addr.get("town") or addr.get("village", ""),
57
+ "country": addr.get("country", ""),
58
+ }
59
+ except Exception as exc:
60
+ log.error("Nominatim geocode error: %s", exc)
61
+ return None
62
+
63
+
64
+ def reverse_geocode(lat: float, lon: float) -> Optional[dict]:
65
+ """
66
+ Reverse geocoding.
67
+ Returns {display_name, city, country, country_code} or None.
68
+ """
69
+ params = urllib.parse.urlencode({
70
+ "lat": lat,
71
+ "lon": lon,
72
+ "format": "json",
73
+ "addressdetails": 1,
74
+ })
75
+ url = f"{NOMINATIM_BASE}/reverse?{params}"
76
+ try:
77
+ r = _request(url)
78
+ addr = r.get("address", {})
79
+ return {
80
+ "display_name": r.get("display_name", ""),
81
+ "city": addr.get("city") or addr.get("town") or addr.get("village", ""),
82
+ "country": addr.get("country", ""),
83
+ "country_code": addr.get("country_code", "").upper(),
84
+ }
85
+ except Exception as exc:
86
+ log.error("Nominatim reverse geocode error: %s", exc)
87
+ return None
88
+
89
+
90
+ def distance_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
91
+ """Haversine distance in km between two coordinates."""
92
+ from math import radians, sin, cos, sqrt, atan2
93
+ R = 6371.0
94
+ dlat = radians(lat2 - lat1)
95
+ dlon = radians(lon2 - lon1)
96
+ a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
97
+ return R * 2 * atan2(sqrt(a), sqrt(1 - a))
services/notification_service/main.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/notification_service/main.py
3
+ ---------------------------------------
4
+ FastAPI unified notification microservice.
5
+ Routes to email / WhatsApp / push depending on the channel field.
6
+ """
7
+
8
+ from fastapi import FastAPI
9
+ from pydantic import BaseModel
10
+ from typing import Optional, List
11
+
12
+ from .email_sender import send as send_email
13
+ from .whatsapp_sender import send_template as send_wa_template, send_text as send_wa_text
14
+ from .push_sender import send as send_push
15
+
16
+ app = FastAPI(title="Notification Service")
17
+
18
+
19
+ class NotifyRequest(BaseModel):
20
+ channel: str # "email" | "whatsapp" | "push" | "all"
21
+ booking_ref: Optional[str] = None
22
+
23
+ # Email
24
+ to_email: Optional[str] = None
25
+ to_name: Optional[str] = None
26
+ email_subject: Optional[str] = None
27
+ email_template: Optional[str] = "booking_confirmation.html"
28
+ template_ctx: Optional[dict] = None
29
+
30
+ # WhatsApp
31
+ wa_phone: Optional[str] = None
32
+ wa_template: Optional[str] = "booking_confirmation"
33
+ wa_language: Optional[str] = "en_US"
34
+ wa_components: Optional[list] = None
35
+
36
+ # Push
37
+ push_subscription: Optional[dict] = None
38
+ push_title: Optional[str] = None
39
+ push_body: Optional[str] = None
40
+ push_url: Optional[str] = None
41
+
42
+
43
+ @app.post("/notify")
44
+ def notify(req: NotifyRequest):
45
+ results = {}
46
+
47
+ ctx = req.template_ctx or {}
48
+ if req.booking_ref:
49
+ ctx.setdefault("booking_ref", req.booking_ref)
50
+
51
+ # Email
52
+ if req.channel in ("email", "all") and req.to_email:
53
+ results["email"] = send_email(
54
+ to_email=req.to_email,
55
+ subject=req.email_subject or f"Booking Confirmation – {req.booking_ref}",
56
+ template=req.email_template,
57
+ context=ctx,
58
+ to_name=req.to_name,
59
+ )
60
+
61
+ # WhatsApp
62
+ if req.channel in ("whatsapp", "all") and req.wa_phone:
63
+ results["whatsapp"] = send_wa_template(
64
+ to=req.wa_phone,
65
+ template_name=req.wa_template,
66
+ language=req.wa_language,
67
+ components=req.wa_components,
68
+ )
69
+
70
+ # Push
71
+ if req.channel in ("push", "all") and req.push_subscription:
72
+ results["push"] = send_push(
73
+ subscription_info=req.push_subscription,
74
+ title=req.push_title or "Booking Update",
75
+ body=req.push_body or ctx.get("message", ""),
76
+ url=req.push_url,
77
+ )
78
+
79
+ return {"results": results}
80
+
81
+
82
+ @app.get("/health")
83
+ def health():
84
+ return {"status": "ok", "service": "notification_service"}
services/review_service/celery_tasks.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/review_service/celery_tasks.py
3
+ -----------------------------------------
4
+ Celery tasks for sending post-stay review requests.
5
+ Triggered 24 h after checkout via a Celery beat schedule.
6
+ """
7
+
8
+ from celery import shared_task
9
+ from datetime import datetime, timezone
10
+ import os
11
+ import json
12
+ import logging
13
+ import urllib.request
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
18
+ SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
19
+ NOTIFY_URL = os.environ.get("NOTIFICATION_SERVICE_URL", "http://localhost:8007")
20
+
21
+
22
+ def _headers():
23
+ return {
24
+ "apikey": SUPABASE_KEY,
25
+ "Authorization": f"Bearer {SUPABASE_KEY}",
26
+ "Content-Type": "application/json",
27
+ }
28
+
29
+
30
+ @shared_task(name="review_service.send_review_request", bind=True, max_retries=3)
31
+ def send_review_request(self, booking_id: str):
32
+ """
33
+ Fetches booking info and sends review request email + Messenger message.
34
+ """
35
+ try:
36
+ url = f"{SUPABASE_URL}/rest/v1/bookings?id=eq.{booking_id}&select=*,guests(*)"
37
+ req = urllib.request.Request(url, headers=_headers())
38
+ with urllib.request.urlopen(req) as resp:
39
+ rows = json.loads(resp.read())
40
+
41
+ if not rows:
42
+ log.warning("Review task: booking %s not found", booking_id)
43
+ return
44
+
45
+ booking = rows[0]
46
+ guest = (booking.get("guests") or [{}])[0]
47
+ ctx = {
48
+ "guest_name": guest.get("first_name", "Guest"),
49
+ "hotel_name": booking.get("hotel_id"),
50
+ "booking_ref": booking.get("ref"),
51
+ "review_url": f"https://bookhotel.ai/review/{booking.get('ref')}",
52
+ }
53
+
54
+ if guest.get("email"):
55
+ payload = json.dumps({
56
+ "channel": "email",
57
+ "to_email": guest["email"],
58
+ "to_name": guest.get("first_name"),
59
+ "email_subject": "How was your stay? Leave a review",
60
+ "email_template": "post_stay_review_request.html",
61
+ "template_ctx": ctx,
62
+ }).encode()
63
+ notify_req = urllib.request.Request(
64
+ f"{NOTIFY_URL}/notify",
65
+ data=payload,
66
+ headers={"Content-Type": "application/json"},
67
+ method="POST",
68
+ )
69
+ urllib.request.urlopen(notify_req)
70
+
71
+ log.info("Review request sent for booking %s", booking_id)
72
+
73
+ except Exception as exc:
74
+ log.error("Review task failed: %s", exc)
75
+ raise self.retry(exc=exc, countdown=300)
services/review_service/main.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/review_service/main.py
3
+ ---------------------------------
4
+ FastAPI review microservice.
5
+ Accepts guest reviews, runs sentiment analysis, stores in Supabase,
6
+ and queues a task to notify the hotel.
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import logging
12
+ from datetime import datetime, timezone
13
+ import urllib.request
14
+
15
+ from fastapi import FastAPI, HTTPException
16
+ from pydantic import BaseModel
17
+ from typing import Optional
18
+
19
+ from .sentiment import analyse
20
+
21
+ log = logging.getLogger(__name__)
22
+ app = FastAPI(title="Review Service")
23
+
24
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
25
+ SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
26
+
27
+
28
+ def _headers():
29
+ return {
30
+ "apikey": SUPABASE_KEY,
31
+ "Authorization": f"Bearer {SUPABASE_KEY}",
32
+ "Content-Type": "application/json",
33
+ "Prefer": "return=representation",
34
+ }
35
+
36
+
37
+ def _insert(table: str, payload: dict) -> dict:
38
+ url = f"{SUPABASE_URL}/rest/v1/{table}"
39
+ data = json.dumps(payload).encode()
40
+ req = urllib.request.Request(url, data=data, headers=_headers(), method="POST")
41
+ with urllib.request.urlopen(req) as resp:
42
+ result = json.loads(resp.read())
43
+ return result[0] if isinstance(result, list) else result
44
+
45
+
46
+ class ReviewRequest(BaseModel):
47
+ booking_id: str
48
+ guest_id: str
49
+ hotel_id: str
50
+ rating: int # 1-5
51
+ text: str
52
+ language: Optional[str] = "en"
53
+
54
+
55
+ @app.post("/review")
56
+ def submit_review(req: ReviewRequest):
57
+ if not 1 <= req.rating <= 5:
58
+ raise HTTPException(status_code=422, detail="Rating must be 1-5")
59
+
60
+ sentiment = analyse(req.text)
61
+
62
+ review = _insert("reviews", {
63
+ "booking_id": req.booking_id,
64
+ "guest_id": req.guest_id,
65
+ "hotel_id": req.hotel_id,
66
+ "rating": req.rating,
67
+ "text": req.text,
68
+ "language": req.language,
69
+ "sentiment": sentiment["sentiment"],
70
+ "created_at": datetime.now(timezone.utc).isoformat(),
71
+ })
72
+
73
+ return {"review_id": review.get("id"), "sentiment": sentiment}
74
+
75
+
76
+ @app.get("/reviews/{hotel_id}")
77
+ def list_reviews(hotel_id: str, limit: int = 20, offset: int = 0):
78
+ url = (
79
+ f"{SUPABASE_URL}/rest/v1/reviews"
80
+ f"?hotel_id=eq.{hotel_id}&order=created_at.desc"
81
+ f"&limit={limit}&offset={offset}"
82
+ )
83
+ req = urllib.request.Request(url, headers=_headers())
84
+ with urllib.request.urlopen(req) as resp:
85
+ return json.loads(resp.read())
86
+
87
+
88
+ @app.get("/health")
89
+ def health():
90
+ return {"status": "ok", "service": "review_service"}
services/review_service/sentiment.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/review_service/sentiment.py
3
+ --------------------------------------
4
+ Analyses the sentiment of a review text.
5
+ Uses a simple VADER-based approach (no GPU required) with a
6
+ transformers fallback if the BERT model is available.
7
+ """
8
+
9
+ import logging
10
+ from typing import Optional
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ def analyse(text: str) -> dict:
16
+ """
17
+ Returns:
18
+ sentiment str "positive" | "neutral" | "negative"
19
+ score float -1.0 to 1.0
20
+ stars int 1-5
21
+ """
22
+ # Try transformers sentiment pipeline first
23
+ try:
24
+ from transformers import pipeline as hf_pipeline
25
+ _clf = hf_pipeline(
26
+ "sentiment-analysis",
27
+ model="distilbert-base-uncased-finetuned-sst-2-english",
28
+ truncation=True,
29
+ )
30
+ result = _clf(text[:512])[0]
31
+ label = result["label"].lower() # "positive" | "negative"
32
+ conf = result["score"]
33
+ score = conf if label == "positive" else -conf
34
+ stars = _score_to_stars(score)
35
+ sentiment = "positive" if score > 0.1 else ("negative" if score < -0.1 else "neutral")
36
+ return {"sentiment": sentiment, "score": round(score, 3), "stars": stars}
37
+ except Exception as exc:
38
+ log.debug("Transformers sentiment unavailable: %s – falling back to VADER", exc)
39
+
40
+ # VADER fallback
41
+ try:
42
+ from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
43
+ analyser = SentimentIntensityAnalyzer()
44
+ vs = analyser.polarity_scores(text)
45
+ score = vs["compound"]
46
+ if score >= 0.05:
47
+ sentiment = "positive"
48
+ elif score <= -0.05:
49
+ sentiment = "negative"
50
+ else:
51
+ sentiment = "neutral"
52
+ return {"sentiment": sentiment, "score": round(score, 3), "stars": _score_to_stars(score)}
53
+ except ImportError:
54
+ pass
55
+
56
+ # Bare keyword fallback
57
+ positive_words = {"excellent", "great", "amazing", "love", "perfect", "wonderful", "fantastic"}
58
+ negative_words = {"terrible", "awful", "horrible", "worst", "bad", "dirty", "rude", "disappointing"}
59
+ tokens = set(text.lower().split())
60
+ pos = len(tokens & positive_words)
61
+ neg = len(tokens & negative_words)
62
+ if pos > neg:
63
+ return {"sentiment": "positive", "score": 0.5, "stars": 4}
64
+ if neg > pos:
65
+ return {"sentiment": "negative", "score": -0.5, "stars": 2}
66
+ return {"sentiment": "neutral", "score": 0.0, "stars": 3}
67
+
68
+
69
+ def _score_to_stars(score: float) -> int:
70
+ if score >= 0.6:
71
+ return 5
72
+ if score >= 0.2:
73
+ return 4
74
+ if score >= -0.2:
75
+ return 3
76
+ if score >= -0.6:
77
+ return 2
78
+ return 1
services/voice_service/audio_utils.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/voice_service/audio_utils.py
3
+ ----------------------------------------
4
+ Audio pre-processing helpers shared by STT and TTS modules.
5
+ """
6
+
7
+ import io
8
+ import logging
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def convert_to_wav(audio_bytes: bytes, src_format: str = "ogg") -> bytes:
14
+ """
15
+ Converts audio to 16-bit PCM WAV at 16 000 Hz mono.
16
+ Uses pydub + ffmpeg. Returns raw WAV bytes.
17
+ """
18
+ try:
19
+ from pydub import AudioSegment
20
+ except ImportError:
21
+ raise RuntimeError("pydub is required: pip install pydub")
22
+
23
+ buf = io.BytesIO(audio_bytes)
24
+ seg = AudioSegment.from_file(buf, format=src_format)
25
+ seg = seg.set_channels(1).set_frame_rate(16000).set_sample_width(2)
26
+
27
+ out = io.BytesIO()
28
+ seg.export(out, format="wav")
29
+ return out.getvalue()
30
+
31
+
32
+ def denoise(audio_bytes: bytes, sample_rate: int = 16000) -> bytes:
33
+ """
34
+ Applies noisereduce to WAV bytes.
35
+ Returns denoised WAV bytes.
36
+ """
37
+ try:
38
+ import numpy as np
39
+ import noisereduce as nr
40
+ import scipy.io.wavfile as wav_io
41
+ except ImportError:
42
+ log.debug("noisereduce not installed; skipping denoising")
43
+ return audio_bytes
44
+
45
+ buf = io.BytesIO(audio_bytes)
46
+ sr, data = wav_io.read(buf)
47
+ reduced = nr.reduce_noise(y=data.astype(float), sr=sr)
48
+ out = io.BytesIO()
49
+ wav_io.write(out, sr, reduced.astype(np.int16))
50
+ return out.getvalue()
51
+
52
+
53
+ def audio_bytes_to_base64(audio_bytes: bytes) -> str:
54
+ import base64
55
+ return base64.b64encode(audio_bytes).decode()
services/voice_service/main.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/voice_service/main.py
3
+ ---------------------------------
4
+ FastAPI voice microservice (STT + TTS).
5
+ """
6
+
7
+ import base64
8
+ from fastapi import FastAPI, UploadFile, File
9
+ from fastapi.responses import Response
10
+ from pydantic import BaseModel
11
+ from typing import Optional
12
+
13
+ from .audio_utils import convert_to_wav, denoise
14
+ from .stt import transcribe
15
+ from .tts import synthesize
16
+
17
+ app = FastAPI(title="Voice Service")
18
+
19
+
20
+ class TTSRequest(BaseModel):
21
+ text: str
22
+ language: Optional[str] = "en"
23
+
24
+
25
+ @app.post("/stt")
26
+ async def speech_to_text(
27
+ file: UploadFile = File(...),
28
+ language: Optional[str] = None,
29
+ denoise_audio: bool = True,
30
+ ):
31
+ raw = await file.read()
32
+ fmt = (file.content_type or "audio/ogg").split("/")[-1].split(";")[0]
33
+
34
+ wav = convert_to_wav(raw, src_format=fmt)
35
+ if denoise_audio:
36
+ wav = denoise(wav)
37
+
38
+ result = transcribe(wav, language=language)
39
+ return result
40
+
41
+
42
+ @app.post("/tts")
43
+ def text_to_speech(req: TTSRequest):
44
+ audio = synthesize(req.text, req.language)
45
+ return {"audio_b64": base64.b64encode(audio).decode(), "format": "wav"}
46
+
47
+
48
+ @app.get("/tts/stream")
49
+ def tts_stream(text: str, language: str = "en"):
50
+ audio = synthesize(text, language)
51
+ return Response(
52
+ content=audio,
53
+ media_type="audio/wav",
54
+ headers={"Content-Disposition": "inline; filename=speech.wav"},
55
+ )
56
+
57
+
58
+ @app.get("/health")
59
+ def health():
60
+ return {"status": "ok", "service": "voice_service"}
services/voice_service/stt.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/voice_service/stt.py
3
+ --------------------------------
4
+ Speech-to-text using faster-whisper (local) with a HuggingFace Space fallback.
5
+ """
6
+
7
+ import io
8
+ import os
9
+ import logging
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ HF_STT_URL = os.environ.get("HF_STT_URL", "")
14
+ WHISPER_SIZE = os.environ.get("WHISPER_MODEL_SIZE", "small")
15
+
16
+ _whisper_model = None
17
+
18
+
19
+ def _get_model():
20
+ global _whisper_model
21
+ if _whisper_model is None:
22
+ try:
23
+ from faster_whisper import WhisperModel
24
+ _whisper_model = WhisperModel(WHISPER_SIZE, device="cpu", compute_type="int8")
25
+ log.info("Whisper %s loaded", WHISPER_SIZE)
26
+ except Exception as exc:
27
+ log.error("Could not load Whisper: %s", exc)
28
+ return _whisper_model
29
+
30
+
31
+ def transcribe(audio_bytes: bytes, language: str = None) -> dict:
32
+ """
33
+ Transcribes audio bytes (WAV 16 kHz mono) and returns
34
+ {text, language, confidence}.
35
+ Falls back to HF Space if local model unavailable.
36
+ """
37
+ # Local model path
38
+ model = _get_model()
39
+ if model:
40
+ try:
41
+ import numpy as np
42
+ import scipy.io.wavfile as wav_io
43
+ sr, data = wav_io.read(io.BytesIO(audio_bytes))
44
+ audio_np = data.astype(np.float32) / 32768.0
45
+ segs, info = model.transcribe(
46
+ audio_np,
47
+ beam_size=5,
48
+ language=language,
49
+ vad_filter=True,
50
+ )
51
+ text = " ".join(s.text for s in segs).strip()
52
+ return {"text": text, "language": info.language, "confidence": info.language_probability}
53
+ except Exception as exc:
54
+ log.warning("Local Whisper failed: %s", exc)
55
+
56
+ # HF Space fallback
57
+ if HF_STT_URL:
58
+ try:
59
+ import urllib.request
60
+ import json
61
+ import base64
62
+ payload = json.dumps({"audio_b64": base64.b64encode(audio_bytes).decode(), "language": language}).encode()
63
+ req = urllib.request.Request(
64
+ f"{HF_STT_URL}/transcribe",
65
+ data=payload,
66
+ headers={"Content-Type": "application/json"},
67
+ method="POST",
68
+ )
69
+ with urllib.request.urlopen(req, timeout=30) as resp:
70
+ return json.loads(resp.read())
71
+ except Exception as exc:
72
+ log.error("HF STT fallback failed: %s", exc)
73
+
74
+ return {"text": "", "language": language or "en", "confidence": 0.0}
services/voice_service/tts.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ services/voice_service/tts.py
3
+ --------------------------------
4
+ Text-to-speech using SpeechT5 (English) / MMS-TTS (multilingual) locally,
5
+ with a HuggingFace Space fallback.
6
+ Redis-caches audio by md5(text+lang) for 24 h.
7
+ """
8
+
9
+ import os
10
+ import io
11
+ import hashlib
12
+ import logging
13
+ from typing import Optional
14
+
15
+ import redis
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")
20
+ HF_TTS_URL = os.environ.get("HF_TTS_URL", "")
21
+
22
+ _r = redis.from_url(REDIS_URL, decode_responses=False)
23
+
24
+ MMS_LANG_MIN = {
25
+ "en": "eng", "hi": "hin", "te": "tel", "ta": "tam",
26
+ "fr": "fra", "es": "spa", "ar": "ara", "de": "deu",
27
+ "zh": "zho", "ja": "jpn", "ko": "kor", "pt": "por",
28
+ }
29
+
30
+
31
+ def synthesize(text: str, language: str = "en") -> bytes:
32
+ """
33
+ Returns MP3 audio bytes.
34
+ Checks Redis cache first; generates and stores if not cached.
35
+ """
36
+ cache_key = "tts:" + hashlib.md5(f"{language}:{text}".encode()).hexdigest()
37
+ cached = _r.get(cache_key)
38
+ if cached:
39
+ return cached
40
+
41
+ audio = _generate(text, language)
42
+ if audio:
43
+ _r.setex(cache_key, 86400, audio)
44
+ return audio or b""
45
+
46
+
47
+ def _generate(text: str, language: str) -> Optional[bytes]:
48
+ """Tries local model first, then HF Space."""
49
+ # Local MMS-TTS (multilingual) -----------------------------------------
50
+ lang_code = MMS_LANG_MIN.get(language[:2], "eng")
51
+ if language[:2] == "en":
52
+ audio = _speecht5(text)
53
+ if audio:
54
+ return audio
55
+
56
+ audio = _mms_tts(text, lang_code)
57
+ if audio:
58
+ return audio
59
+
60
+ # HF Space fallback -------------------------------------------------------
61
+ if HF_TTS_URL:
62
+ try:
63
+ import urllib.request
64
+ import json
65
+ payload = json.dumps({"text": text, "language": language}).encode()
66
+ req = urllib.request.Request(
67
+ f"{HF_TTS_URL}/tts",
68
+ data=payload,
69
+ headers={"Content-Type": "application/json"},
70
+ method="POST",
71
+ )
72
+ with urllib.request.urlopen(req, timeout=30) as resp:
73
+ return resp.read()
74
+ except Exception as exc:
75
+ log.error("HF TTS fallback failed: %s", exc)
76
+
77
+ return None
78
+
79
+
80
+ def _speecht5(text: str) -> Optional[bytes]:
81
+ try:
82
+ import torch
83
+ from transformers import SpeechT5Processor, SpeechT5ForTextToSpeech, SpeechT5HifiGan
84
+ from datasets import load_dataset
85
+
86
+ processor = SpeechT5Processor.from_pretrained("microsoft/speecht5_tts")
87
+ model = SpeechT5ForTextToSpeech.from_pretrained("microsoft/speecht5_tts")
88
+ vocoder = SpeechT5HifiGan.from_pretrained("microsoft/speecht5_hifigan")
89
+
90
+ ds = load_dataset("Matthijs/cmu-arctic-xvectors", split="validation")
91
+ xvec = torch.tensor(ds[7306]["xvector"]).unsqueeze(0)
92
+
93
+ inputs = processor(text=text, return_tensors="pt")
94
+ speech = model.generate_speech(inputs["input_ids"], xvec, vocoder=vocoder)
95
+
96
+ import scipy.io.wavfile as wav_io
97
+ buf = io.BytesIO()
98
+ wav_io.write(buf, 16000, speech.numpy())
99
+ return buf.getvalue()
100
+ except Exception as exc:
101
+ log.debug("SpeechT5 unavailable: %s", exc)
102
+ return None
103
+
104
+
105
+ def _mms_tts(text: str, lang_code: str) -> Optional[bytes]:
106
+ try:
107
+ from transformers import VitsModel, AutoTokenizer
108
+ import torch
109
+ model_id = f"facebook/mms-tts-{lang_code}"
110
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
111
+ model = VitsModel.from_pretrained(model_id)
112
+ inputs = tokenizer(text, return_tensors="pt")
113
+ with torch.no_grad():
114
+ output = model(**inputs).waveform.squeeze().numpy()
115
+ import scipy.io.wavfile as wav_io
116
+ buf = io.BytesIO()
117
+ wav_io.write(buf, model.config.sampling_rate, output)
118
+ return buf.getvalue()
119
+ except Exception as exc:
120
+ log.debug("MMS-TTS unavailable for %s: %s", lang_code, exc)
121
+ return None
tasks/celery_app.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tasks/celery_app.py
3
+ ---------------------
4
+ Celery application factory.
5
+ Broker : Redis (REDIS_URL env var)
6
+ Backend : Redis (for result storage)
7
+ """
8
+
9
+ import os
10
+ from celery import Celery
11
+ from celery.schedules import crontab
12
+
13
+ REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
14
+
15
+ celery_app = Celery(
16
+ "bookhotel",
17
+ broker=REDIS_URL,
18
+ backend=REDIS_URL,
19
+ include=[
20
+ "tasks.scheduled_tasks",
21
+ "services.review_service.celery_tasks",
22
+ ],
23
+ )
24
+
25
+ celery_app.conf.update(
26
+ task_serializer="json",
27
+ accept_content=["json"],
28
+ result_serializer="json",
29
+ timezone="UTC",
30
+ enable_utc=True,
31
+ task_acks_late=True,
32
+ worker_prefetch_multiplier=1,
33
+ task_track_started=True,
34
+ result_expires=3600,
35
+ # Beat schedule
36
+ beat_schedule={
37
+ "send-review-requests-daily": {
38
+ "task": "tasks.scheduled_tasks.trigger_review_requests",
39
+ "schedule": crontab(hour=10, minute=0), # 10:00 UTC daily
40
+ },
41
+ "expire-loyalty-points-monthly": {
42
+ "task": "tasks.scheduled_tasks.expire_loyalty_points",
43
+ "schedule": crontab(day_of_month=1, hour=0, minute=0),
44
+ },
45
+ "release-stale-soft-locks": {
46
+ "task": "tasks.scheduled_tasks.release_stale_soft_locks",
47
+ "schedule": crontab(minute="*/15"), # every 15 min
48
+ },
49
+ },
50
+ )
tasks/scheduled_tasks.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tasks/scheduled_tasks.py
3
+ --------------------------
4
+ Celery beat-scheduled maintenance tasks.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ import logging
10
+ from datetime import datetime, timezone, timedelta
11
+ import urllib.request
12
+
13
+ from .celery_app import celery_app
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
18
+ SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
19
+ REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
20
+
21
+
22
+ def _sb_headers():
23
+ return {
24
+ "apikey": SUPABASE_KEY,
25
+ "Authorization": f"Bearer {SUPABASE_KEY}",
26
+ "Content-Type": "application/json",
27
+ }
28
+
29
+
30
+ @celery_app.task(name="tasks.scheduled_tasks.trigger_review_requests")
31
+ def trigger_review_requests():
32
+ """
33
+ Finds bookings that checked out yesterday and enqueues review requests.
34
+ """
35
+ yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
36
+ url = (
37
+ f"{SUPABASE_URL}/rest/v1/bookings"
38
+ f"?check_out=eq.{yesterday}&status=eq.confirmed&select=id"
39
+ )
40
+ req = urllib.request.Request(url, headers=_sb_headers())
41
+ with urllib.request.urlopen(req) as resp:
42
+ bookings = json.loads(resp.read())
43
+
44
+ queued = 0
45
+ for b in bookings:
46
+ celery_app.send_task(
47
+ "review_service.send_review_request",
48
+ kwargs={"booking_id": b["id"]},
49
+ countdown=3600, # 1 h after checkout
50
+ )
51
+ queued += 1
52
+ log.info("Queued %d review request tasks for %s", queued, yesterday)
53
+ return {"queued": queued, "date": yesterday}
54
+
55
+
56
+ @celery_app.task(name="tasks.scheduled_tasks.expire_loyalty_points")
57
+ def expire_loyalty_points():
58
+ """
59
+ Marks loyalty_transactions expired where expires_at < now.
60
+ (Points deduction handled by a Supabase function / trigger in production.)
61
+ """
62
+ now = datetime.now(timezone.utc).isoformat()
63
+ url = (
64
+ f"{SUPABASE_URL}/rest/v1/loyalty_transactions"
65
+ f"?expires_at=lt.{now}&expired=eq.false"
66
+ )
67
+ headers = {**_sb_headers(), "Prefer": "return=representation"}
68
+ data = json.dumps({"expired": True}).encode()
69
+ req = urllib.request.Request(url, data=data, headers=headers, method="PATCH")
70
+ with urllib.request.urlopen(req) as resp:
71
+ updated = json.loads(resp.read())
72
+ log.info("Expired %d loyalty point records", len(updated))
73
+ return {"expired": len(updated)}
74
+
75
+
76
+ @celery_app.task(name="tasks.scheduled_tasks.release_stale_soft_locks")
77
+ def release_stale_soft_locks():
78
+ """
79
+ Redis soft locks expire via TTL automatically, but this task cleans up
80
+ any orphaned keys whose session no longer exists.
81
+ Iterates soft_lock:* keys older than LOCK_TTL without matching session.
82
+ """
83
+ import redis as redis_lib
84
+ r = redis_lib.from_url(REDIS_URL, decode_responses=True)
85
+ pattern = "soft_lock:*"
86
+ cursor = 0
87
+ released = 0
88
+
89
+ while True:
90
+ cursor, keys = r.scan(cursor, match=pattern, count=200)
91
+ for key in keys:
92
+ ttl = r.ttl(key)
93
+ if ttl == -1: # no TTL set → stale
94
+ r.delete(key)
95
+ released += 1
96
+ if cursor == 0:
97
+ break
98
+
99
+ log.info("Released %d stale soft locks", released)
100
+ return {"released": released}