3v324v23 commited on
Commit
6f4e455
·
0 Parent(s):

Deploy backend to Hugging Face Spaces

Browse files
Files changed (13) hide show
  1. .gitignore +11 -0
  2. .hf/metadata.json +4 -0
  3. Dockerfile +22 -0
  4. Dockerfile.hf +15 -0
  5. README.md +45 -0
  6. api.py +442 -0
  7. app.py +8 -0
  8. deploy_to_huggingface.sh +119 -0
  9. docker-compose.yml +2 -0
  10. main.py +91 -0
  11. requirements.txt +16 -0
  12. routers/__init__.py +23 -0
  13. routers/model.py +78 -0
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.class
5
+ .pytest_cache/
6
+ .coverage
7
+ htmlcov/
8
+ .ipynb_checkpoints
9
+ *.ipynb
10
+ venv/
11
+ .venv/
.hf/metadata.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "app_port": 7860,
3
+ "app_file": "api.py"
4
+ }
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Base image with Python
2
+ FROM python:3.10-slim
3
+
4
+ # Set up a non-root user for security
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+
9
+ WORKDIR /app
10
+
11
+ # Install dependencies
12
+ COPY --chown=user requirements.txt .
13
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
14
+
15
+ # Copy application files
16
+ COPY --chown=user . .
17
+
18
+ # Hugging Face Spaces uses port 7860
19
+ ENV PORT=7860
20
+
21
+ # Use the api.py file as your entry point
22
+ CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
Dockerfile.hf ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ COPY . .
10
+
11
+ # For Hugging Face Spaces - it expects port 7860 by default
12
+ ENV PORT=7860
13
+
14
+ # Entry point that will be used by Hugging Face Spaces
15
+ CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "${PORT}"]
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Serendip Experiential Backend
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ sdk_version: "3.10"
8
+ app_file: api.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # Serendip Experiential Backend
14
+
15
+ This Space hosts the FastAPI backend for the Serendip Experiential Engine, which serves the j2damax/serendip-travel-classifier model.
16
+
17
+ ## API Endpoints
18
+
19
+ - `GET /`: Health check endpoint
20
+ - `POST /predict`: Analyzes a tourism review text and returns experiential dimension scores
21
+ - `POST /explain`: Provides explainability for prediction results using SHAP
22
+
23
+ ## Usage
24
+
25
+ This backend API is designed to be used with the [Serendip Experiential Frontend](https://huggingface.co/spaces/j2damax/serendip-experiential-frontend).
26
+
27
+ ## Technologies
28
+
29
+ - FastAPI
30
+ - Hugging Face Transformers
31
+ - SHAP for explainability
32
+ - PyTorch
33
+
34
+ ## Model
35
+
36
+ This application uses the `j2damax/serendip-travel-classifier` model, which was trained to identify four key experiential dimensions in Sri Lankan tourism reviews:
37
+
38
+ - 🌱 Regenerative & Eco-Tourism
39
+ - 🧘 Integrated Wellness
40
+ - 🍜 Immersive Culinary
41
+ - 🌄 Off-the-Beaten-Path Adventure
42
+
43
+ ---
44
+
45
+ <a href="https://github.com/j2damax/explainable-tourism-nlp" target="_blank">View on GitHub</a>
api.py ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API module for BertForSequenceClassification model loading and inference
3
+ with storage optimization for Hugging Face Spaces
4
+ """
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from typing import List, Dict, Any, Optional
9
+ import logging
10
+ from fastapi import FastAPI, HTTPException
11
+ from pydantic import BaseModel
12
+ import torch
13
+ import numpy as np
14
+ from transformers import (
15
+ AutoTokenizer,
16
+ AutoModelForSequenceClassification,
17
+ pipeline
18
+ )
19
+
20
+ # Configure logging - use stderr to avoid filling up disk with log files
21
+ logging.basicConfig(
22
+ level=logging.INFO,
23
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
24
+ handlers=[logging.StreamHandler()] # Log to stderr instead of files
25
+ )
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Model constants
29
+ MODEL_NAME = "j2damax/serendip-travel-classifier"
30
+ NUM_LABELS = 4
31
+ MAX_LENGTH = 512
32
+
33
+ # Dimension labels
34
+ DIMENSIONS = [
35
+ "Regenerative & Eco-Tourism",
36
+ "Integrated Wellness",
37
+ "Immersive Culinary",
38
+ "Off-the-Beaten-Path Adventure"
39
+ ]
40
+
41
+ # Set up a temporary cache directory for HuggingFace Transformers
42
+ # This prevents filling up the persistent storage on HF Spaces
43
+ os.environ['TRANSFORMERS_CACHE'] = os.path.join(tempfile.gettempdir(), 'hf_transformers_cache')
44
+ os.environ['HF_HOME'] = os.path.join(tempfile.gettempdir(), 'hf_home')
45
+ os.makedirs(os.environ['TRANSFORMERS_CACHE'], exist_ok=True)
46
+ os.makedirs(os.environ['HF_HOME'], exist_ok=True)
47
+
48
+ # Initialize FastAPI app
49
+ app = FastAPI(
50
+ title="Serendip Travel Classifier API",
51
+ description="API for classifying experiential dimensions in Sri Lankan tourism reviews using BERT",
52
+ version="0.1.0",
53
+ )
54
+
55
+ # Request and response models
56
+ class PredictRequest(BaseModel):
57
+ review_text: str
58
+
59
+ class PredictionResult(BaseModel):
60
+ label: str
61
+ score: float
62
+
63
+ class ExplainRequest(BaseModel):
64
+ review_text: str
65
+ top_n_words: int = 10
66
+
67
+ # Global variables for model, tokenizer, and classifier
68
+ model = None
69
+ tokenizer = None
70
+ classifier = None
71
+
72
+ def cleanup_unused_files():
73
+ """Clean up temporary files and caches to save space"""
74
+ try:
75
+ # Clear transformers cache
76
+ cache_dir = os.environ.get('TRANSFORMERS_CACHE')
77
+ if cache_dir and os.path.exists(cache_dir):
78
+ logger.info(f"Cleaning up transformers cache: {cache_dir}")
79
+ # Instead of deleting everything, just remove files older than 1 hour
80
+ import time
81
+ for root, dirs, files in os.walk(cache_dir):
82
+ for f in files:
83
+ file_path = os.path.join(root, f)
84
+ try:
85
+ if time.time() - os.path.getmtime(file_path) > 3600:
86
+ os.remove(file_path)
87
+ except Exception as e:
88
+ logger.warning(f"Error removing file {file_path}: {str(e)}")
89
+
90
+ # Remove other temp files
91
+ temp_dir = tempfile.gettempdir()
92
+ for f in os.listdir(temp_dir):
93
+ if f.startswith('tmp') and not f.endswith('.py'):
94
+ try:
95
+ file_path = os.path.join(temp_dir, f)
96
+ if os.path.isfile(file_path) and time.time() - os.path.getmtime(file_path) > 3600:
97
+ os.remove(file_path)
98
+ except Exception:
99
+ pass
100
+ except Exception as e:
101
+ logger.warning(f"Error during cleanup: {str(e)}")
102
+
103
+ def load_model_if_needed():
104
+ """Load the model and tokenizer if they're not already loaded"""
105
+ global model, tokenizer, classifier
106
+
107
+ if model is None or tokenizer is None or classifier is None:
108
+ try:
109
+ logger.info("Loading model and tokenizer...")
110
+
111
+ # Clean up any existing cache to prevent storage issues
112
+ cleanup_unused_files()
113
+
114
+ # Use device setting
115
+ device = "cuda" if torch.cuda.is_available() else "cpu"
116
+ logger.info(f"Using device: {device}")
117
+
118
+ # Load tokenizer
119
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
120
+
121
+ # Load model with optimization settings
122
+ model = AutoModelForSequenceClassification.from_pretrained(
123
+ MODEL_NAME,
124
+ num_labels=NUM_LABELS,
125
+ problem_type="multi_label_classification",
126
+ low_cpu_mem_usage=True # Lower memory usage
127
+ )
128
+
129
+ # Create classifier pipeline
130
+ classifier = pipeline(
131
+ "text-classification",
132
+ model=model,
133
+ tokenizer=tokenizer,
134
+ function_to_apply="sigmoid",
135
+ top_k=None,
136
+ device=-1 if device == "cpu" else 0
137
+ )
138
+
139
+ logger.info("Model loaded successfully!")
140
+ return True
141
+ except Exception as e:
142
+ logger.error(f"Failed to load model: {str(e)}")
143
+ raise HTTPException(status_code=500, detail=f"Failed to load model: {str(e)}")
144
+
145
+ return True
146
+
147
+ @app.on_event("startup")
148
+ async def startup_event():
149
+ """Run startup tasks"""
150
+ logger.info("Starting API server. Model will be loaded on first request.")
151
+
152
+ # Clean up unused files from previous runs
153
+ import time # Required for cleanup function
154
+ cleanup_unused_files()
155
+
156
+ @app.get("/")
157
+ def read_root():
158
+ """Health check endpoint"""
159
+ global model, tokenizer, classifier
160
+
161
+ model_status = "loaded" if model is not None else "not_loaded"
162
+ return {
163
+ "status": "active",
164
+ "model": MODEL_NAME,
165
+ "model_status": model_status
166
+ }
167
+
168
+ @app.post("/predict", response_model=List[PredictionResult])
169
+ async def predict(request: PredictRequest):
170
+ """
171
+ Classify a tourism review into experiential dimensions
172
+
173
+ This endpoint processes the review text and returns prediction scores for all dimensions.
174
+ """
175
+ # Load model if needed (using our new optimized function)
176
+ load_model_if_needed()
177
+
178
+ try:
179
+ logger.info(f"Processing review: {request.review_text[:50]}...")
180
+
181
+ # Run inference
182
+ result = classifier(request.review_text)
183
+
184
+ # Print the raw result for debugging
185
+ print(f"Raw prediction result: {result}")
186
+
187
+ # Extract predictions and format response
188
+ if isinstance(result, list) and len(result) > 0:
189
+ if isinstance(result[0], list) and len(result[0]) > 0:
190
+ # Handle nested list structure [[{...}, {...}, ...]]
191
+ predictions = result[0]
192
+ formatted_results = []
193
+
194
+ # Create a mapping from label names to scores
195
+ label_scores = {item['label']: item['score'] for item in predictions}
196
+
197
+ # Ensure we have results for all dimensions in the expected order
198
+ for label in DIMENSIONS:
199
+ score = label_scores.get(label, 0.0)
200
+ if isinstance(score, torch.Tensor):
201
+ score = score.item()
202
+ formatted_results.append({
203
+ "label": label,
204
+ "score": float(score)
205
+ })
206
+ elif isinstance(result[0], dict):
207
+ # Handle the case where the pipeline returns a list with one dict
208
+ scores = result[0]
209
+
210
+ # Format output as a list of label-score pairs
211
+ formatted_results = []
212
+
213
+ for idx, label in enumerate(DIMENSIONS):
214
+ label_id = f"LABEL_{idx}"
215
+ score = scores.get(label_id, 0.0)
216
+ if isinstance(score, torch.Tensor):
217
+ score = score.item()
218
+ formatted_results.append({
219
+ "label": label,
220
+ "score": float(score)
221
+ })
222
+
223
+ # Sort by score in descending order
224
+ formatted_results.sort(key=lambda x: x["score"], reverse=True)
225
+ return formatted_results
226
+ else:
227
+ # If the pipeline returns something unexpected, try to convert it
228
+ formatted_results = []
229
+ for i, label in enumerate(DIMENSIONS):
230
+ score = 0.0
231
+ if i < len(result):
232
+ if isinstance(result[i], dict) and "score" in result[i]:
233
+ score = result[i]["score"]
234
+ elif isinstance(result[i], (int, float, np.number, torch.Tensor)):
235
+ score = float(result[i])
236
+
237
+ formatted_results.append({
238
+ "label": label,
239
+ "score": float(score)
240
+ })
241
+
242
+ return formatted_results
243
+
244
+ except Exception as e:
245
+ logger.error(f"Error during prediction: {str(e)}")
246
+ raise HTTPException(status_code=500, detail=f"Prediction error: {str(e)}")
247
+
248
+ @app.post("/explain")
249
+ async def explain(request: ExplainRequest):
250
+ """
251
+ Generate explanations for a review's classification
252
+
253
+ This endpoint returns both HTML visualization and top influencing words,
254
+ using a simple attribution method for reliability.
255
+ """
256
+ # Load model if needed (using our new optimized function)
257
+ load_model_if_needed()
258
+
259
+ try:
260
+ # Get the input text
261
+ review_text = request.review_text
262
+
263
+ # Tokenize the text by word (use simple space splitting for visualization)
264
+ # NOTE: This is not the same as model tokenization, it's just for display
265
+ words = review_text.split()
266
+ if len(words) < 2:
267
+ raise ValueError("Review text must contain at least 2 words for explanation")
268
+
269
+ # Generate word importance for all dimensions using simpler method
270
+ dimension_scores = {}
271
+ for i, dimension in enumerate(DIMENSIONS):
272
+ dimension_scores[dimension] = []
273
+
274
+ # 1. Get the baseline prediction for the full text
275
+ with torch.no_grad():
276
+ inputs = tokenizer(
277
+ review_text,
278
+ return_tensors="pt",
279
+ truncation=True,
280
+ padding=True,
281
+ max_length=MAX_LENGTH
282
+ )
283
+ outputs = model(**inputs)
284
+ predictions = torch.sigmoid(outputs.logits)
285
+ baseline_scores = predictions.detach().numpy()[0]
286
+
287
+ # 2. For each word, measure its importance by removing it
288
+ for i, word in enumerate(words):
289
+ if len(words) <= 1: # Skip if only one word
290
+ continue
291
+
292
+ # Create text with this word removed
293
+ words_without_i = words.copy()
294
+ words_without_i.pop(i)
295
+ modified_text = " ".join(words_without_i)
296
+
297
+ # Get prediction without the word
298
+ with torch.no_grad():
299
+ mod_inputs = tokenizer(
300
+ modified_text,
301
+ return_tensors="pt",
302
+ truncation=True,
303
+ padding=True,
304
+ max_length=MAX_LENGTH
305
+ )
306
+ mod_outputs = model(**mod_inputs)
307
+ mod_predictions = torch.sigmoid(mod_outputs.logits)
308
+ mod_scores = mod_predictions.detach().numpy()[0]
309
+
310
+ # For each dimension, calculate importance as difference in scores
311
+ for dim_idx, dimension in enumerate(DIMENSIONS):
312
+ importance = float(baseline_scores[dim_idx] - mod_scores[dim_idx])
313
+ dimension_scores[dimension].append({
314
+ "word": word,
315
+ "value": importance,
316
+ "is_positive": importance > 0
317
+ })
318
+
319
+ # 3. For each dimension, sort words by absolute importance and take top N
320
+ top_words = {}
321
+ for dimension in DIMENSIONS:
322
+ if dimension_scores[dimension]:
323
+ # Sort by absolute importance (largest effect first)
324
+ sorted_words = sorted(
325
+ dimension_scores[dimension],
326
+ key=lambda x: abs(x["value"]),
327
+ reverse=True
328
+ )
329
+ # Take top N words
330
+ top_words[dimension] = sorted_words[:request.top_n_words]
331
+ else:
332
+ top_words[dimension] = []
333
+
334
+ # 4. Create visualization using matplotlib
335
+ try:
336
+ import matplotlib
337
+ matplotlib.use('Agg')
338
+ import matplotlib.pyplot as plt
339
+ from io import BytesIO
340
+ import base64
341
+
342
+ # Create visualization for the top dimension
343
+ top_dim_idx = np.argmax(baseline_scores)
344
+ top_dimension = DIMENSIONS[top_dim_idx]
345
+
346
+ # Extract top words for visualization
347
+ top_words_for_viz = top_words[top_dimension]
348
+
349
+ # Configure matplotlib to use smaller sizes and lower quality to save memory
350
+ plt.rcParams['figure.dpi'] = 80 # Lower DPI
351
+ plt.rcParams['savefig.dpi'] = 100 # Lower save DPI
352
+
353
+ # Create figure with a smaller size to reduce memory usage
354
+ fig, ax = plt.subplots(figsize=(8, 5))
355
+
356
+ # Prepare data for visualization - limit to top 8 words to reduce image size
357
+ viz_words = [item["word"] for item in top_words_for_viz[:8]]
358
+ viz_values = [item["value"] for item in top_words_for_viz[:8]]
359
+
360
+ # Create horizontal bar chart with simplified styling
361
+ bars = ax.barh(
362
+ viz_words,
363
+ viz_values,
364
+ color=['#FF4444' if v > 0 else '#3366CC' for v in viz_values],
365
+ height=0.7,
366
+ edgecolor='black',
367
+ linewidth=0.5
368
+ )
369
+
370
+ # Add simple labels and title
371
+ ax.set_title(f"Words influencing '{top_dimension}'", fontsize=12)
372
+ ax.set_xlabel("Impact on score", fontsize=10)
373
+
374
+ # Add a vertical line at x=0 with simplified styling
375
+ ax.axvline(x=0, color='black', linestyle='-', linewidth=1)
376
+
377
+ # Add simple legend to explain colors
378
+ from matplotlib.patches import Patch
379
+ legend_elements = [
380
+ Patch(facecolor='#FF4444', edgecolor='black', label='Increases score'),
381
+ Patch(facecolor='#3366CC', edgecolor='black', label='Decreases score')
382
+ ]
383
+ ax.legend(handles=legend_elements, loc='lower right', fontsize=8)
384
+
385
+ # Convert plot to HTML image with lower resolution
386
+ buffer = BytesIO()
387
+ fig.tight_layout()
388
+ plt.savefig(buffer, format='png', dpi=80, bbox_inches='tight')
389
+ buffer.seek(0)
390
+ img_str = base64.b64encode(buffer.read()).decode()
391
+
392
+ # Create simplified HTML with inline image
393
+ html = f"""
394
+ <div style="text-align: center;">
395
+ <h3>Words influencing '{top_dimension}'</h3>
396
+ <img src="data:image/png;base64,{img_str}" style="width:100%; max-width:600px;" />
397
+ <p>Red bars increase prediction score, blue bars decrease it.</p>
398
+ </div>
399
+ """
400
+
401
+ # Close the figure to free memory
402
+ plt.close(fig)
403
+ plt.close(fig)
404
+
405
+ except Exception as viz_error:
406
+ logger.error(f"Error creating visualization: {str(viz_error)}")
407
+ html = f"<p>Could not generate visualization: {str(viz_error)}</p>"
408
+
409
+ # Return the HTML and top words in the format expected by the frontend
410
+ return {
411
+ "explanation": {
412
+ "html": html,
413
+ "top_words": top_words
414
+ }
415
+ }
416
+
417
+ except Exception as e:
418
+ logger.error(f"Error during explanation: {str(e)}")
419
+ raise HTTPException(status_code=500, detail=f"Explanation error: {str(e)}")
420
+
421
+ @app.on_event("shutdown")
422
+ async def shutdown_event():
423
+ """Clean up resources when shutting down"""
424
+ logger.info("Shutting down API server")
425
+ cleanup_unused_files()
426
+
427
+ # Clear global model references to help garbage collection
428
+ global model, tokenizer, classifier
429
+ model = None
430
+ tokenizer = None
431
+ classifier = None
432
+
433
+ if __name__ == "__main__":
434
+ import uvicorn
435
+ import time # Required for cleanup function
436
+
437
+ # Determine the host and port from environment variables or use defaults
438
+ host = os.environ.get("HOST", "0.0.0.0")
439
+ port = int(os.environ.get("PORT", 8000))
440
+
441
+ # Run the application - disable reload in production to save memory
442
+ uvicorn.run("api:app", host=host, port=port, reload=False)
app.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Entry point for Hugging Face Spaces deployment
3
+ """
4
+ # Import your FastAPI app
5
+ from api import app
6
+
7
+ # This is the entry point for Hugging Face Spaces
8
+ # It will automatically be detected and run
deploy_to_huggingface.sh ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Deployment script for Serendip Experiential Backend to Hugging Face Spaces
4
+
5
+ # Check if HF_TOKEN is set
6
+ if [ -z "$HF_TOKEN" ]; then
7
+ echo "Error: HF_TOKEN environment variable not set"
8
+ echo "Please set it with: export HF_TOKEN=your_hugging_face_token"
9
+ exit 1
10
+ fi
11
+
12
+ # Define variables
13
+ SPACE_NAME="j2damax/serendip-experiential-backend"
14
+ REPO_URL="https://huggingface.co/spaces/$SPACE_NAME"
15
+ LOCAL_DIR="$(pwd)"
16
+
17
+ echo "Deploying backend to Hugging Face Spaces: $SPACE_NAME"
18
+
19
+ # Create a temporary directory
20
+ TMP_DIR=$(mktemp -d)
21
+ cd $TMP_DIR
22
+
23
+ # Initialize git and set credentials
24
+ git init
25
+ git config --local user.email "you@example.com"
26
+ git config --local user.name "Your Name"
27
+
28
+ # Clone the space if it exists, otherwise create from scratch
29
+ if curl --fail --silent -H "Authorization: Bearer $HF_TOKEN" $REPO_URL > /dev/null; then
30
+ echo "Space exists, cloning repository..."
31
+ # Format for Hugging Face API token authentication
32
+ git clone "https://huggingface.co/spaces/$SPACE_NAME" .
33
+ git config --local credential.helper store
34
+ echo "https://oauth2:$HF_TOKEN@huggingface.co" > ~/.git-credentials
35
+ # Remove all files except .git to ensure clean state
36
+ find . -mindepth 1 -not -path "./.git*" -delete
37
+ else
38
+ echo "Creating new space..."
39
+ # Will push later to create the repository
40
+ fi
41
+
42
+ # Copy all files from the backend directory
43
+ echo "Copying files from $LOCAL_DIR to temporary directory..."
44
+ cp -r $LOCAL_DIR/* .
45
+
46
+ # Remove any unnecessary files
47
+ echo "Cleaning up unnecessary files..."
48
+ rm -rf __pycache__ .ipynb_checkpoints .pytest_cache .venv
49
+
50
+ # Use the Hugging Face specific Dockerfile
51
+ if [ -f "Dockerfile.huggingface" ]; then
52
+ echo "Using Hugging Face specific Dockerfile..."
53
+ mv Dockerfile.huggingface Dockerfile
54
+ fi
55
+
56
+ # Copy the README.md file with proper YAML metadata
57
+ if [ -f "$LOCAL_DIR/README.md" ]; then
58
+ echo "Using existing README.md with YAML metadata..."
59
+ cp "$LOCAL_DIR/README.md" ./README.md
60
+ else
61
+ echo "# Creating default README.md with YAML metadata..."
62
+ cat > README.md << EOL
63
+ ---
64
+ title: Serendip Experiential Backend
65
+ emoji: 🚀
66
+ colorFrom: blue
67
+ colorTo: indigo
68
+ sdk: docker
69
+ sdk_version: "3.10"
70
+ app_file: api.py
71
+ pinned: false
72
+ license: mit
73
+ ---
74
+
75
+ # Serendip Experiential Backend
76
+ FastAPI backend for the Serendip Experiential Engine
77
+ EOL
78
+ fi
79
+
80
+ # Create .gitignore
81
+ echo ".env
82
+ __pycache__/
83
+ *.py[cod]
84
+ *$py.class
85
+ .pytest_cache/
86
+ .coverage
87
+ htmlcov/
88
+ .ipynb_checkpoints
89
+ *.ipynb
90
+ venv/
91
+ .venv/" > .gitignore
92
+
93
+ # Create Hugging Face Space metadata file
94
+ mkdir -p .hf
95
+ cat > .hf/metadata.json << EOL
96
+ {
97
+ "app_port": 7860,
98
+ "app_file": "api.py"
99
+ }
100
+ EOL
101
+
102
+ # Add all files to git
103
+ git add .
104
+
105
+ # Commit changes
106
+ git commit -m "Deploy backend to Hugging Face Spaces"
107
+
108
+ # Push to Hugging Face Spaces
109
+ echo "Pushing to Hugging Face Spaces..."
110
+ # Use stored credential helper instead of embedding in URL
111
+ git remote add origin "https://huggingface.co/spaces/$SPACE_NAME"
112
+ git push -f origin main
113
+
114
+ # Clean up
115
+ cd - > /dev/null
116
+ rm -rf $TMP_DIR
117
+
118
+ echo "Deployment complete! Your backend should be available at:"
119
+ echo "https://huggingface.co/spaces/$SPACE_NAME"
docker-compose.yml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ dockerfile: Dockerfile.hf
2
+ base_path: /app
main.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from pydantic import BaseModel
3
+ import numpy as np
4
+ from typing import Dict, List
5
+ import logging
6
+ import os
7
+
8
+ # Configure logging
9
+ logging.basicConfig(
10
+ level=logging.INFO,
11
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
12
+ )
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Initialize FastAPI app
16
+ app = FastAPI(
17
+ title="Serendip Experiential Engine API",
18
+ description="API for classifying experiential dimensions in Sri Lankan tourism reviews",
19
+ version="0.1.0"
20
+ )
21
+
22
+ class ReviewRequest(BaseModel):
23
+ text: str
24
+
25
+ class ExplanationItem(BaseModel):
26
+ word: str
27
+ value: float
28
+
29
+ class ClassificationResponse(BaseModel):
30
+ predictions: Dict[str, float]
31
+ explanation: Dict[str, List[ExplanationItem]]
32
+
33
+ # Define the experiential dimensions
34
+ DIMENSIONS = [
35
+ "Regenerative & Eco-Tourism",
36
+ "Integrated Wellness",
37
+ "Immersive Culinary",
38
+ "Off-the-Beaten-Path Adventure"
39
+ ]
40
+
41
+ @app.get("/")
42
+ def read_root():
43
+ """Root endpoint for health checking"""
44
+ return {"status": "active", "service": "Serendip Experiential Engine API"}
45
+
46
+ @app.get("/dimensions")
47
+ def get_dimensions():
48
+ """Get all available experiential dimensions"""
49
+ return {"dimensions": DIMENSIONS}
50
+
51
+ @app.post("/classify", response_model=ClassificationResponse)
52
+ async def classify_review(request: ReviewRequest):
53
+ """
54
+ Classify a tourism review into experiential dimensions
55
+ """
56
+ try:
57
+ logger.info(f"Processing review: {request.text[:50]}...")
58
+
59
+ # TODO: Replace this with actual model inference
60
+ # This is just placeholder logic that returns random values
61
+ mock_predictions = {
62
+ dim: float(np.random.random()) for dim in DIMENSIONS
63
+ }
64
+
65
+ # Mock explanation data (in a real app, this would come from SHAP or similar)
66
+ mock_explanation = {
67
+ dim: [
68
+ {"word": "beautiful", "value": float(np.random.random())},
69
+ {"word": "amazing", "value": float(np.random.random())},
70
+ {"word": "sustainable", "value": float(np.random.random())}
71
+ ] for dim in DIMENSIONS
72
+ }
73
+
74
+ return {
75
+ "predictions": mock_predictions,
76
+ "explanation": mock_explanation
77
+ }
78
+
79
+ except Exception as e:
80
+ logger.error(f"Error processing review: {str(e)}")
81
+ raise HTTPException(status_code=500, detail=f"Error processing review: {str(e)}")
82
+
83
+ if __name__ == "__main__":
84
+ import uvicorn
85
+
86
+ # Determine the host and port from environment variables or use defaults
87
+ host = os.environ.get("HOST", "0.0.0.0")
88
+ port = int(os.environ.get("PORT", 8000))
89
+
90
+ # Run the application
91
+ uvicorn.run("main:app", host=host, port=port, reload=True)
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend requirements
2
+ fastapi>=0.100.0
3
+ uvicorn>=0.23.0
4
+ pydantic>=2.0.0
5
+ numpy>=1.24.0
6
+ pandas>=2.0.0
7
+ python-dotenv>=1.0.0
8
+ loguru>=0.7.0
9
+ requests>=2.31.0
10
+
11
+ # ML/NLP requirements
12
+ torch>=2.0.0
13
+ transformers>=4.30.0
14
+ shap>=0.42.0
15
+ huggingface-hub>=0.16.0
16
+ matplotlib>=3.7.0
routers/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from pydantic import BaseModel
3
+ from typing import Dict, List
4
+
5
+ router = APIRouter(
6
+ prefix="/api/v1",
7
+ tags=["model"],
8
+ responses={404: {"description": "Not found"}},
9
+ )
10
+
11
+ class ReviewRequest(BaseModel):
12
+ text: str
13
+
14
+ class ClassificationResponse(BaseModel):
15
+ predictions: Dict[str, float]
16
+ explanation: Dict[str, List[Dict[str, float]]]
17
+
18
+ @router.get("/health")
19
+ async def health_check():
20
+ """Check if the model is healthy and ready to serve predictions"""
21
+ return {"status": "ok", "model": "active"}
22
+
23
+ # Additional model-related endpoints can be added here
routers/model.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from pydantic import BaseModel
3
+ from typing import Dict, List
4
+ import numpy as np
5
+ import logging
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ router = APIRouter(
10
+ prefix="/api/v1",
11
+ tags=["model"],
12
+ responses={404: {"description": "Not found"}},
13
+ )
14
+
15
+ class ReviewRequest(BaseModel):
16
+ text: str
17
+
18
+ class ExplanationItem(BaseModel):
19
+ word: str
20
+ value: float
21
+
22
+ class ClassificationResponse(BaseModel):
23
+ predictions: Dict[str, float]
24
+ explanation: Dict[str, List[ExplanationItem]]
25
+
26
+ # Define the experiential dimensions
27
+ DIMENSIONS = [
28
+ "Regenerative & Eco-Tourism",
29
+ "Integrated Wellness",
30
+ "Immersive Culinary",
31
+ "Off-the-Beaten-Path Adventure"
32
+ ]
33
+
34
+ @router.get("/health")
35
+ async def health_check():
36
+ """Check if the model is healthy and ready to serve predictions"""
37
+ return {"status": "ok", "model": "active"}
38
+
39
+ @router.get("/dimensions")
40
+ async def get_dimensions():
41
+ """Get all available experiential dimensions"""
42
+ return {"dimensions": DIMENSIONS}
43
+
44
+ # NOTE: This endpoint was removed as it's not currently used by the frontend
45
+ # The frontend uses its own implementation with OpenAI API directly
46
+ # If you need this endpoint in the future, uncomment the code below
47
+
48
+ # @router.post("/classify", response_model=ClassificationResponse)
49
+ # async def classify_review(request: ReviewRequest):
50
+ # """
51
+ # Classify a tourism review into experiential dimensions
52
+ # """
53
+ # try:
54
+ # logger.info(f"Processing review: {request.text[:50]}...")
55
+ #
56
+ # # TODO: Replace this with actual model inference
57
+ # # This is just placeholder logic that returns random values
58
+ # mock_predictions = {
59
+ # dim: float(np.random.random()) for dim in DIMENSIONS
60
+ # }
61
+ #
62
+ # # Mock explanation data (in a real app, this would come from SHAP or similar)
63
+ # mock_explanation = {
64
+ # dim: [
65
+ # {"word": "beautiful", "value": float(np.random.random())},
66
+ # {"word": "amazing", "value": float(np.random.random())},
67
+ # {"word": "sustainable", "value": float(np.random.random())}
68
+ # ] for dim in DIMENSIONS
69
+ # }
70
+ #
71
+ # return {
72
+ # "predictions": mock_predictions,
73
+ # "explanation": mock_explanation
74
+ # }
75
+ #
76
+ # except Exception as e:
77
+ # logger.error(f"Error processing review: {str(e)}")
78
+ # raise HTTPException(status_code=500, detail=f"Error processing review: {str(e)}")