Mohammed Thameem commited on
Commit
a2eaad1
Β·
1 Parent(s): 11a86dd

WIP before rebase

Browse files
Files changed (3) hide show
  1. .github/workflows/hf-sync.yml +19 -3
  2. app.py +264 -54
  3. tests/test_app.py +32 -0
.github/workflows/hf-sync.yml CHANGED
@@ -1,4 +1,4 @@
1
- name: Sync to Hugging Face hub
2
 
3
  on:
4
  push:
@@ -7,7 +7,7 @@ on:
7
  workflow_dispatch:
8
 
9
  jobs:
10
- sync-to-hub:
11
  runs-on: ubuntu-latest
12
  steps:
13
  - uses: actions/checkout@v3
@@ -15,7 +15,23 @@ jobs:
15
  fetch-depth: 0
16
  lfs: true
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  - name: Push to Hugging Face
 
19
  env:
20
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
21
  run: |
@@ -24,7 +40,7 @@ jobs:
24
  git push --force https://mlops-group8:$HF_TOKEN@huggingface.co/spaces/mlops-group8/case-study-1-local HEAD:main
25
 
26
  - name: Notify Slack (on success)
27
- if: success()
28
  uses: rtCamp/action-slack-notify@v2
29
  env:
30
  SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
 
1
+ name: Test and Sync to Hugging Face hub
2
 
3
  on:
4
  push:
 
7
  workflow_dispatch:
8
 
9
  jobs:
10
+ test-and-sync:
11
  runs-on: ubuntu-latest
12
  steps:
13
  - uses: actions/checkout@v3
 
15
  fetch-depth: 0
16
  lfs: true
17
 
18
+ - name: Set up Python
19
+ uses: actions/setup-python@v4
20
+ with:
21
+ python-version: '3.10'
22
+
23
+ - name: Install dependencies
24
+ run: |
25
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
26
+ pip install pytest
27
+
28
+ - name: Run tests
29
+ env:
30
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
31
+ run: PYTHONPATH=. pytest --maxfail=1 --disable-warnings -q
32
+
33
  - name: Push to Hugging Face
34
+ if: success() # only run if tests pass
35
  env:
36
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
37
  run: |
 
40
  git push --force https://mlops-group8:$HF_TOKEN@huggingface.co/spaces/mlops-group8/case-study-1-local HEAD:main
41
 
42
  - name: Notify Slack (on success)
43
+ if: success()
44
  uses: rtCamp/action-slack-notify@v2
45
  env:
46
  SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
app.py CHANGED
@@ -4,87 +4,297 @@ import gradio as gr
4
  from huggingface_hub import login
5
  from transformers import AutoTokenizer, AutoModelForCausalLM
6
 
 
7
  HF_TOKEN = os.getenv("HF_TOKEN")
8
  if not HF_TOKEN:
9
  raise RuntimeError("HF_TOKEN not found. In Spaces, add it under Settings β†’ Repository secrets.")
10
 
11
  login(token=HF_TOKEN)
12
 
13
- MODEL_ID = "meta-llama/Llama-3.2-1B"
14
  DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
15
 
16
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN)
 
 
 
 
 
17
  model = AutoModelForCausalLM.from_pretrained(
18
  MODEL_ID,
19
  token=HF_TOKEN,
20
- torch_dtype=torch.float32,
21
  ).to(DEVICE)
22
  model.eval()
23
 
24
- # Ensure Llama 3 chat template exists
25
  if tokenizer.chat_template is None:
26
- tokenizer.chat_template = """<|begin_of_text|>{% for message in messages %}{% if message['role'] == 'user' %}{{ '<|start_header_id|>user<|end_header_id|>\n\n' + message['content'] + '<|eot_id|>' }}{% elif message['role'] == 'system' %}{{ '<|start_header_id|>system<|end_header_id|>\n\n' + message['content'] + '<|eot_id|>' }}{% elif message['role'] == 'assistant' %}{{ '<|start_header_id|>assistant<|end_header_id|>\n\n' + message['content'] + '<|eot_id|>' }}{% endif %}{% endfor %}{% if add_generation_prompt %}{{ '<|start_header_id|>assistant<|end_header_id|>\n\n' }}{% endif %}"""
 
 
 
 
 
 
 
27
 
