kluvin commited on
Commit
a6cac18
·
verified ·
1 Parent(s): 8b26067

Upload folder using huggingface_hub

Browse files
__pycache__/app.cpython-311.pyc ADDED
Binary file (10.8 kB). View file
 
__pycache__/app.cpython-313.pyc ADDED
Binary file (9.46 kB). View file
 
app.py CHANGED
@@ -1,156 +1,209 @@
1
- from flask import Flask, request, render_template
2
- from transformers import pipeline
3
- from sklearn.pipeline import Pipeline
4
- from sklearn.feature_extraction.text import TfidfVectorizer
5
- from sklearn.svm import LinearSVC
6
- from sklearn.ensemble import RandomForestClassifier
7
- from sklearn.linear_model import LogisticRegression
8
- from sklearn.tree import DecisionTreeClassifier
9
- import polars as pl
10
- import joblib
11
- from pathlib import Path
12
- import logging
13
- import os
14
-
15
- # Configure logging
16
- logging.basicConfig(
17
- level=logging.INFO,
18
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
19
- )
20
- logger = logging.getLogger(__name__)
21
-
22
- app = Flask(__name__)
23
-
24
- CLASS_ID_TO_SENTIMENT = {
25
- "0": "negative",
26
- "1": "neutral",
27
- "2": "positive"
28
- }
29
-
30
- # Use HF Spaces persistent storage if available, otherwise local cache
31
- CACHE_DIR = Path(os.getenv("HF_HOME", ".")) / ".model_cache"
32
- CACHE_DIR.mkdir(exist_ok=True)
33
-
34
- logger.info("Loading BERTweet from HuggingFace Hub...")
35
- bertweet_pipeline = pipeline("sentiment-analysis", model="kluvin/bertweet-tweet-sentiment")
36
- logger.info("BERTweet loaded successfully")
37
-
38
- # Define model configurations
39
- model_configs = {
40
- "Decision Tree": Pipeline([
41
- ("tfidf", TfidfVectorizer(max_features=2000, stop_words="english")),
42
- ("clf", DecisionTreeClassifier(max_depth=10, random_state=42))
43
- ]),
44
- "Random Forest": Pipeline([
45
- ("tfidf", TfidfVectorizer(max_features=500, stop_words="english")),
46
- ("clf", RandomForestClassifier(n_estimators=100, random_state=42))
47
- ]),
48
- "Logistic Regression": Pipeline([
49
- ("tfidf", TfidfVectorizer(max_features=2000, stop_words="english")),
50
- ("clf", LogisticRegression(max_iter=1000, random_state=42))
51
- ]),
52
- "Linear SVM": Pipeline([
53
- ("tfidf", TfidfVectorizer(max_features=2000, stop_words="english")),
54
- ("clf", LinearSVC(random_state=42))
55
- ])
56
- }
57
-
58
- sklearn_pipelines = {}
59
- cache_file = CACHE_DIR / "ml_models.joblib"
60
-
61
- if cache_file.exists():
62
- logger.info("Loading cached ML models...")
63
- try:
64
- sklearn_pipelines = joblib.load(cache_file)
65
- logger.info(" Cached models loaded successfully!")
66
- except Exception as e:
67
- logger.error(f"Failed to load cache: {e}")
68
- logger.info("Will retrain models...")
69
-
70
- if not sklearn_pipelines:
71
- logger.info("Loading training data and training ML models...")
72
- splits = {'train': 'train.jsonl'}
73
- df = pl.read_ndjson('hf://datasets/SetFit/tweet_sentiment_extraction/' + splits['train'])
74
- X_train = df['text'].to_list()
75
- y_train = df['label'].to_list()
76
-
77
- logger.info("Training models...")
78
- for model_name, sklearn_pipeline in model_configs.items():
79
- logger.info(f" Training {model_name}...")
80
- sklearn_pipeline.fit(X_train, y_train)
81
- sklearn_pipelines[model_name] = sklearn_pipeline
82
-
83
- logger.info("Saving models to cache...")
84
- joblib.dump(sklearn_pipelines, cache_file)
85
- logger.info(f"✓ Models cached at {cache_file}")
86
-
87
- logger.info("All models loaded and ready!")
88
-
89
- def render_model_result(model_name: str, sentiment_name: str, probability: float | None) -> str:
90
- probability_text = f"Probability: {probability:.2%}" if probability else "N/A"
91
- return f'''
92
- <div class="model-result {sentiment_name}">
93
- <h3>{model_name}</h3>
94
- <p class="sentiment">{sentiment_name.capitalize()}</p>
95
- <p class="confidence">{probability_text}</p>
96
- </div>
97
- '''
98
-
99
- @app.route('/')
100
- def home():
101
- return render_template('index.html')
102
-
103
- @app.route('/classify', methods=['POST'])
104
- def clasify():
105
- try:
106
- text_input = request.form['text']
107
-
108
- if not text_input.strip():
109
- return '''
110
- <div class="result error">
111
- <h2>Error: Please enter some text</h2>
112
- </div>
113
- '''
114
-
115
- logger.info(f"Classifying: {text_input[:50]}...")
116
-
117
- results_html = ""
118
-
119
- pipeline_output = bertweet_pipeline(text_input)[0]
120
- predicted_class_id = pipeline_output['label']
121
- probability = pipeline_output['score']
122
- sentiment_name = CLASS_ID_TO_SENTIMENT[predicted_class_id]
123
-
124
- results_html += render_model_result("BERTweet (Transformer)", sentiment_name, probability)
125
-
126
- for model_name, sklearn_pipeline in sklearn_pipelines.items():
127
- inputs = [text_input]
128
- predicted_class = sklearn_pipeline.predict(inputs)[0]
129
-
130
- classifier = sklearn_pipeline.named_steps['clf']
131
- if hasattr(classifier, 'predict_proba'):
132
- class_probabilities = sklearn_pipeline.predict_proba(inputs)[0]
133
- probability = class_probabilities.max()
134
- elif hasattr(classifier, 'decision_function'):
135
- decision_scores = sklearn_pipeline.decision_function(inputs)[0]
136
- probability = 1.0 / (1.0 + abs(decision_scores.min()))
137
- else:
138
- probability = None
139
-
140
- sentiment_name = CLASS_ID_TO_SENTIMENT[str(predicted_class)]
141
-
142
- results_html += render_model_result(model_name, sentiment_name, probability)
143
-
144
- return f'<div class="results-grid">{results_html}</div>'
145
- except Exception as e:
146
- logger.error(f"Classification error: {e}", exc_info=True)
147
- return f'''
148
- <div class="result error">
149
- <h2>Error: {e}</h2>
150
- </div>
151
- '''
152
-
153
- if __name__ == "__main__":
154
- if app.debug:
155
- logger.setLevel(logging.DEBUG)
156
- app.run(debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, request, render_template
2
+ from transformers import pipeline
3
+ from sklearn.pipeline import Pipeline
4
+ from sklearn.feature_extraction.text import TfidfVectorizer
5
+ from sklearn.svm import LinearSVC
6
+ from sklearn.ensemble import RandomForestClassifier
7
+ from sklearn.linear_model import LogisticRegression
8
+ from sklearn.tree import DecisionTreeClassifier
9
+ import polars as pl
10
+ import joblib
11
+ from pathlib import Path
12
+ import logging
13
+ import os
14
+ from time import perf_counter
15
+ from typing import Optional, Tuple
16
+
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
20
+ )
21
+ logger = logging.getLogger(__name__)
22
+
23
+ app = Flask(__name__)
24
+
25
+ CLASS_ID_TO_SENTIMENT = {
26
+ "0": "negative",
27
+ "1": "neutral",
28
+ "2": "positive"
29
+ }
30
+
31
+ def categorize_probability(probability: Optional[float]) -> Tuple[str, str, str]:
32
+ """
33
+ Map a probability (0-1) to a qualitative label and associated CSS modifier.
34
+ Returns (label, css_class, display_value).
35
+ """
36
+ if probability is None:
37
+ return ("Unknown", "probability-unknown", "N/A")
38
+
39
+ percent = max(0.0, min(probability * 100.0, 100.0))
40
+
41
+ if percent >= 80:
42
+ return ("Definitely", "probability-definitely", f"{percent:.0f}%")
43
+ if percent >= 60:
44
+ return ("Probably", "probability-probably", f"{percent:.0f}%")
45
+ return ("Maybe", "probability-maybe", f"{percent:.0f}%")
46
+
47
+ PRESET_TEXTS = [
48
+ "flower isn't beautiful",
49
+ "there is no more love. only pain.",
50
+ "one isn't a beauty, but two is a wondrous wonder",
51
+ "hvl is a fake university #uibforever"
52
+ ]
53
+
54
+ # Use HF Spaces persistent storage if available, otherwise local cache
55
+ CACHE_DIR = Path(os.getenv("HF_HOME", ".")) / ".model_cache"
56
+ CACHE_DIR.mkdir(exist_ok=True)
57
+
58
+ logger.info("Loading BERTweet from HuggingFace Hub...")
59
+ bertweet_pipeline = pipeline("sentiment-analysis", model="kluvin/bertweet-tweet-sentiment")
60
+ logger.info("BERTweet loaded successfully")
61
+
62
+ # Define model configurations
63
+ model_configs = {
64
+ "Decision Tree": Pipeline([
65
+ ("tfidf", TfidfVectorizer(max_features=2000, stop_words="english")),
66
+ ("clf", DecisionTreeClassifier(max_depth=10, random_state=42))
67
+ ]),
68
+ "Random Forest": Pipeline([
69
+ ("tfidf", TfidfVectorizer(max_features=500, stop_words="english")),
70
+ ("clf", RandomForestClassifier(n_estimators=100, random_state=42))
71
+ ]),
72
+ "Logistic Regression": Pipeline([
73
+ ("tfidf", TfidfVectorizer(max_features=2000, stop_words="english")),
74
+ ("clf", LogisticRegression(max_iter=1000, random_state=42))
75
+ ]),
76
+ "Linear SVM": Pipeline([
77
+ ("tfidf", TfidfVectorizer(max_features=2000, stop_words="english")),
78
+ ("clf", LinearSVC(random_state=42))
79
+ ])
80
+ }
81
+
82
+ sklearn_pipelines = {}
83
+ cache_file = CACHE_DIR / "ml_models.joblib"
84
+
85
+ if cache_file.exists():
86
+ logger.info("Loading cached ML models...")
87
+ try:
88
+ sklearn_pipelines = joblib.load(cache_file)
89
+ logger.info("✓ Cached models loaded successfully!")
90
+ except Exception as e:
91
+ logger.error(f"Failed to load cache: {e}")
92
+ logger.info("Will retrain models...")
93
+
94
+ if not sklearn_pipelines:
95
+ logger.info("Loading training data and training ML models...")
96
+ splits = {'train': 'train.jsonl'}
97
+ df = pl.read_ndjson('hf://datasets/SetFit/tweet_sentiment_extraction/' + splits['train'])
98
+ X_train = df['text'].to_list()
99
+ y_train = df['label'].to_list()
100
+
101
+ logger.info("Training models...")
102
+ for model_name, sklearn_pipeline in model_configs.items():
103
+ logger.info(f" Training {model_name}...")
104
+ sklearn_pipeline.fit(X_train, y_train)
105
+ sklearn_pipelines[model_name] = sklearn_pipeline
106
+
107
+ logger.info("Saving models to cache...")
108
+ joblib.dump(sklearn_pipelines, cache_file)
109
+ logger.info(f"✓ Models cached at {cache_file}")
110
+
111
+ logger.info("All models loaded and ready!")
112
+
113
+ def render_model_result(model_name: str, sentiment_name: str, probability: float | None) -> str:
114
+ probability_label, probability_css, probability_value = categorize_probability(probability)
115
+ return f'''
116
+ <div class="model-result {sentiment_name}">
117
+ <h3>{model_name}</h3>
118
+ <p class="sentiment">{sentiment_name.capitalize()}</p>
119
+ <p class="confidence">
120
+ <span class="probability-badge {probability_css}">
121
+ <span class="probability-label">{probability_label}</span>
122
+ <span class="probability-value">{probability_value}</span>
123
+ </span>
124
+ </p>
125
+ </div>
126
+ '''
127
+
128
+ def build_results_markup(text_input: str) -> str:
129
+ inference_start = perf_counter()
130
+ results_html = ""
131
+
132
+ pipeline_output = bertweet_pipeline(text_input)[0]
133
+ predicted_class_id = pipeline_output['label']
134
+ probability = pipeline_output['score']
135
+ sentiment_name = CLASS_ID_TO_SENTIMENT[predicted_class_id]
136
+
137
+ results_html += render_model_result("BERTweet (Transformer)", sentiment_name, probability)
138
+
139
+ for model_name, sklearn_pipeline in sklearn_pipelines.items():
140
+ inputs = [text_input]
141
+ predicted_class = sklearn_pipeline.predict(inputs)[0]
142
+
143
+ classifier = sklearn_pipeline.named_steps['clf']
144
+ if hasattr(classifier, 'predict_proba'):
145
+ class_probabilities = sklearn_pipeline.predict_proba(inputs)[0]
146
+ probability = class_probabilities.max()
147
+ elif hasattr(classifier, 'decision_function'):
148
+ decision_scores = sklearn_pipeline.decision_function(inputs)[0]
149
+ probability = 1.0 / (1.0 + abs(decision_scores.min()))
150
+ else:
151
+ probability = None
152
+
153
+ sentiment_name = CLASS_ID_TO_SENTIMENT[str(predicted_class)]
154
+
155
+ results_html += render_model_result(model_name, sentiment_name, probability)
156
+
157
+ elapsed_ms = (perf_counter() - inference_start) * 1000
158
+
159
+ return (
160
+ f'<aside class="inference-meta">Inference time: {elapsed_ms:.0f} ms</aside>'
161
+ f'<div class="results-grid">{results_html}</div>'
162
+ )
163
+
164
+ @app.route('/')
165
+ def home():
166
+ default_text = PRESET_TEXTS[0]
167
+ initial_results_html = ""
168
+
169
+ try:
170
+ logger.info("Precomputing initial classification for default preset...")
171
+ initial_results_html = build_results_markup(default_text)
172
+ except Exception as e:
173
+ logger.error(f"Failed to precompute initial results: {e}", exc_info=True)
174
+
175
+ return render_template(
176
+ 'index.html',
177
+ presets=PRESET_TEXTS,
178
+ default_preset=default_text,
179
+ initial_results=initial_results_html
180
+ )
181
+
182
+ @app.route('/classify', methods=['POST'])
183
+ def classify():
184
+ try:
185
+ text_input = request.form['text']
186
+
187
+ cleaned_text = text_input.strip()
188
+
189
+ if not cleaned_text:
190
+ return '''
191
+ <div class="result error">
192
+ <h2>Error: Please enter some text</h2>
193
+ </div>
194
+ '''
195
+
196
+ logger.info(f"Classifying: {cleaned_text[:50]}...")
197
+ return build_results_markup(cleaned_text)
198
+ except Exception as e:
199
+ logger.error(f"Classification error: {e}", exc_info=True)
200
+ return f'''
201
+ <div class="result error">
202
+ <h2>Error: {e}</h2>
203
+ </div>
204
+ '''
205
+
206
+ if __name__ == "__main__":
207
+ if app.debug:
208
+ logger.setLevel(logging.DEBUG)
209
+ app.run(debug=True)
static/index.css CHANGED
@@ -1,134 +1,134 @@
1
- body {
2
- font-family: 'Segoe UI', sans-serif;
3
- background: linear-gradient(135deg, #74ebd5, #9face6);
4
- display: flex;
5
- justify-content: center;
6
- align-items: center;
7
- height: 100vh;
8
- margin: 0;
9
- }
10
-
11
- .container {
12
- background-color: white;
13
- padding: 40px;
14
- border-radius: 15px;
15
- box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
16
- width: 90%;
17
- max-width: 1200px;
18
- text-align: center;
19
- }
20
-
21
- textarea {
22
- width: 100%;
23
- padding: 10px;
24
- font-size: 1rem;
25
- border-radius: 8px;
26
- border: 1px solid #ccc;
27
- resize: none;
28
- }
29
-
30
- button {
31
- background-color: #007bff;
32
- color: white;
33
- border: none;
34
- padding: 10px 20px;
35
- margin-top: 10px;
36
- border-radius: 8px;
37
- font-size: 1rem;
38
- cursor: pointer;
39
- }
40
-
41
- button:hover {
42
- background-color: #0056b3;
43
- }
44
-
45
- .result {
46
- margin-top: 20px;
47
- padding: 10px;
48
- border-radius: 8px;
49
- }
50
-
51
- .positive {
52
- color: #00953e;
53
- }
54
-
55
- .negative {
56
- color: #b00400;
57
- }
58
-
59
- .neutral {
60
- color: #c99e00;
61
- }
62
-
63
- .bird {
64
- width: 25px;
65
- }
66
-
67
- /* Results grid layout */
68
- .results-grid {
69
- display: grid;
70
- grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
71
- gap: 15px;
72
- margin-top: 20px;
73
- }
74
-
75
- .model-result {
76
- padding: 15px;
77
- border-radius: 8px;
78
- border: 2px solid;
79
- text-align: center;
80
- }
81
-
82
- .model-result h3 {
83
- margin: 0 0 10px 0;
84
- font-size: 1rem;
85
- color: #333;
86
- }
87
-
88
- .model-result .sentiment {
89
- font-size: 1.3rem;
90
- font-weight: bold;
91
- margin: 5px 0;
92
- }
93
-
94
- .model-result .confidence {
95
- font-size: 0.9rem;
96
- color: #666;
97
- margin: 5px 0;
98
- }
99
-
100
- /* Sentiment-specific background colors */
101
- .model-result.positive {
102
- background-color: #d4edda;
103
- border-color: #28a745;
104
- }
105
-
106
- .model-result.positive .sentiment {
107
- color: #00953e;
108
- }
109
-
110
- .model-result.negative {
111
- background-color: #f8d7da;
112
- border-color: #dc3545;
113
- }
114
-
115
- .model-result.negative .sentiment {
116
- color: #b00400;
117
- }
118
-
119
- .model-result.neutral {
120
- background-color: #fff3cd;
121
- border-color: #ffc107;
122
- }
123
-
124
- .model-result.neutral .sentiment {
125
- color: #c99e00;
126
- }
127
-
128
- .result.error {
129
- background-color: #f8d7da;
130
- border: 2px solid #dc3545;
131
- padding: 15px;
132
- border-radius: 8px;
133
- text-align: center;
134
- }
 
1
+ body {
2
+ background: linear-gradient(135deg, #74ebd5, #9face6);
3
+ }
4
+
5
+ .results-grid {
6
+ display: grid;
7
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
8
+ gap: 1rem;
9
+ margin-top: 1.25rem;
10
+ }
11
+
12
+ .model-result {
13
+ padding: 1.25rem;
14
+ border-radius: 1rem;
15
+ border: 1px solid #e2e8f0;
16
+ text-align: center;
17
+ background-color: #f8fafc;
18
+ background-image: linear-gradient(135deg, rgba(248, 250, 252, 0.85), rgba(241, 245, 249, 0.9));
19
+ box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05);
20
+ }
21
+
22
+ .model-result h3 {
23
+ margin: 0 0 0.75rem 0;
24
+ font-size: 1rem;
25
+ color: #1f2937;
26
+ }
27
+
28
+ .model-result .sentiment {
29
+ font-size: 1.2rem;
30
+ font-weight: 700;
31
+ margin: 0.5rem 0;
32
+ }
33
+
34
+ .model-result .confidence {
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ gap: 0.5rem;
39
+ margin: 0.5rem 0 0;
40
+ color: inherit;
41
+ }
42
+
43
+ .probability-label {
44
+ font-size: 0.65rem;
45
+ text-transform: uppercase;
46
+ letter-spacing: 0.08em;
47
+ opacity: 0.8;
48
+ }
49
+
50
+ .probability-badge {
51
+ display: inline-flex;
52
+ flex-direction: column;
53
+ align-items: center;
54
+ justify-content: center;
55
+ min-width: 4rem;
56
+ padding: 0.45rem 0.8rem;
57
+ text-align: center;
58
+ border-radius: 999px;
59
+ font-size: 0.9rem;
60
+ font-weight: 700;
61
+ border: 1px solid transparent;
62
+ background-color: #e2e8f0;
63
+ }
64
+
65
+ .probability-value {
66
+ font-size: 1.1rem;
67
+ line-height: 1.1;
68
+ }
69
+
70
+ .model-result.positive {
71
+ background-image: linear-gradient(135deg, rgba(222, 247, 236, 0.95), rgba(187, 247, 208, 0.9));
72
+ border-color: #86efac;
73
+ }
74
+
75
+ .model-result.positive .sentiment {
76
+ color: #047857;
77
+ }
78
+
79
+ .model-result.negative {
80
+ background-image: linear-gradient(135deg, rgba(254, 228, 226, 0.95), rgba(254, 202, 202, 0.9));
81
+ border-color: #fca5a5;
82
+ }
83
+
84
+ .model-result.negative .sentiment {
85
+ color: #b91c1c;
86
+ }
87
+
88
+ .model-result.neutral {
89
+ background-image: linear-gradient(135deg, rgba(254, 249, 195, 0.95), rgba(254, 240, 138, 0.9));
90
+ border-color: #facc15;
91
+ }
92
+
93
+ .model-result.neutral .sentiment {
94
+ color: #a16207;
95
+ }
96
+
97
+ .probability-definitely {
98
+ background-color: #bbf7d0;
99
+ border-color: #34d399;
100
+ color: #065f46;
101
+ }
102
+
103
+ .probability-probably {
104
+ background-color: #fde68a;
105
+ border-color: #facc15;
106
+ color: #854d0e;
107
+ }
108
+
109
+ .probability-maybe {
110
+ background-color: #e0e7ff;
111
+ border-color: #93c5fd;
112
+ color: #1e3a8a;
113
+ }
114
+
115
+ .probability-unknown {
116
+ background-color: #e2e8f0;
117
+ border-color: #cbd5f5;
118
+ color: #475569;
119
+ }
120
+
121
+ .result.error {
122
+ background-color: #fee2e2;
123
+ border: 1px solid #f87171;
124
+ padding: 1rem;
125
+ border-radius: 0.75rem;
126
+ text-align: center;
127
+ color: #7f1d1d;
128
+ }
129
+
130
+ .inference-meta {
131
+ font-size: 0.85rem;
132
+ color: #64748b;
133
+ text-align: right;
134
+ }
templates/index.html CHANGED
@@ -1,22 +1,133 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <title>Tweet Sentiment Classifier</title>
5
- <link rel="stylesheet" href="{{ url_for('static', filename='index.css') }}">
6
- <script src="https://unpkg.com/htmx.org@1.9.10"></script>
7
- </head>
8
- <body>
9
- <div class="container">
10
- <h1><img class="bird" src="../{{ url_for('static', filename='images/bird.png') }}"> Tweet Sentiment Classifier</h1>
11
- <p style="text-align: center; color: #666; margin-bottom: 20px;">
12
- </p>
13
- <form hx-post="/classify" hx-target="#result">
14
- <textarea name="text" rows="4" placeholder="Type or paste a tweet..." required></textarea>
15
- <br>
16
- <button type="submit">Analyze Sentiment</button>
17
- </form>
18
-
19
- <div id="result"></div>
20
- </div>
21
- </body>
22
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Tweet Sentiment Classifier</title>
6
+ <link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet" type="text/css" />
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="stylesheet" href="{{ url_for('static', filename='index.css') }}">
9
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
10
+ </head>
11
+ <body class="min-h-screen text-slate-700">
12
+ <main class="flex min-h-screen items-start justify-center px-4 py-8 sm:py-12 lg:items-center lg:max-h-screen">
13
+ <div class="w-full max-w-5xl">
14
+ <div class="rounded-3xl bg-white/95 shadow-xl ring-1 ring-slate-100 backdrop-blur lg:max-h-[90vh] lg:overflow-y-auto">
15
+ <div class="grid gap-8 p-6 md:p-8 lg:grid-cols-3 lg:p-10">
16
+ <section class="space-y-6 lg:col-span-2">
17
+ <header class="text-center lg:text-left">
18
+ <h1 class="text-3xl font-semibold text-slate-800">
19
+ <img class="mr-2 inline h-8 w-8 align-middle" src="../{{ url_for('static', filename='images/bird.png') }}" alt="bird icon">
20
+ Tweet Sentiment Classifier
21
+ </h1>
22
+ </header>
23
+
24
+ <form
25
+ class="space-y-4"
26
+ hx-post="/classify"
27
+ hx-target="#result"
28
+ hx-trigger="input changed delay:300ms from:textarea[name='text'], submit"
29
+ hx-sync="this:replace"
30
+ >
31
+ <div>
32
+ <textarea
33
+ id="text-input"
34
+ name="text"
35
+ rows="5"
36
+ placeholder="Type or paste a tweet..."
37
+ class="textarea textarea-bordered w-full resize-none rounded-2xl border border-slate-200 bg-slate-50/80 p-4 text-base text-slate-700 shadow-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-200"
38
+ required>{{ default_preset }}</textarea>
39
+ </div>
40
+ <div class="flex flex-wrap items-center gap-3">
41
+ <button type="submit" class="btn btn-primary rounded-full border-none bg-sky-500 px-6 text-white shadow hover:bg-sky-600">
42
+ Analyze Sentiment
43
+ </button>
44
+ <span class="text-sm text-slate-500">
45
+ Auto-analyze on
46
+ </span>
47
+ </div>
48
+ </form>
49
+ </section>
50
+
51
+ <aside class="rounded-2xl border border-slate-200 bg-slate-50/80 p-6 shadow-sm lg:self-start">
52
+ <h2 class="text-lg font-semibold text-slate-800">
53
+ Preset tweets
54
+ </h2>
55
+ <p class="mt-2 text-sm text-slate-500">
56
+ Tap to load a prompt instantly.
57
+ </p>
58
+ <ul class="mt-5 space-y-2">
59
+ {% for preset in presets %}
60
+ <li>
61
+ <button
62
+ type="button"
63
+ class="btn btn-sm w-full justify-start rounded-xl border border-transparent bg-white/70 text-left font-normal text-slate-600 shadow-sm transition hover:border-sky-200 hover:bg-sky-50"
64
+ data-preset-index="{{ loop.index0 }}"
65
+ >
66
+ {{ preset }}
67
+ </button>
68
+ </li>
69
+ {% endfor %}
70
+ </ul>
71
+ </aside>
72
+
73
+ <section class="lg:col-span-3">
74
+ <div id="result" class="space-y-4">{{ initial_results|safe }}</div>
75
+ </section>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </main>
80
+
81
+ <script>
82
+ const presets = {{ presets | tojson }};
83
+ const textarea = document.querySelector('#text-input');
84
+ const presetButtons = document.querySelectorAll('[data-preset-index]');
85
+
86
+ function setPreset(text, activeButton, options = {}) {
87
+ if (!textarea) {
88
+ return;
89
+ }
90
+
91
+ const { trigger = true, focus = true } = options;
92
+
93
+ textarea.value = text;
94
+
95
+ if (focus) {
96
+ textarea.focus({ preventScroll: true });
97
+ }
98
+
99
+ presetButtons.forEach((button) => {
100
+ button.classList.remove('btn-active', 'border-sky-300', 'bg-sky-100', 'text-sky-800');
101
+ button.setAttribute('aria-pressed', 'false');
102
+ });
103
+
104
+ if (activeButton) {
105
+ activeButton.classList.add('btn-active', 'border-sky-300', 'bg-sky-100', 'text-sky-800');
106
+ activeButton.setAttribute('aria-pressed', 'true');
107
+ }
108
+
109
+ if (trigger) {
110
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
111
+ textarea.dispatchEvent(new Event('change', { bubbles: true }));
112
+ }
113
+ }
114
+
115
+ presetButtons.forEach((button) => {
116
+ button.addEventListener('click', () => {
117
+ const index = Number.parseInt(button.dataset.presetIndex, 10);
118
+ const presetText = presets[index] ?? '';
119
+ setPreset(presetText, button);
120
+ });
121
+ });
122
+
123
+ window.addEventListener('load', () => {
124
+ if (!textarea) {
125
+ return;
126
+ }
127
+ const firstButton = presetButtons[0] ?? null;
128
+ const initialText = presets.length ? presets[0] : (textarea.value || '');
129
+ setPreset(initialText, firstButton, { trigger: false, focus: false });
130
+ });
131
+ </script>
132
+ </body>
133
+ </html>