Spaces:
Runtime error
Runtime error
Mohammed Thameem commited on
Commit Β·
a2eaad1
1
Parent(s): 11a86dd
WIP before rebase
Browse files- .github/workflows/hf-sync.yml +19 -3
- app.py +264 -54
- 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 |
-
|
| 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 = "
|
| 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 |
-
#
|
| 25 |
if tokenizer.chat_template is None:
|
| 26 |
-
tokenizer.chat_template = """
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
)
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
@torch.inference_mode()
|
| 42 |
-
def chat(
|
| 43 |
-
|
| 44 |
-
|
| 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 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|