28
- def _get_eot_id(tok):
29
- tid = tok.convert_tokens_to_ids("<|eot_id|>")
30
- return tid if isinstance(tid, int) and tid >= 0 else (tok.eos_token_id or 0)
 
31
 
32
- EOT_ID = _get_eot_id(tokenizer)
33
- PAD_ID = tokenizer.eos_token_id if tokenizer.eos_token_id is not None else 0
 
 
 
34
 
35
- SYSTEM_PROMPT = (
36
- "You are Sustainable.ai, a friendly, encouraging assistant. "
37
- "Offer simple, practical, sustainable alternatives to everyday actions. "
38
- "Be supportive and never judgmental."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  )
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  @torch.inference_mode()
42
- def chat(message, history):
43
- messages = [{"role": "system", "content": SYSTEM_PROMPT}]
44
- for u, a in history:
45
- if u:
46
- messages.append({"role": "user", "content": u})
47
- if a:
48
- messages.append({"role": "assistant", "content": a})
49
- messages.append({"role": "user", "content": message})
50
-
51
- prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
52
- inputs = tokenizer(prompt, return_tensors="pt")
53
- inputs = {k: v.to(DEVICE) for k, v in inputs.items()}
54
 
55
- outputs = model.generate(
56
- **inputs,
57
- max_new_tokens=200,
58
- do_sample=True,
59
- temperature=0.7,
60
- top_p=0.9,
61
- eos_token_id=EOT_ID,
62
- pad_token_id=PAD_ID,
63
  )
64
 
65
- new_tokens = outputs[0, inputs["input_ids"].shape[1]:]
66
- return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
67
-
68
- # Create the app with graceful fallback for older Gradio
69
- try:
70
- demo = gr.ChatInterface(
71
- fn=chat,
72
- title="Sustainable.ai 🌿",
73
- description="Tell me what you plan to do, and I’ll suggest a simpler, greener alternative.",
74
- submit_btn="Suggest",
75
- retry_btn="Regenerate", # only available on newer Gradio
76
- clear_btn="Clear", # may also be unavailable on older versions
77
- )
78
- except TypeError:
79
- # Older Gradio versions
80
- demo = gr.ChatInterface(
81
- fn=chat,
82
- title="Sustainable.ai 🌿",
83
- description="Tell me what you plan to do, and I’ll suggest a simpler, greener alternative.",
84
- # use defaults for buttons
85
  )
86
 
87
- demo = demo.queue(max_size=32, default_concurrency=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  if __name__ == "__main__":
90
  demo.launch()
 
4
  from huggingface_hub import login
5
  from transformers import AutoTokenizer, AutoModelForCausalLM
6
 
7
+ # ========================== Setup ==========================
8
  HF_TOKEN = os.getenv("HF_TOKEN")
9
  if not HF_TOKEN:
10
  raise RuntimeError("HF_TOKEN not found. In Spaces, add it under Settings β†’ Repository secrets.")
11
 
12
  login(token=HF_TOKEN)
13
 
14
+ MODEL_ID = os.getenv("MODEL_ID", "google/gemma-3-270m-it")
15
  DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
16
 
17
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN)
18
+
19
+ # If pad is missing, map to eos
20
+ if tokenizer.pad_token_id is None and tokenizer.eos_token_id is not None:
21
+ tokenizer.pad_token = tokenizer.eos_token
22
+
23
  model = AutoModelForCausalLM.from_pretrained(
24
  MODEL_ID,
25
  token=HF_TOKEN,
26
+ torch_dtype=(torch.bfloat16 if torch.cuda.is_available() else torch.float32),
27
  ).to(DEVICE)
28
  model.eval()
29
 
30
+ # Use model's provided chat template if present; otherwise a minimal one.
31
  if tokenizer.chat_template is None:
32
+ tokenizer.chat_template = """{% for message in messages -%}
33
+ <start_of_turn>{{ message['role'] }}
34
+ {{ message['content'] }}<end_of_turn>
35
+ {% endfor -%}{% if add_generation_prompt %}<start_of_turn>assistant
36
+ {% endif %}"""
37
+
38
+ EOS_ID = tokenizer.eos_token_id
39
+ PAD_ID = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else (EOS_ID or 0)
40
 
