Spaces:
Sleeping
Sleeping
Commit ·
fdceecd
1
Parent(s): cd86c42
major changes
Browse files- COPILOT_MASTER_PROMPT.md +1568 -0
- hf_spaces/embeddings/app.py +91 -0
- hf_spaces/llm/app.py +136 -0
- hf_spaces/stt/app.py +101 -0
- rasa/actions/__init__.py +2 -0
- rasa/actions/actions_brain.py +243 -0
- rasa/config.yml +38 -0
- rasa/credentials.yml +12 -0
- rasa/data/nlu/nlu_en.yml +207 -0
- rasa/domain.yml +177 -0
- rasa/endpoints.yml +24 -0
- scripts/hotel_onboarding.py +89 -0
- scripts/setup_elasticsearch_index.py +85 -0
- scripts/setup_messenger_menu.py +62 -0
- scripts/setup_qdrant_collection.py +43 -0
- services/analytics_service/clickhouse_schema.sql +55 -0
- services/analytics_service/kafka_consumer.py +116 -0
- services/auth_service/main.py +135 -0
- services/corporate_service/approval_engine.py +96 -0
- services/corporate_service/main.py +69 -0
- services/corporate_service/rate_lookup.py +83 -0
- services/force_majeure_service/main.py +54 -0
- services/force_majeure_service/news_monitor.py +100 -0
- services/force_majeure_service/relocation.py +76 -0
- services/group_service/deposit_scheduler.py +56 -0
- services/group_service/main.py +76 -0
- services/group_service/room_block.py +99 -0
- services/loyalty_service/gamification.py +71 -0
- services/loyalty_service/main.py +81 -0
- services/loyalty_service/points_engine.py +138 -0
- services/loyalty_service/tier_engine.py +54 -0
- services/maps_service/nominatim.py +97 -0
- services/notification_service/main.py +84 -0
- services/review_service/celery_tasks.py +75 -0
- services/review_service/main.py +90 -0
- services/review_service/sentiment.py +78 -0
- services/voice_service/audio_utils.py +55 -0
- services/voice_service/main.py +60 -0
- services/voice_service/stt.py +74 -0
- services/voice_service/tts.py +121 -0
- tasks/celery_app.py +50 -0
- 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}
|