41
+ # Detect which assistant role the template expects.
42
+ # Many Gemma-3 templates use "assistant"; some forks use "model".
43
+ TEMPLATE_STR = tokenizer.chat_template or ""
44
+ ASSISTANT_ROLE = "assistant" if "assistant" in TEMPLATE_STR else "model"
45
 
46
+ # ================== Sustainability Logic ===================
47
+ EMISSIONS_FACTORS = {
48
+ "transportation": {"car": 2.3, "bus": 0.1, "train": 0.04, "plane": 0.25}, # kg CO2 per km
49
+ "food": {"meat": 6.0, "vegetarian": 1.5, "vegan": 1.0}, # kg CO2 per meal
50
+ }
51
 
52
+ def calculate_footprint(car_km, bus_km, train_km, air_km_week, meat_meals, vegetarian_meals, vegan_meals):
53
+ transport_emissions = (
54
+ car_km * EMISSIONS_FACTORS["transportation"]["car"] +
55
+ bus_km * EMISSIONS_FACTORS["transportation"]["bus"] +
56
+ train_km * EMISSIONS_FACTORS["transportation"]["train"] +
57
+ air_km_week * EMISSIONS_FACTORS["transportation"]["plane"]
58
+ )
59
+ food_emissions = (
60
+ meat_meals * EMISSIONS_FACTORS["food"]["meat"] +
61
+ vegetarian_meals * EMISSIONS_FACTORS["food"]["vegetarian"] +
62
+ vegan_meals * EMISSIONS_FACTORS["food"]["vegan"]
63
+ )
64
+ total_emissions = transport_emissions + food_emissions
65
+ stats = {
66
+ "trees": round(total_emissions / 21), # playful rough equivalents
67
+ "flights": round(total_emissions / 500),
68
+ "driving100km": round(total_emissions / 230),
69
+ }
70
+ return total_emissions, stats
71
+
72
+ GUIDANCE = (
73
+ "You are Sustainable.ai. Give practical, encouraging sustainability alternatives only.\n"
74
+ "Constraints:\n"
75
+ "1) Reply in 3 to 6 short bullet points.\n"
76
+ "2) Include a rough CO2 saving per bullet.\n"
77
+ "3) No moralizing.\n"
78
+ "4) Offer 1 easy switch, 1 medium switch, 1 stretch goal.\n"
79
+ )
80
+
81
+ GEN_KW = dict(
82
+ max_new_tokens=256,
83
+ do_sample=False, # deterministic for stability
84
+ temperature=0.0,
85
+ repetition_penalty=1.05,
86
+ eos_token_id=EOS_ID,
87
+ pad_token_id=PAD_ID,
88
  )
89
 
90
+ # ======================= Utilities ========================
91
+ def _to_float(x, default=0.0):
92
+ try:
93
+ return float(x)
94
+ except Exception:
95
+ return float(default)
96
+
97
+ def _add(conv, role, content):
98
+ """Append a role/content pair if content is non-empty."""
99
+ if not content:
100
+ return
101
+ # Map roles to the template's expected assistant role
102
+ if role == "assistant":
103
+ role = ASSISTANT_ROLE
104
+ elif role == "system":
105
+ # Gemma templates often do not support 'system'; treat as user context
106
+ role = "user"
107
+ elif role not in ("user", "assistant", "model"):
108
+ role = "user"
109
+ conv.append({"role": role, "content": str(content)})
110
+
111
+ def _normalize_from_history(history, conv):
112
+ """
113
+ history may be:
114
+ - list[tuple(user, assistant)]
115
+ - list[dict(role, content)]
116
+ """
117
+ if not isinstance(history, list):
118
+ return
119
+ for item in history:
120
+ if isinstance(item, tuple) and len(item) == 2:
121
+ u, a = item
122
+ if u:
123
+ _add(conv, "user", u)
124
+ if a:
125
+ _add(conv, "assistant", a)
126
+ elif isinstance(item, dict):
127
+ _add(conv, item.get("role", "user"), item.get("content", ""))
128
+
129
+ def _normalize_from_messages(messages, conv):
130
+ """
131
+ messages may be:
132
+ - list[dict(role, content)]
133
+ - list[str]
134
+ - str
135
+ - None
136
+ """
137
+ if messages is None:
138
+ return
139
+ if isinstance(messages, list):
140
+ # If dicts, use them; if strings, treat each as a user turn
141
+ for m in messages:
142
+ if isinstance(m, dict):
143
+ _add(conv, m.get("role", "user"), m.get("content", ""))
144
+ elif isinstance(m, str):
145
+ _add(conv, "user", m)
146
+ elif isinstance(messages, str):
147
+ _add(conv, "user", messages)
148
+
149
+ def _merge_consecutive_same_role(conv):
150
+ """Merge consecutive same-role messages to satisfy strict alternation."""
151
+ if not conv:
152
+ return conv
153
+ merged = [conv[0]]
154
+ for msg in conv[1:]:
155
+ if msg["role"] == merged[-1]["role"]:
156
+ merged[-1]["content"] = (merged[-1]["content"].rstrip() + "\n\n" + msg["content"].lstrip())
157
+ else:
158
+ merged.append(msg)
159
+ return merged
160
+
161
+ def _ensure_last_is_user(conv):
162
+ """
163
+ For add_generation_prompt=True, the template expects the last message to be a user turn.
164
+ If the last is assistant/model, append a light user nudge.
165
+ """
166
+ if not conv:
167
+ return [{"role": "user", "content": "Please respond."}]
168
+ last_role = conv[-1]["role"]
169
+ if last_role in ("assistant", "model"):
170
+ conv.append({"role": "user", "content": "Continue."})
171
+ return conv
172
+
173
+ # ===================== Chat Function ======================
174
+ # Be tolerant to Gradio shapes: (messages, history, ...) or (message, history, ...)
175
  @torch.inference_mode()
176
+ def chat(messages=None, history=None, car_km=0, bus_km=0, train_km=0, air_km_month=0, meat_meals=0, vegetarian_meals=0, vegan_meals=0, *args):
177
+ # Convert monthly air travel to weekly to keep units consistent
178
+ air_km_week = _to_float(air_km_month) / 4.3
 
 
 
 
 
 
 
 
 
179
 
180
+ footprint, stats = calculate_footprint(
181
+ _to_float(car_km), _to_float(bus_km), _to_float(train_km), air_km_week,
182
+ _to_float(meat_meals), _to_float(vegetarian_meals), _to_float(vegan_meals)
 
 
 
 
 
183
  )
184
 
185
+ context = (
186
+ f"User’s estimated weekly footprint: {footprint:.1f} kg CO2.\n"
187
+ f"Equivalents: about {stats['trees']} trees or {stats['flights']} short flights.\n"
188
+ "Help them lower this number."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  )
190
 
191
+ # Build conversation seed with guidance folded into the FIRST user turn.
192
+ conv = []
193
+
194
+ # Prefer Gradio messages if they are structured; otherwise use history.
195
+ # We'll assemble a provisional conv, then fold guidance in.
196
+ provisional = []
197
+ _normalize_from_history(history, provisional)
198
+ _normalize_from_messages(messages, provisional)
199
+
200
+ # If first message exists and is a user turn, prepend guidance+context to that same message.
201
+ guidance_block = GUIDANCE + "\n" + context
202
+ if provisional and provisional[0]["role"] == "user":
203
+ provisional[0]["content"] = guidance_block + "\n\n" + provisional[0]["content"]
204
+ else:
205
+ # Start with a user turn containing guidance and context
206
+ provisional.insert(0, {"role": "user", "content": guidance_block})
207
+
208
+ # Merge consecutive same-role messages to satisfy alternation
209
+ conv = _merge_consecutive_same_role(provisional)
210
+
211
+ # Ensure final message is a user turn for add_generation_prompt=True
212
+ conv = _ensure_last_is_user(conv)
213
+
214
+ # Apply chat template
215
+ prompt = tokenizer.apply_chat_template(conv, tokenize=False, add_generation_prompt=True)
216
+ inputs = tokenizer(prompt, return_tensors="pt")
217
+ inputs = {k: v.to(DEVICE) for k, v in inputs.items()}
218
+
219
+ # Generate
220
+ outputs = model.generate(**inputs, **GEN_KW)
221
+ new_tokens = outputs[0, inputs["input_ids"].shape[1]:]
222
+ text = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
223
+
224
+ # Light formatting nudge toward bullets
225
+ if not any(ch in text for ch in ("β€’", "-", "*")):
226
+ lines = [l.strip() for l in text.split("\n") if l.strip()]
227
+ if lines:
228
+ text = "\n".join(f"β€’ {l}" for l in lines[:6])
229
+
230
+ return text
231
+
232
+ # ========================== UI ============================
233
+ with gr.Blocks(css="""
234
+ body {
235
+ background: linear-gradient(135deg, #e0f7fa, #f1f8e9);
236
+ font-family: 'Inter', sans-serif;
237
+ }
238
+ .section-card {
239
+ background: white;
240
+ padding: 20px;
241
+ border-radius: 15px;
242
+ box-shadow: 0px 4px 12px rgba(0,0,0,0.1);
243
+ margin-bottom: 20px;
244
+ }
245
+ .title-text {
246
+ text-align: center;
247
+ font-size: 32px;
248
+ font-weight: bold;
249
+ color: #1b5e20;
250
+ margin-bottom: 5px;
251
+ }
252
+ .subtitle-text {
253
+ text-align: center;
254
+ font-size: 16px;
255
+ color: #444;
256
+ margin-bottom: 30px;
257
+ }
258
+ footer {
259
+ text-align: center;
260
+ font-size: 12px;
261
+ color: #666;
262
+ margin-top: 20px;
263
+ }
264
+ """) as demo:
265
+ with gr.Column():
266
+ gr.HTML("<div class='title-text'>🌍 Eco Wise AI</div>")
267
+ gr.HTML("<div class='subtitle-text'>Track your weekly habits and get personalized sustainability tips 🌱</div>")
268
+
269
+ with gr.Row():
270
+ with gr.Group(elem_classes="section-card"):
271
+ gr.Markdown("### πŸš— Transportation (per week)")
272
+ car_input = gr.Number(label="🚘 Car Travel (km)", value=0)
273
+ bus_input = gr.Number(label="🚌 Bus Travel (km)", value=0)
274
+ train_input = gr.Number(label="πŸš† Train Travel (km)", value=0)
275
+ air_input = gr.Number(label="✈️ Air Travel (km/month)", value=0)
276
+
277
+ with gr.Group(elem_classes="section-card"):
278
+ gr.Markdown("### 🍽️ Food Habits (per week)")
279
+ meat_input = gr.Number(label="πŸ₯© Meat Meals", value=0)
280
+ vegetarian_input = gr.Number(label="πŸ₯— Vegetarian Meals", value=0)
281
+ vegan_input = gr.Number(label="🌱 Vegan Meals", value=0)
282
+
283
+ with gr.Group(elem_classes="section-card"):
284
+ gr.Markdown("### πŸ’¬ Chat with Sustainable.ai")
285
+ chatbot = gr.ChatInterface(
286
+ fn=chat,
287
+ type="messages", # role/content dicts when available
288
+ additional_inputs=[
289
+ car_input, bus_input, train_input, air_input,
290
+ meat_input, vegetarian_input, vegan_input
291
+ ],
292
+ )
293
+
294
+ gr.HTML("<footer>⚑ Built with Gemma 3 270M IT & Gradio β€’ Eco Wise AI Β© 2025</footer>")
295
+
296
+ # Queue with concurrency control
297
+ demo = demo.queue(max_size=32, default_concurrency_limit=2)
298
 
299
  if __name__ == "__main__":
300
  demo.launch()
tests/test_app.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
3
+
4
+ import app
5
+ import types
6
+ import app
7
+
8
+
9
+ def test_respond_function_exists():
10
+ """Check that the app has a respond() function."""
11
+ assert hasattr(app, "respond")
12
+ assert callable(app.respond)
13
+
14
+
15
+ def test_respond_returns_generator():
16
+ """respond() should return a generator when called with minimal args."""
17
+ # Fake OAuthToken object for testing (since we don't want to call real HF API in CI)
18
+ class DummyToken:
19
+ token = "dummy"
20
+
21
+ gen = app.respond(
22
+ message="I'm buying a bottle of water.",
23
+ history=[],
24
+ system_message="You are Sustainable.ai.",
25
+ max_tokens=10,
26
+ temperature=0.7,
27
+ top_p=0.9,
28
+ hf_token=DummyToken(),
29
+ )
30
+
31
+ # respond() is a generator, not a plain string
32
+ assert isinstance(gen, types.GeneratorType)