OpelSpeedster commited on
Commit
deb0435
Β·
verified Β·
1 Parent(s): d9a3c0e

Upload 7 files

Browse files
Files changed (7) hide show
  1. .env.example +20 -0
  2. AGENTS.md +114 -0
  3. README.md +157 -7
  4. app.py +926 -0
  5. ecoagent-plan.md +271 -0
  6. requirements.txt +18 -0
  7. watsonx_client.py +520 -0
.env.example ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EcoAgent β€” environment variable template
2
+ # Copy this file to .env and fill in your values.
3
+ # NEVER commit .env with real API keys to version control.
4
+
5
+ # IBM Cloud API Key
6
+ # Get from: https://cloud.ibm.com β†’ Manage β†’ Access (IAM) β†’ API keys
7
+ WATSONX_API_KEY=your-ibm-cloud-api-key-here
8
+
9
+ # watsonx.ai endpoint β€” choose your region:
10
+ # eu-de (Frankfurt): https://eu-de.ml.cloud.ibm.com <- default
11
+ # us-south (Dallas): https://us-south.ml.cloud.ibm.com
12
+ # eu-gb (London): https://eu-gb.ml.cloud.ibm.com
13
+ # jp-tok (Tokyo): https://jp-tok.ml.cloud.ibm.com
14
+ # au-syd (Sydney): https://au-syd.ml.cloud.ibm.com
15
+ WATSONX_URL= your-region-endpoint
16
+
17
+ # watsonx.ai Studio Project ID (UUID)
18
+ # Get from: https://eu-de.dataplatform.cloud.ibm.com
19
+ # β†’ open your project β†’ Manage tab β†’ General β†’ Project ID
20
+ WATSONX_PROJECT_ID=your-project-id-uuid-here
AGENTS.md ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to agents when working with code in this repository.
4
+
5
+ ## Project
6
+
7
+ EcoAgent v2 β€” A Gradio Blocks web app for eco lifestyle advice, powered by **IBM Granite** (`ibm/granite-4-h-small`) via the `ibm-watsonx-ai` SDK. India-focused with 4 tabs: Chat, Dashboard, Recycling Guide, Household Profile.
8
+
9
+ **Classification:** Prompt-Engineered LLM Application (not RAG, not Agentic AI)
10
+ **IBM Orchestrate REST API is NOT used** β€” direct SDK calls to watsonx.ai only.
11
+
12
+ ## Stack
13
+
14
+ - **Runtime:** Python 3.14 via `uv` (venv at `.venv/`)
15
+ - **Package manager:** `uv` β€” always use `uv pip install` / `uv run python`, NOT bare `pip` or `python`
16
+ - **UI Framework:** Gradio 6.20 (ultra-light eco green theme)
17
+ - **AI Model:** IBM Granite 4 H Small (`ibm/granite-4-h-small`) via watsonx.ai eu-de
18
+ - **SDK:** `ibm-watsonx-ai` >= 1.1.15 (APIClient + ModelInference pattern)
19
+ - **System Python is 3.11 (Miniconda)** β€” unrelated to this project's venv
20
+ - **Target deploy:** Hugging Face Spaces (Gradio SDK)
21
+
22
+ ## Key Commands
23
+
24
+ ```bash
25
+ uv pip install -r requirements.txt # install deps
26
+ uv run python app.py # run locally β†’ http://localhost:7860
27
+ ```
28
+
29
+ ## Critical Gotchas
30
+
31
+ - **`.env` is gitignored** β€” cannot be written by file tools. Use `Set-Content` PowerShell command instead.
32
+ - **`WATSONX_PROJECT_ID` is mandatory** β€” the SDK raises an error without it. Get it from: `https://eu-de.dataplatform.cloud.ibm.com` β†’ project β†’ Manage β†’ General β†’ Project ID.
33
+ - **Region is eu-de (Frankfurt)** β€” `WATSONX_URL=https://eu-de.ml.cloud.ibm.com`. Do not use `us-south`.
34
+ - **Model lazy-init** β€” `_get_model()` in `watsonx_client.py` initialises `ModelInference` on the first call, not at import. Import-time errors = missing env vars. First-call errors = bad project ID or model access.
35
+ - **`load_dotenv(".env")` explicit path** β€” both `app.py` and `watsonx_client.py` call this. The default `load_dotenv()` looks for `.env` by name; the explicit path ensures it works regardless of CWD.
36
+ - **Chat history format (Gradio 6.x)** β€” History uses structured content blocks: `{"role": "user", "content": [{"type": "text", "text": "..."}]}`. The `chat_submit()` function extracts text for the API call and returns structured blocks for display.
37
+ - **`dashboard_html` is defined inside the Tab 2 block** β€” it's referenced by `save_profile()` in Tab 4. Both must be inside the same `gr.Blocks` context.
38
+ - **Theme/CSS in Gradio 6.x** β€” `theme` and `css` parameters go in `demo.launch()`, NOT in `gr.Blocks()` constructor.
39
+ - **SSL_CERT_FILE** β€” The app auto-detects and sets the correct certifi path on startup.
40
+
41
+ ## Architecture
42
+
43
+ ```
44
+ .env
45
+ └─ WATSONX_API_KEY, WATSONX_PROJECT_ID, WATSONX_URL
46
+ β”‚
47
+ β–Ό
48
+ watsonx_client.py
49
+ β”œβ”€ AGENT_INSTRUCTIONS ← 86-line system prompt (persona, tone, rules, format)
50
+ β”œβ”€ IMPACT_TABLE ← 20 actions with CO2/water/waste lookup values
51
+ β”œβ”€ PRODUCT_RECS ← static eco-product recs per material category
52
+ β”œβ”€ INDIAN_CITIES ← 15 major Indian cities for recycling guide
53
+ β”œβ”€ _get_model() ← lazy-init APIClient + ModelInference ( Granite 4 H Small )
54
+ β”œβ”€ get_eco_answer() ← multi-turn chat, builds [system]+messages list
55
+ β”œβ”€ get_recycling_guide() ← single-turn recycling lookup call
56
+ └─ compute_session_impact() ← aggregates logged actions β†’ metric dict
57
+ β”‚
58
+ β–Ό
59
+ app.py (Gradio Blocks β€” ultra-light theme)
60
+ β”œβ”€ Tab 1: Chat ← chatbot + action chip logger + eco score ring
61
+ β”œβ”€ Tab 2: Dashboard ← HTML metric cards from compute_session_impact()
62
+ β”œβ”€ Tab 3: Recycling ← material+city dropdowns β†’ get_recycling_guide()
63
+ └─ Tab 4: Profile ← household form β†’ profile_state (gr.State)
64
+ ```
65
+
66
+ ## Environment Variables
67
+
68
+ | Variable | Source | Purpose |
69
+ |---|---|---|
70
+ | `WATSONX_API_KEY` | `.env.example` | IBM Cloud API key |
71
+ | `WATSONX_PROJECT_ID` | watsonx.ai Studio | SDK project scope β€” required |
72
+ | `WATSONX_URL` | `https://eu-de.ml.cloud.ibm.com` | eu-de watsonx.ai endpoint |
73
+
74
+ ## Prompt Engineering Pattern
75
+
76
+ This project uses **prompt engineering** to transform IBM Granite into a domain-specific eco advisor:
77
+
78
+ - **System Prompt:** 86-line `AGENT_INSTRUCTIONS` defining persona, output format, focus areas, and guardrails
79
+ - **Static Knowledge:** `IMPACT_TABLE` (20 eco actions) and `PRODUCT_RECS` (8 material categories) injected via prompt
80
+ - **Dynamic Context:** Household profile (members, location, habits) injected per session
81
+ - **Output Format:** Fixed 4-part structure (Quick Tip β†’ Why it Matters β†’ Impact β†’ Optional Resource)
82
+ - **Guardrails:** Never invent stats, label [Lookup] vs [Estimate], no medical/financial advice
83
+
84
+ ## UI Theme
85
+
86
+ Ultra-light eco green theme:
87
+ - Background: `#fcfcfd` (near white)
88
+ - Primary accent: `#2e7d50` (green)
89
+ - Cards: `#ffffff` with subtle shadows
90
+ - Text: `#1c1c1e` (near black), muted: `#4a4a4e`
91
+ - Borders: `#e4e4e7` (light gray)
92
+ - CheckboxGroup styled as selectable pills/chips
93
+ - Chatbot with welcome placeholder and white-flash prevention CSS
94
+
95
+ ## Project Files
96
+
97
+ | File | Purpose |
98
+ |---|---|
99
+ | `app.py` | Gradio Blocks UI β€” 4 tabs, callbacks, CSS theme |
100
+ | `watsonx_client.py` | IBM watsonx.ai SDK wrapper, agent config, impact data |
101
+ | `requirements.txt` | Pinned Python dependencies (4 packages) |
102
+ | `.env` | Local credentials (gitignored) |
103
+ | `.env.example` | Template β€” safe to commit |
104
+ | `AGENTS.md` | This file β€” agent guidance |
105
+ | `README.md` | HF Spaces front-matter, setup instructions |
106
+ | `ecoagent-plan.md` | Implementation plan and architecture decisions |
107
+ | `architecture.png` | Architecture blueprint diagram |
108
+ | `fill.txt` | PPT content fill for presentation |
109
+
110
+ ## Deleted Files (v1 β†’ v2)
111
+
112
+ - `rag_pipeline.py` β†’ replaced by `watsonx_client.py`
113
+ - `ibm-credentials.env` β†’ replaced by `.env`
114
+ - `embed.txt` β†’ was a debugging artifact; deleted
README.md CHANGED
@@ -1,14 +1,164 @@
1
  ---
2
  title: EcoAgent
3
- emoji: 🏒
4
- colorFrom: red
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- short_description: Agent adopting a greener lifestyle for users
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: EcoAgent
3
+ emoji: 🌱
4
+ colorFrom: green
5
+ colorTo: pink
6
  sdk: gradio
7
+ sdk_version: "6.20.0"
 
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
+ # 🌱 EcoAgent β€” AI-Powered Eco Lifestyle Assistant
13
+
14
+ An India-focused sustainable living assistant powered by **IBM Granite** via **watsonx.ai**. Chat about eco habits, explore a household impact dashboard, look up local recycling guides, and build a family sustainability profile.
15
+
16
+ **Classification:** Prompt-Engineered LLM Application β€” not RAG, not Agentic AI.
17
+
18
+ ---
19
+
20
+ ## Features
21
+
22
+ | Tab | What it does |
23
+ |---|---|
24
+ | πŸ’¬ **Chat** | Multi-turn conversation with IBM Granite β€” personalised eco tips, government schemes, impact estimates |
25
+ | πŸ“Š **Dashboard** | Session-based eco score (0–100), COβ‚‚/water/waste savings tracker, household summary |
26
+ | ♻️ **Recycling Guide** | City-specific recycling instructions for 8 material categories + eco-friendly product alternatives |
27
+ | 🏑 **Profile** | Household members, Indian city, current eco habits β€” personalises all chat responses |
28
+
29
+ ---
30
+
31
+ ## Architecture
32
+
33
+ ```
34
+ .env (WATSONX_API_KEY, WATSONX_PROJECT_ID, WATSONX_URL)
35
+ β”‚
36
+ β–Ό
37
+ watsonx_client.py ← IBM Granite ModelInference, AGENT_INSTRUCTIONS, IMPACT_TABLE
38
+ β”‚
39
+ β–Ό
40
+ app.py ← Gradio Blocks (4 tabs, ultra-light eco green theme, session state)
41
+ ```
42
+
43
+ **Model:** `ibm/granite-4-h-small` (eu-de region, watsonx.ai)
44
+ **Auth:** IBM Cloud API key β†’ watsonx.ai SDK (no Orchestrate REST API)
45
+
46
+ ---
47
+
48
+ ## How It Works
49
+
50
+ EcoAgent uses **prompt engineering** to transform IBM Granite into a domain-specific eco advisor:
51
+
52
+ 1. **System Prompt:** 86-line `AGENT_INSTRUCTIONS` defining persona, output format, focus areas, and guardrails
53
+ 2. **Static Knowledge:** `IMPACT_TABLE` (20 eco actions) and `PRODUCT_RECS` (8 material categories) injected via prompt
54
+ 3. **Dynamic Context:** Household profile (members, location, habits) injected per session
55
+ 4. **Output Format:** Fixed 4-part structure (Quick Tip β†’ Why it Matters β†’ Impact β†’ Optional Resource)
56
+ 5. **Guardrails:** Never invent stats, label [Lookup] vs [Estimate], no medical/financial advice
57
+
58
+ ---
59
+
60
+ ## UI Theme
61
+
62
+ Ultra-light eco green theme with near-white background and green accents:
63
+
64
+ - Background: `#fcfcfd` (near white)
65
+ - Primary accent: `#2e7d50` (green)
66
+ - Cards: `#ffffff` with subtle shadows
67
+ - Text: `#1c1c1e` (near black), muted: `#4a4a4e`
68
+ - Borders: `#e4e4e7` (light gray)
69
+ - CheckboxGroup styled as selectable pills/chips
70
+
71
+ ---
72
+
73
+ ## Environment Variables
74
+
75
+ Set these as **Secrets** in your Hugging Face Space (Settings β†’ Variables and Secrets)
76
+ or in a local `.env` file (never commit with real values).
77
+
78
+ | Variable | Required | Description |
79
+ |---|---|---|
80
+ | `WATSONX_API_KEY` | βœ… | IBM Cloud API key β€” [get one here](https://cloud.ibm.com/iam/apikeys) |
81
+ | `WATSONX_PROJECT_ID` | βœ… | watsonx.ai Studio Project ID (UUID) β€” see below |
82
+ | `WATSONX_URL` | βœ… | watsonx.ai endpoint for your region β€” default `https://eu-de.ml.cloud.ibm.com` |
83
+
84
+ ### How to get `WATSONX_PROJECT_ID`
85
+
86
+ 1. Go to [https://eu-de.dataplatform.cloud.ibm.com](https://eu-de.dataplatform.cloud.ibm.com)
87
+ 2. Create or open a project
88
+ 3. Click the **Manage** tab β†’ **General** section
89
+ 4. Copy the **Project ID** (a UUID like `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`)
90
+ 5. Add it to `.env` as `WATSONX_PROJECT_ID=<your-uuid>`
91
+
92
+ ### Regional watsonx.ai URLs
93
+
94
+ | Region | URL |
95
+ |---|---|
96
+ | EU Frankfurt (default) | `https://eu-de.ml.cloud.ibm.com` |
97
+ | US Dallas | `https://us-south.ml.cloud.ibm.com` |
98
+ | UK London | `https://eu-gb.ml.cloud.ibm.com` |
99
+ | Japan Tokyo | `https://jp-tok.ml.cloud.ibm.com` |
100
+ | Australia Sydney | `https://au-syd.ml.cloud.ibm.com` |
101
+
102
+ ---
103
+
104
+ ## Local Development
105
+
106
+ ### Prerequisites
107
+ - Python 3.10+ (Python 3.14 via `uv` is configured in `.venv`)
108
+ - [`uv`](https://github.com/astral-sh/uv) (recommended) or `pip`
109
+
110
+ ### Steps
111
+
112
+ ```bash
113
+ # 1. Clone the repo
114
+ git clone https://huggingface.co/spaces/<your-username>/ecoagent
115
+ cd ecoagent
116
+
117
+ # 2. Install dependencies
118
+ uv pip install -r requirements.txt
119
+ # or: pip install -r requirements.txt
120
+
121
+ # 3. Configure credentials
122
+ # Copy .env.example to .env and fill in your values:
123
+ cp .env.example .env
124
+ # Edit .env β€” set WATSONX_API_KEY, WATSONX_PROJECT_ID, WATSONX_URL
125
+
126
+ # 4. Run
127
+ uv run python app.py
128
+ # β†’ Open http://localhost:7860
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Customising the Agent
134
+
135
+ Open [`watsonx_client.py`](watsonx_client.py) and edit the `AGENT_INSTRUCTIONS` constant at the top of the file. You can change:
136
+
137
+ - **Persona & tone** β€” make it more formal, more playful, multilingual, etc.
138
+ - **Focus areas** β€” add specific sustainability topics (e.g. marine conservation)
139
+ - **India-specific context** β€” add regional schemes, local brands, city-specific advice
140
+ - **Safety rules** β€” tighten or relax what the agent will/won't say
141
+ - **Answer structure** β€” change the tip β†’ why β†’ impact β†’ resource format
142
+
143
+ The `IMPACT_TABLE` dict below it controls the carbon/water/waste numbers shown in the Dashboard tab β€” add new actions or update existing values there.
144
+
145
+ ---
146
+
147
+ ## Project Files
148
+
149
+ | File | Purpose |
150
+ |---|---|
151
+ | `app.py` | Gradio Blocks UI β€” 4 tabs, callbacks, CSS theme |
152
+ | `watsonx_client.py` | IBM watsonx.ai SDK wrapper, agent config, impact data |
153
+ | `requirements.txt` | Pinned Python dependencies (4 packages) |
154
+ | `.env` | Local credentials (gitignored) |
155
+ | `.env.example` | Template β€” safe to commit |
156
+ | `ecoagent-plan.md` | Implementation plan and architecture decisions |
157
+ | `architecture.png` | Architecture blueprint diagram |
158
+ | `fill.txt` | PPT content fill for presentation |
159
+
160
+ ---
161
+
162
+ ## Disclaimer
163
+
164
+ Answers are AI-generated by IBM Granite. Impact figures labelled `[Lookup]` are sourced from IPCC AR6, BEE India, and CPCB data. Figures labelled `[Estimate]` are model-generated approximations. Always verify government schemes and legal/financial details with official sources.
app.py ADDED
@@ -0,0 +1,926 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+ ------
4
+ EcoAgent β€” Gradio Blocks UI with 4 tabs:
5
+ Chat | Dashboard | Recycling Guide | Household Profile
6
+
7
+ Features:
8
+ - IBM Granite 4 H Small via watsonx.ai (ibm-watsonx-ai APIClient)
9
+ - Clean light-theme CSS, no dark mode
10
+ - Session-based eco score, action logging, impact dashboard
11
+ - India-specific recycling guide and product recommendations
12
+ - Household/family profile with habit tracking
13
+ - Hugging Face Spaces compatible
14
+ """
15
+
16
+ import os
17
+ import logging
18
+
19
+ # Fix SSL certificate path for environments where SSL_CERT_FILE points to wrong Python
20
+ if "SSL_CERT_FILE" not in os.environ or not os.path.isfile(os.environ.get("SSL_CERT_FILE", "")):
21
+ try:
22
+ import certifi
23
+ os.environ["SSL_CERT_FILE"] = certifi.where()
24
+ os.environ["REQUESTS_CA_BUNDLE"] = certifi.where()
25
+ except ImportError:
26
+ pass
27
+
28
+ import gradio as gr
29
+ from dotenv import load_dotenv
30
+
31
+ from watsonx_client import (
32
+ get_eco_answer,
33
+ get_recycling_guide,
34
+ compute_session_impact,
35
+ IMPACT_TABLE,
36
+ PRODUCT_RECS,
37
+ INDIAN_CITIES,
38
+ )
39
+
40
+ # ---------------------------------------------------------------------------
41
+ load_dotenv(".env")
42
+ logging.basicConfig(level=logging.INFO)
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Custom CSS β€” light-only eco green theme, polished cards and layout
47
+ # ---------------------------------------------------------------------------
48
+ CUSTOM_CSS = """
49
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
50
+
51
+ :root {
52
+ --eco-primary: #2e7d50;
53
+ --eco-secondary: #43a868;
54
+ --eco-light: #f0f7f3;
55
+ --eco-bg: #fcfcfd;
56
+ --eco-card: #ffffff;
57
+ --eco-text: #1c1c1e;
58
+ --eco-muted: #6b6b6f;
59
+ --eco-border: #e4e4e7;
60
+ --eco-shadow: 0 1px 3px rgba(0,0,0,0.04);
61
+ --radius: 6px;
62
+ }
63
+
64
+ body, .gradio-container {
65
+ font-family: 'Inter', system-ui, sans-serif !important;
66
+ background: var(--eco-bg) !important;
67
+ color: var(--eco-text) !important;
68
+ }
69
+
70
+ /* ── Gradio component overrides for ultra-light look ── */
71
+ .gradio-container { background: var(--eco-bg) !important; }
72
+ .gradio-container .wrap { background: var(--eco-bg) !important; }
73
+
74
+ /* Tabs */
75
+ .tab-nav button {
76
+ font-weight: 500 !important;
77
+ color: var(--eco-muted) !important;
78
+ border: none !important;
79
+ border-bottom: 2px solid transparent !important;
80
+ background: transparent !important;
81
+ border-radius: 0 !important;
82
+ padding: 8px 16px !important;
83
+ }
84
+ .tab-nav button.selected {
85
+ color: var(--eco-primary) !important;
86
+ border-bottom: 2.5px solid var(--eco-primary) !important;
87
+ font-weight: 600 !important;
88
+ }
89
+ .tab-nav button:hover {
90
+ color: var(--eco-primary) !important;
91
+ background: var(--eco-light) !important;
92
+ }
93
+
94
+ /* Buttons */
95
+ button.primary, button.primary.lg {
96
+ background: var(--eco-primary) !important;
97
+ border-color: var(--eco-primary) !important;
98
+ color: #fff !important;
99
+ border-radius: var(--radius) !important;
100
+ font-weight: 600 !important;
101
+ }
102
+ button.primary:hover {
103
+ background: #256a42 !important;
104
+ border-color: #256a42 !important;
105
+ }
106
+ button.secondary, button.secondary.lg {
107
+ background: var(--eco-light) !important;
108
+ border: 1px solid var(--eco-border) !important;
109
+ color: var(--eco-text) !important;
110
+ border-radius: var(--radius) !important;
111
+ font-weight: 500 !important;
112
+ }
113
+ button.secondary:hover {
114
+ background: #e4eff0 !important;
115
+ border-color: var(--eco-border) !important;
116
+ }
117
+
118
+ /* Textbox / Dropdown / Slider */
119
+ textarea, input[type="text"], .wrap textarea, .wrap input[type="text"] {
120
+ border: 1px solid var(--eco-border) !important;
121
+ border-radius: var(--radius) !important;
122
+ background: var(--eco-card) !important;
123
+ color: var(--eco-text) !important;
124
+ }
125
+ textarea:focus, input[type="text"]:focus {
126
+ border-color: var(--eco-primary) !important;
127
+ box-shadow: 0 0 0 2px rgba(46,125,80,0.12) !important;
128
+ }
129
+
130
+ /* Fix Gradio 6 Dropdown*/
131
+
132
+ .gr-dropdown-menu,
133
+ .gr-select-dropdown,
134
+ .gradio-dropdown-menu,
135
+ [role="listbox"] {
136
+ background: #ffffff !important;
137
+ border: 1px solid #dcdcdc !important;
138
+ color: #1c1c1e !important;
139
+ }
140
+
141
+ .gr-dropdown-menu li,
142
+ .gr-select-dropdown li,
143
+ [role="option"] {
144
+ background: #ffffff !important;
145
+ color: #1c1c1e !important;
146
+ padding: 10px 14px !important;
147
+ }
148
+
149
+ .gr-dropdown-menu li:hover,
150
+ .gr-select-dropdown li:hover,
151
+ [role="option"]:hover,
152
+ [aria-selected="true"] {
153
+ background: #e8f5e9 !important;
154
+ color: #2e7d50 !important;
155
+ }
156
+
157
+ /* Slider */
158
+ input[type="range"] {
159
+ accent-color: #2e7d50 !important;
160
+ }
161
+
162
+ input[type="range"]::-webkit-slider-thumb {
163
+ background: #2e7d50 !important;
164
+ }
165
+
166
+ input[type="range"]::-webkit-slider-runnable-track {
167
+ background: #d8e9dc !important;
168
+ }
169
+
170
+ input[type="range"]::-moz-range-thumb {
171
+ background: #2e7d50 !important;
172
+ }
173
+
174
+ input[type="range"]::-moz-range-track {
175
+ background: #d8e9dc !important;
176
+ }
177
+
178
+ /* CheckboxGroup β€” pills/chips style */
179
+ /* ---------- CheckboxGroup ---------- */
180
+
181
+ .gr-checkboxgroup label,
182
+ .gradio-checkbox label {
183
+ background: #f0f7f3 !important;
184
+ border: 1px solid #d6e7da !important;
185
+ color: #1c1c1e !important;
186
+ border-radius: 18px !important;
187
+ padding: 8px 14px !important;
188
+ }
189
+
190
+ .gr-checkboxgroup label:hover,
191
+ .gradio-checkbox label:hover {
192
+ background: #e6f3ea !important;
193
+ }
194
+
195
+ .gr-checkboxgroup input:checked + span,
196
+ .gradio-checkbox input:checked + span {
197
+ color: white !important;
198
+ }
199
+
200
+ .gr-checkboxgroup label:has(input:checked),
201
+ .gradio-checkbox label:has(input:checked) {
202
+ background: #2e7d50 !important;
203
+ border-color: #2e7d50 !important;
204
+ }
205
+
206
+ /* Chatbot */
207
+ .message.user {
208
+ background: var(--eco-light) !important;
209
+ border: 1px solid var(--eco-border) !important;
210
+ border-radius: var(--radius) !important;
211
+ }
212
+ .message.bot {
213
+ background: var(--eco-card) !important;
214
+ border: 1px solid var(--eco-border) !important;
215
+ border-radius: var(--radius) !important;
216
+ }
217
+ /* Prevent white flash during model thinking */
218
+ #chatbot, #chatbot > .wrap, #chatbot > .wrap > .panel {
219
+ background: var(--eco-bg) !important;
220
+ }
221
+
222
+ /* Markdown headings inside tabs */
223
+ h3, h2 { color: var(--eco-text) !important; }
224
+
225
+ /* ── Header ── */
226
+ .eco-header {
227
+ background: var(--eco-card);
228
+ border: 1px solid var(--eco-border);
229
+ border-radius: var(--radius);
230
+ padding: 22px 28px;
231
+ margin-bottom: 16px;
232
+ color: var(--eco-text);
233
+ box-shadow: var(--eco-shadow);
234
+ }
235
+ .eco-header h1 {
236
+ margin: 0 0 4px;
237
+ font-size: 1.5rem;
238
+ font-weight: 700;
239
+ letter-spacing: -0.3px;
240
+ color: var(--eco-primary);
241
+ }
242
+ .eco-header p {
243
+ margin: 0;
244
+ font-size: 0.85rem;
245
+ color: var(--eco-muted);
246
+ }
247
+ .eco-model-badge {
248
+ display: inline-block;
249
+ margin-top: 8px;
250
+ background: var(--eco-light);
251
+ border: 1px solid var(--eco-border);
252
+ border-radius: 20px;
253
+ padding: 3px 12px;
254
+ font-size: 0.73rem;
255
+ font-weight: 600;
256
+ color: var(--eco-primary);
257
+ letter-spacing: 0.3px;
258
+ }
259
+
260
+ /* ── Metric cards ── */
261
+ .metric-card {
262
+ background: var(--eco-card);
263
+ border: 1px solid var(--eco-border);
264
+ border-radius: var(--radius);
265
+ padding: 18px 16px;
266
+ text-align: center;
267
+ box-shadow: var(--eco-shadow);
268
+ }
269
+ .metric-value {
270
+ font-size: 1.8rem;
271
+ font-weight: 700;
272
+ color: var(--eco-primary);
273
+ line-height: 1.1;
274
+ }
275
+ .metric-label {
276
+ font-size: 0.7rem;
277
+ color: var(--eco-muted);
278
+ margin-top: 3px;
279
+ text-transform: uppercase;
280
+ letter-spacing: 0.6px;
281
+ font-weight: 500;
282
+ }
283
+ .metric-unit {
284
+ font-size: 0.82rem;
285
+ color: var(--eco-muted);
286
+ }
287
+
288
+ /* ── Score ring ── */
289
+ .score-ring-wrap {
290
+ display: flex;
291
+ flex-direction: column;
292
+ align-items: center;
293
+ gap: 6px;
294
+ }
295
+ .score-ring {
296
+ width: 80px; height: 80px; border-radius: 50%;
297
+ background: conic-gradient(var(--eco-secondary) calc(var(--pct) * 1%), #eaeaea 0);
298
+ display: flex; align-items: center; justify-content: center;
299
+ position: relative;
300
+ }
301
+ .score-ring::after {
302
+ content: '';
303
+ width: 60px; height: 60px;
304
+ background: var(--eco-card);
305
+ border-radius: 50%;
306
+ position: absolute;
307
+ }
308
+ .score-number {
309
+ font-size: 1rem; font-weight: 700;
310
+ color: var(--eco-primary);
311
+ position: relative; z-index: 1;
312
+ }
313
+ .score-label {
314
+ font-size: 0.68rem;
315
+ color: var(--eco-muted);
316
+ font-weight: 600;
317
+ text-transform: uppercase;
318
+ letter-spacing: 0.5px;
319
+ }
320
+ .score-count {
321
+ background: var(--eco-light);
322
+ color: var(--eco-primary);
323
+ border: 1px solid var(--eco-border);
324
+ border-radius: 20px;
325
+ padding: 2px 10px;
326
+ font-size: 0.72rem;
327
+ font-weight: 600;
328
+ }
329
+
330
+ /* ── Recycling output ── */
331
+ .recycling-output {
332
+ background: var(--eco-card);
333
+ border: 1px solid var(--eco-border);
334
+ border-radius: var(--radius);
335
+ padding: 14px;
336
+ box-shadow: var(--eco-shadow);
337
+ }
338
+
339
+ /* ── Product cards ── */
340
+ .product-card {
341
+ background: var(--eco-card);
342
+ border-left: 3px solid var(--eco-secondary);
343
+ border-radius: 0 var(--radius) var(--radius) 0;
344
+ padding: 10px 14px;
345
+ margin: 5px 0;
346
+ box-shadow: var(--eco-shadow);
347
+ font-size: 0.86rem;
348
+ color: var(--eco-text);
349
+ }
350
+
351
+ /* ── Profile card ── */
352
+ .profile-card {
353
+ background: var(--eco-card);
354
+ border: 1px solid var(--eco-border);
355
+ border-radius: var(--radius);
356
+ padding: 14px;
357
+ box-shadow: var(--eco-shadow);
358
+ }
359
+
360
+ /* ── Disclaimer ── */
361
+ .disclaimer {
362
+ font-size: 0.72rem;
363
+ color: var(--eco-muted);
364
+ text-align: center;
365
+ padding: 8px 0 4px;
366
+ border-top: 1px solid var(--eco-border);
367
+ margin-top: 10px;
368
+ }
369
+ """
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # Static HTML β€” clean light header, no dark-mode button
373
+ # ---------------------------------------------------------------------------
374
+ HEADER_HTML = """
375
+ <div class="eco-header">
376
+ <h1>EcoAgent</h1>
377
+ <p>Your AI guide to greener living β€” personalized, practical, powered by IBM Granite and watsonx.ai</p>
378
+ <span class="eco-model-badge">ibm/granite-4-h-small</span>
379
+ </div>
380
+ """
381
+
382
+ DISCLAIMER_HTML = """
383
+ <div class="disclaimer">
384
+ Answers are AI-generated by IBM Granite. Impact figures marked [Lookup] are from IPCC/BEE/CPCB data;
385
+ [Estimate] figures are model-generated. Verify government schemes at official sources.
386
+ Not financial or legal advice.
387
+ </div>
388
+ """
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # Quick-action chips for logging eco actions
392
+ # ---------------------------------------------------------------------------
393
+ ACTION_CHIPS = [
394
+ ("cloth_bags", "Used cloth bags"),
395
+ ("led_bulbs", "Switched to LED"),
396
+ ("short_shower", "Short shower"),
397
+ ("no_plastic_bottles", "Used steel bottle"),
398
+ ("public_transport", "Public transport"),
399
+ ("composting", "Composted waste"),
400
+ ("segregate_waste", "Segregated waste"),
401
+ ("seasonal_local_produce","Bought local produce"),
402
+ ("line_dry_clothes", "Line-dried clothes"),
403
+ ("fix_water_leaks", "Fixed a water leak"),
404
+ ]
405
+
406
+ EXAMPLE_PROMPTS = [
407
+ "How can I reduce plastic use in my Indian kitchen?",
408
+ "What government schemes help with solar panel installation in India?",
409
+ "Give me a week-long plan to reduce my family's water usage.",
410
+ "How do I properly dispose of old mobile phones and laptops?",
411
+ "What are the most eco-friendly travel options in a Tier-2 Indian city?",
412
+ ]
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # Dashboard HTML builder
417
+ # ---------------------------------------------------------------------------
418
+ def _build_dashboard_html(impact: dict, profile: dict) -> str:
419
+ score = impact.get("eco_score", 0)
420
+ co2 = impact.get("co2_kg_year", 0.0)
421
+ water = impact.get("water_L_day", 0.0)
422
+ waste = impact.get("waste_kg_year", 0.0)
423
+ actions = impact.get("actions_count", 0)
424
+ location = profile.get("location", "India") if profile else "India"
425
+ members = profile.get("members", 1) if profile else 1
426
+
427
+ return f"""
428
+ <div style="font-family:Inter,sans-serif;color:#1c1c1e;">
429
+ <div style="display:flex;gap:14px;flex-wrap:wrap;margin-bottom:14px;align-items:stretch;">
430
+ <div class="metric-card" style="flex:1;min-width:150px;">
431
+ <div class="score-ring-wrap">
432
+ <div class="score-ring" style="--pct:{score};">
433
+ <span class="score-number">{score}</span>
434
+ </div>
435
+ <span class="score-label">Eco Score</span>
436
+ <span class="score-count">{actions} action{'s' if actions != 1 else ''} logged</span>
437
+ </div>
438
+ </div>
439
+ <div class="metric-card" style="flex:1;min-width:150px;">
440
+ <div class="metric-value">{members}</div>
441
+ <div class="metric-unit">household member{'s' if members != 1 else ''}</div>
442
+ <div class="metric-label">{location}</div>
443
+ </div>
444
+ </div>
445
+ <p style="font-size:0.76rem;color:#6b6b6f;margin:0 0 8px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">
446
+ Estimated annual savings if logged actions are sustained
447
+ </p>
448
+ <div style="display:flex;gap:10px;flex-wrap:wrap;">
449
+ <div class="metric-card" style="flex:1;min-width:130px;">
450
+ <div class="metric-value">{co2:,.0f}</div>
451
+ <div class="metric-unit">kg CO&#8322;</div>
452
+ <div class="metric-label">Carbon Avoided</div>
453
+ </div>
454
+ <div class="metric-card" style="flex:1;min-width:130px;">
455
+ <div class="metric-value">{water:,.0f}</div>
456
+ <div class="metric-unit">L / day</div>
457
+ <div class="metric-label">Water Saved</div>
458
+ </div>
459
+ <div class="metric-card" style="flex:1;min-width:130px;">
460
+ <div class="metric-value">{waste:,.0f}</div>
461
+ <div class="metric-unit">kg / year</div>
462
+ <div class="metric-label">Waste Diverted</div>
463
+ </div>
464
+ </div>
465
+ {_equivalencies_html(co2, water) if co2 > 0 or water > 0 else ""}
466
+ {_actions_html(impact)}
467
+ </div>
468
+ """
469
+
470
+
471
+ def _equivalencies_html(co2: float, water: float) -> str:
472
+ lines = []
473
+ if co2 > 0:
474
+ trees = round(co2 / 21)
475
+ lines.append(f"CO&#8322; savings equivalent to planting <b>{trees} trees</b> per year")
476
+ if water > 0:
477
+ baths = round(water / 150)
478
+ lines.append(f"Water savings equivalent to <b>{baths} bucket baths</b> per day")
479
+ if not lines:
480
+ return ""
481
+ return (
482
+ "<div style='background:#f0f7f3;border:1px solid #e4e4e7;border-radius:6px;"
483
+ "padding:10px 14px;margin-top:12px;font-size:0.82rem;color:#2e7d50;'>"
484
+ + "<br>".join(lines)
485
+ + "</div>"
486
+ )
487
+
488
+
489
+ def _actions_html(impact: dict) -> str:
490
+ count = impact.get("actions_count", 0)
491
+ if count == 0:
492
+ return (
493
+ "<p style='color:#6b6b6f;font-size:0.82rem;margin-top:10px;'>"
494
+ "No actions logged yet. Chat with EcoAgent, then use the action buttons "
495
+ "to log what you have done today.</p>"
496
+ )
497
+ return (
498
+ f"<p style='color:#6b6b6f;font-size:0.80rem;margin-top:10px;'>"
499
+ f"You have logged <b>{count} unique action{'s' if count != 1 else ''}</b> this session. "
500
+ f"Each sustained action compounds over the year.</p>"
501
+ )
502
+
503
+
504
+ # ---------------------------------------------------------------------------
505
+ # Recycling products HTML builder
506
+ # ---------------------------------------------------------------------------
507
+ def _build_products_html(material: str) -> str:
508
+ recs = PRODUCT_RECS.get(material, [])
509
+ if not recs:
510
+ return ""
511
+ items = "".join(f'<div class="product-card">{r}</div>' for r in recs)
512
+ return (
513
+ f"<div style='margin-top:14px;'>"
514
+ f"<p style='font-weight:600;color:#2e7d50;font-size:0.86rem;margin-bottom:4px;'>"
515
+ f"Eco-friendly alternatives and resources for {material}:</p>"
516
+ f"{items}</div>"
517
+ )
518
+
519
+
520
+ # ---------------------------------------------------------------------------
521
+ # Profile summary HTML builder
522
+ # ---------------------------------------------------------------------------
523
+ def _build_profile_html(profile: dict) -> str:
524
+ if not profile or not profile.get("name"):
525
+ return "<p style='color:#6b6b6f;font-size:0.84rem;'>No profile saved yet.</p>"
526
+ habits = profile.get("habits", [])
527
+ habit_list = (
528
+ "<ul style='margin:4px 0;padding-left:18px;font-size:0.82rem;'>"
529
+ + "".join(f"<li>{h}</li>" for h in habits)
530
+ + "</ul>"
531
+ if habits
532
+ else "<p style='color:#6b6b6f;font-size:0.82rem;'>No habits recorded.</p>"
533
+ )
534
+ return f"""
535
+ <div class="profile-card">
536
+ <p style="font-size:0.93rem;font-weight:700;color:#2e7d50;margin:0 0 6px;">{profile['name']}</p>
537
+ <p style="font-size:0.82rem;margin:2px 0;"><b>Location:</b> {profile.get('location','β€”')}</p>
538
+ <p style="font-size:0.82rem;margin:2px 0;"><b>Members:</b> {profile.get('members',1)}</p>
539
+ <p style="font-size:0.82rem;margin:6px 0 2px;"><b>Current eco habits:</b></p>
540
+ {habit_list}
541
+ </div>
542
+ """
543
+
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # Core chat callback
547
+ # ---------------------------------------------------------------------------
548
+ def chat_submit(
549
+ message: str,
550
+ history: list,
551
+ profile_state: dict,
552
+ actions_state: list,
553
+ ):
554
+ """Handle a user message: call Granite, update history.
555
+
556
+ Gradio 6.x passes history as list[dict] where content is a list of
557
+ content blocks: [{"role": "user", "content": [{"type": "text", "text": "..."}]}].
558
+ We extract plain text for the API call and return structured blocks for display.
559
+ """
560
+ if not message or not message.strip():
561
+ return history, profile_state, actions_state, _score_html(actions_state)
562
+
563
+ # Gradio 6 delivers history with structured content blocks
564
+ # Extract plain text for the watsonx API call
565
+ messages: list[dict] = []
566
+ for turn in history:
567
+ role = turn.get("role", "")
568
+ content = turn.get("content", "")
569
+ if role in ("user", "assistant") and content:
570
+ # Content can be a string or a list of content blocks
571
+ if isinstance(content, list):
572
+ text = " ".join(
573
+ block.get("text", "") for block in content
574
+ if isinstance(block, dict) and block.get("type") == "text"
575
+ )
576
+ else:
577
+ text = str(content)
578
+ if text:
579
+ messages.append({"role": role, "content": text})
580
+
581
+ messages.append({"role": "user", "content": message})
582
+
583
+ try:
584
+ reply = get_eco_answer(messages, profile_state)
585
+ except EnvironmentError as exc:
586
+ reply = (
587
+ "**Setup required.**\n\n"
588
+ f"{exc}\n\n"
589
+ "Please set your credentials in `.env` and restart the app."
590
+ )
591
+ except RuntimeError as exc:
592
+ logger.error("watsonx error: %s", exc)
593
+ reply = (
594
+ "**EcoAgent is temporarily unavailable.**\n\n"
595
+ f"Error: {exc}\n\n"
596
+ "Please check your internet connection or try again in a moment."
597
+ )
598
+ except Exception as exc: # noqa: BLE001
599
+ logger.exception("Unexpected error in chat_submit")
600
+ reply = f"An unexpected error occurred ({type(exc).__name__}). Please try again."
601
+
602
+ # Append in Gradio 6 structured content block format
603
+ history = history + [
604
+ {"role": "user", "content": [{"type": "text", "text": message}]},
605
+ {"role": "assistant", "content": [{"type": "text", "text": reply}]},
606
+ ]
607
+ return history, profile_state, actions_state, _score_html(actions_state)
608
+
609
+
610
+ def _score_html(actions_state: list) -> str:
611
+ impact = compute_session_impact(actions_state)
612
+ score = impact["eco_score"]
613
+ count = impact["actions_count"]
614
+ return (
615
+ f"<div class='score-ring-wrap' style='padding:6px 0;'>"
616
+ f"<div class='score-ring' style='--pct:{score};width:72px;height:72px;'>"
617
+ f"<span class='score-number'>{score}</span></div>"
618
+ f"<span class='score-label'>Eco Score</span>"
619
+ f"<span class='score-count'>{count} action{'s' if count != 1 else ''}</span></div>"
620
+ )
621
+
622
+
623
+ def log_action(slug: str, actions_state: list) -> tuple[list, str]:
624
+ """Toggle an action slug in the logged actions list."""
625
+ if slug in actions_state:
626
+ actions_state = [a for a in actions_state if a != slug]
627
+ else:
628
+ actions_state = actions_state + [slug]
629
+ return actions_state, _score_html(actions_state)
630
+
631
+
632
+ def update_dashboard(actions_state: list, profile_state: dict) -> str:
633
+ members = profile_state.get("members", 1) if profile_state else 1
634
+ impact = compute_session_impact(actions_state, members)
635
+ return _build_dashboard_html(impact, profile_state or {})
636
+
637
+
638
+ def get_recycling(material: str, city: str) -> tuple[str, str]:
639
+ """Fetch guide + product recs for a material/city combination."""
640
+ if not material or not city:
641
+ return "Please select both a material and a city.", ""
642
+ try:
643
+ guide = get_recycling_guide(material, city)
644
+ except EnvironmentError as exc:
645
+ guide = f"Setup required: {exc}"
646
+ except RuntimeError as exc:
647
+ guide = f"Could not fetch guide: {exc}"
648
+ products_html = _build_products_html(material)
649
+ return guide, products_html
650
+
651
+
652
+ def save_profile(
653
+ name: str,
654
+ location: str,
655
+ members: int,
656
+ habits: list,
657
+ actions_state: list,
658
+ ) -> tuple[dict, str, str]:
659
+ """Save household profile and return updated state + HTML summary."""
660
+ profile = {
661
+ "name": name.strip() if name else "My Household",
662
+ "location": location,
663
+ "members": int(members),
664
+ "habits": habits,
665
+ }
666
+ summary = _build_profile_html(profile)
667
+ dashboard = update_dashboard(actions_state, profile)
668
+ return profile, summary, dashboard
669
+
670
+
671
+ # ===========================================================================
672
+ # GRADIO BLOCKS APP
673
+ # ===========================================================================
674
+ with gr.Blocks(title="EcoAgent β€” Eco Lifestyle Assistant") as demo:
675
+
676
+ profile_state = gr.State({})
677
+ actions_state = gr.State([])
678
+
679
+ gr.HTML(HEADER_HTML)
680
+
681
+ with gr.Tabs(elem_classes="tab-nav"):
682
+
683
+ # ================================================================
684
+ # TAB 1: CHAT
685
+ # ================================================================
686
+ with gr.Tab("Chat"):
687
+ with gr.Row():
688
+ with gr.Column(scale=3):
689
+ chatbot = gr.Chatbot(
690
+ height=430,
691
+ show_label=False,
692
+ elem_id="chatbot",
693
+ placeholder=(
694
+ "<div style='text-align:center;padding:40px 20px;background:#fcfcfd;border-radius:6px;'>"
695
+ "<p style='font-size:1rem;font-weight:600;margin:0 0 6px;color:#2e7d50;'>Welcome to EcoAgent</p>"
696
+ "<p style='font-size:0.84rem;margin:0;color:#4a4a4e;'>Ask me anything about sustainable living, "
697
+ "eco travel, recycling, or Indian government green schemes.</p>"
698
+ "</div>"
699
+ ),
700
+ )
701
+ with gr.Row():
702
+ msg_input = gr.Textbox(
703
+ placeholder="Ask EcoAgent a question...",
704
+ show_label=False,
705
+ scale=5,
706
+ lines=1,
707
+ )
708
+ send_btn = gr.Button("Send", variant="primary", scale=1)
709
+
710
+ gr.Markdown("**Quick start questions:**")
711
+ with gr.Row():
712
+ for prompt in EXAMPLE_PROMPTS[:3]:
713
+ ex_btn = gr.Button(prompt, size="sm", variant="secondary")
714
+ ex_btn.click(lambda p=prompt: p, outputs=msg_input)
715
+ with gr.Row():
716
+ for prompt in EXAMPLE_PROMPTS[3:]:
717
+ ex_btn = gr.Button(prompt, size="sm", variant="secondary")
718
+ ex_btn.click(lambda p=prompt: p, outputs=msg_input)
719
+
720
+ with gr.Column(scale=1, min_width=190):
721
+ score_display = gr.HTML(_score_html([]))
722
+ gr.Markdown("**Log today's actions:**")
723
+ chip_btns = []
724
+ for slug, label in ACTION_CHIPS:
725
+ btn = gr.Button(label, size="sm", variant="secondary")
726
+ chip_btns.append((slug, btn))
727
+
728
+ def _submit(message, history, profile, actions):
729
+ return chat_submit(message, history, profile, actions)
730
+
731
+ send_btn.click(
732
+ _submit,
733
+ inputs=[msg_input, chatbot, profile_state, actions_state],
734
+ outputs=[chatbot, profile_state, actions_state, score_display],
735
+ ).then(lambda: "", outputs=msg_input)
736
+
737
+ msg_input.submit(
738
+ _submit,
739
+ inputs=[msg_input, chatbot, profile_state, actions_state],
740
+ outputs=[chatbot, profile_state, actions_state, score_display],
741
+ ).then(lambda: "", outputs=msg_input)
742
+
743
+ for slug, btn in chip_btns:
744
+ btn.click(
745
+ lambda a, s=slug: log_action(s, a),
746
+ inputs=[actions_state],
747
+ outputs=[actions_state, score_display],
748
+ )
749
+
750
+ # ================================================================
751
+ # TAB 2: DASHBOARD
752
+ # ================================================================
753
+ with gr.Tab("Dashboard"):
754
+ gr.Markdown(
755
+ "### Your Eco Impact Dashboard\n"
756
+ "Log actions in the Chat tab to see your estimated savings grow."
757
+ )
758
+ dashboard_html = gr.HTML(
759
+ _build_dashboard_html(compute_session_impact([]), {}),
760
+ )
761
+ refresh_btn = gr.Button("Refresh Dashboard", variant="secondary")
762
+ refresh_btn.click(
763
+ update_dashboard,
764
+ inputs=[actions_state, profile_state],
765
+ outputs=dashboard_html,
766
+ )
767
+
768
+ # ================================================================
769
+ # TAB 3: RECYCLING GUIDE
770
+ # ================================================================
771
+ with gr.Tab("Recycling Guide"):
772
+ gr.Markdown(
773
+ "### Local Recycling Guide\n"
774
+ "Select a waste material and your city to get India-specific "
775
+ "recycling instructions and eco-friendly product alternatives."
776
+ )
777
+ with gr.Row():
778
+ material_dd = gr.Dropdown(
779
+ choices=list(PRODUCT_RECS.keys()),
780
+ label="Waste Material",
781
+ value="E-waste",
782
+ scale=1,
783
+ )
784
+ city_dd = gr.Dropdown(
785
+ choices=INDIAN_CITIES,
786
+ label="Your City",
787
+ value="Bangalore",
788
+ scale=1,
789
+ )
790
+ guide_btn = gr.Button("Get Recycling Guide", variant="primary", scale=1)
791
+
792
+ guide_output = gr.Markdown(
793
+ "*Select a material and city above, then click Get Recycling Guide.*",
794
+ label="Recycling Instructions",
795
+ elem_classes="recycling-output",
796
+ )
797
+ products_output = gr.HTML("", label="Eco-Friendly Alternatives")
798
+
799
+ guide_btn.click(
800
+ get_recycling,
801
+ inputs=[material_dd, city_dd],
802
+ outputs=[guide_output, products_output],
803
+ )
804
+ material_dd.change(
805
+ lambda m: _build_products_html(m),
806
+ inputs=material_dd,
807
+ outputs=products_output,
808
+ )
809
+
810
+ # ================================================================
811
+ # TAB 4: PROFILE
812
+ # ================================================================
813
+ with gr.Tab("Profile"):
814
+ gr.Markdown(
815
+ "### Household Profile\n"
816
+ "Set your household details so EcoAgent can give personalised, "
817
+ "family-scale advice relevant to your location."
818
+ )
819
+ with gr.Row():
820
+ with gr.Column(scale=2):
821
+ hh_name = gr.Textbox(
822
+ label="Household Name",
823
+ placeholder="e.g. The Sharma Family",
824
+ )
825
+ with gr.Row():
826
+ hh_location = gr.Dropdown(
827
+ choices=INDIAN_CITIES + [
828
+ "Other - North India", "Other - South India",
829
+ "Other - East India", "Other - West India",
830
+ "Rural area",
831
+ ],
832
+ label="City / Location",
833
+ value="Bangalore",
834
+ scale=2,
835
+ )
836
+ hh_members = gr.Slider(
837
+ minimum=1, maximum=12, value=4, step=1,
838
+ label="Household Members",
839
+ scale=2,
840
+ )
841
+ hh_habits = gr.CheckboxGroup(
842
+ choices=[
843
+ "Vegetarian / vegan diet",
844
+ "Use public transport regularly",
845
+ "Have solar panels installed",
846
+ "Compost kitchen waste",
847
+ "Use cloth / jute bags",
848
+ "Avoid single-use plastics",
849
+ "Harvest rainwater",
850
+ "Segregate wet and dry waste",
851
+ "Use LED bulbs throughout",
852
+ "Grow some food at home",
853
+ ],
854
+ label="Current Eco Habits (select all that apply)",
855
+ )
856
+ save_btn = gr.Button("Save Profile", variant="primary")
857
+
858
+ with gr.Column(scale=1):
859
+ profile_summary = gr.HTML(
860
+ "<p style='color:#6b6b6f;font-size:0.84rem;'>"
861
+ "Fill in your details and click Save Profile.</p>",
862
+ )
863
+
864
+ # Save updates profile_state, profile HTML, and dashboard
865
+ save_btn.click(
866
+ save_profile,
867
+ inputs=[hh_name, hh_location, hh_members, hh_habits, actions_state],
868
+ outputs=[profile_state, profile_summary, dashboard_html],
869
+ )
870
+
871
+ # ── Footer disclaimer ──────────────────────────────────────────────────
872
+ gr.HTML(DISCLAIMER_HTML)
873
+
874
+
875
+ # ---------------------------------------------------------------------------
876
+ # Entry point
877
+ # ---------------------------------------------------------------------------
878
+ if __name__ == "__main__":
879
+ eco_theme = gr.themes.Soft(
880
+ primary_hue="green",
881
+ secondary_hue="emerald",
882
+ neutral_hue="gray",
883
+ font="Inter, system-ui, sans-serif",
884
+ font_mono="Fira Code, monospace",
885
+ ).set(
886
+ body_background_fill="#fcfcfd",
887
+ body_background_fill_dark="#fcfcfd",
888
+ body_text_color="#1c1c1e",
889
+ body_text_color_dark="#1c1c1e",
890
+ block_background_fill="#ffffff",
891
+ block_border_color="#e4e4e7",
892
+ block_label_text_color="#4a4a4e",
893
+ block_title_text_color="#1c1c1e",
894
+ input_background_fill="#ffffff",
895
+ input_border_color="#e4e4e7",
896
+ input_border_color_focus="#2e7d50",
897
+ button_primary_background_fill="#2e7d50",
898
+ button_primary_background_fill_hover="#256a42",
899
+ button_primary_border_color="#2e7d50",
900
+ button_primary_text_color="#ffffff",
901
+ button_secondary_background_fill="#f0f7f3",
902
+ button_secondary_background_fill_hover="#e4eff0",
903
+ button_secondary_border_color="#e4e4e7",
904
+ button_secondary_text_color="#1c1c1e",
905
+ checkbox_background_color="#ffffff",
906
+ checkbox_background_color_selected="#2e7d50",
907
+ checkbox_border_color="#e4e4e7",
908
+ checkbox_border_color_selected="#2e7d50",
909
+ checkbox_label_text_color="#1c1c1e",
910
+ checkbox_label_text_color_selected="#ffffff",
911
+ checkbox_label_background_fill="#f0f7f3",
912
+ checkbox_label_background_fill_hover="#e4eff0",
913
+ checkbox_label_background_fill_selected="#2e7d50",
914
+ checkbox_label_border_color="#e4e4e7",
915
+ checkbox_label_border_color_selected="#2e7d50",
916
+ checkbox_label_border_width="1px",
917
+ slider_color="#2e7d50",
918
+ )
919
+
920
+ demo.launch(
921
+ server_name="0.0.0.0",
922
+ server_port=int(os.environ.get("PORT", 7860)),
923
+ share=False,
924
+ theme=eco_theme,
925
+ css=CUSTOM_CSS,
926
+ )
ecoagent-plan.md ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EcoAgent v2 β€” Complete Rebuild Plan
2
+
3
+ ## Top-Level Overview
4
+
5
+ **Goal:** Rebuild EcoAgent from scratch as a full-featured Gradio web application using `ibm-watsonx-ai` SDK directly (ModelInference.chat() with `ibm/granite-4-h-small`). The IBM Orchestrate REST API integration is abandoned β€” it was not reachable via a public REST endpoint. The IBM Cloud API key (`CLOUD_API` from `.env.example`) works for direct watsonx.ai SDK calls.
6
+
7
+ **Classification:** Prompt-Engineered LLM Application (not RAG, not Agentic AI)
8
+
9
+ **What gets replaced/deleted:**
10
+ - `rag_pipeline.py` β†’ replaced by `watsonx_client.py`
11
+ - `app.py` β†’ fully rewritten
12
+ - `ibm-credentials.env` β†’ replaced by `.env` (simpler var names)
13
+ - `requirements.txt` β†’ updated
14
+ - `ecoagent-plan.md` β†’ this file supersedes it
15
+
16
+ **Architecture:**
17
+ ```
18
+ .env
19
+ └─ WATSONX_API_KEY, WATSONX_PROJECT_ID, WATSONX_URL
20
+ β”‚
21
+ β–Ό
22
+ watsonx_client.py ← ModelInference wrapper, AGENT_INSTRUCTIONS, impact lookup table
23
+ β”‚
24
+ β–Ό
25
+ app.py ← Gradio Blocks UI: Chat + Dashboard + Recycling + Profile
26
+ β”‚
27
+ β”œβ”€ gr.Blocks (ultra-light eco green theme)
28
+ β”œβ”€ Chat tab ← primary interaction, eco score streak
29
+ β”œβ”€ Dashboard tab ← COβ‚‚/water/waste savings visualised
30
+ β”œβ”€ Recycling tab ← material lookup β†’ guidelines + product recommendations
31
+ └─ Profile tab ← household members, habits, location (India-specific)
32
+ ```
33
+
34
+ **IBM SDK usage (confirmed working):**
35
+ ```python
36
+ from ibm_watsonx_ai import APIClient, Credentials
37
+ from ibm_watsonx_ai.foundation_models import ModelInference
38
+ model = ModelInference(model_id="ibm/granite-4-h-small", credentials=creds, project_id=...)
39
+ response = model.chat(messages=[{"role":"system","content":"..."}, {"role":"user","content":"..."}])
40
+ answer = response["choices"][0]["message"]["content"]
41
+ ```
42
+
43
+ **Non-Goals:**
44
+ - No Exa MCP / web search (user dropped that requirement in the new prompt)
45
+ - No IBM Orchestrate REST API
46
+ - No FAISS / local embeddings
47
+
48
+ ---
49
+
50
+ ## Environment Variables (new `.env`)
51
+
52
+ | Variable | Value | Purpose |
53
+ |---|---|---|
54
+ | `WATSONX_API_KEY` | `CLOUD_API` value from `.env.example` | IBM Cloud API key for watsonx.ai |
55
+ | `WATSONX_PROJECT_ID` | User must fill in from watsonx.ai Studio | Required by ModelInference |
56
+ | `WATSONX_URL` | `https://eu-de.ml.cloud.ibm.com` | watsonx.ai eu-de (Frankfurt) endpoint |
57
+
58
+ **Region confirmed:** `eu-de` (Frankfurt) β€” platform at `https://eu-de.dataplatform.cloud.ibm.com`
59
+ **`ibm-credentials.env`**: Delete β€” fully superseded by `.env`.
60
+
61
+ ---
62
+
63
+ ## Sub-Tasks
64
+
65
+ ---
66
+
67
+ ### Sub-Task 1: Create `.env` and update `ibm-credentials.env`
68
+
69
+ **Intent:** Replace the sprawling `ibm-credentials.env` with a clean `.env` using the new variable names. Keep `ibm-credentials.env` as a legacy backup comment only.
70
+
71
+ **Expected Outcomes:**
72
+ - `.env` exists with `WATSONX_API_KEY`, `WATSONX_PROJECT_ID`, `WATSONX_URL`
73
+ - `.env.example` updated to document the new vars (without real values for PROJECT_ID)
74
+ - `ibm-credentials.env` cleared out or noted as superseded
75
+
76
+ **Todo List:**
77
+ 1. Write `.env` with `CLOUD_API` value mapped to `WATSONX_API_KEY`
78
+ 2. Set `WATSONX_URL=https://eu-de.ml.cloud.ibm.com`
79
+ 3. Leave `WATSONX_PROJECT_ID=` blank (user must fill in from watsonx.ai Studio)
80
+ 4. Update `.env.example` to document all three vars without values
81
+
82
+ **Status:** `[x] completed`
83
+
84
+ ---
85
+
86
+ ### Sub-Task 2: Create `watsonx_client.py`
87
+
88
+ **Intent:** All IBM watsonx.ai SDK logic in one file: `AGENT_INSTRUCTIONS` block, carbon impact lookup table, and `get_eco_answer(messages, profile)` function.
89
+
90
+ **Expected Outcomes:**
91
+ - `watsonx_client.py` is importable and `get_eco_answer()` returns a string
92
+ - `AGENT_INSTRUCTIONS` is a clearly labelled, easily editable multi-line string at the top
93
+ - Carbon/resource impact lookup table covers β‰₯15 common actions with COβ‚‚/water/waste values
94
+ - If SDK call fails, raises `RuntimeError` with a descriptive message
95
+ - `project_id` must be set; if missing raises a clear error at import time
96
+
97
+ **Todo List:**
98
+ 1. `load_dotenv(".env")` at module top
99
+ 2. Write `AGENT_INSTRUCTIONS` constant β€” covers:
100
+ - Persona/tone: friendly, concise, India-aware eco advisor
101
+ - Answer structure: quick tip β†’ why it matters (1 line) β†’ optional resource
102
+ - Safety rules: no invented stats, label estimates vs lookup values
103
+ - Sustainability focus areas: plastic, energy, water, travel, food, waste
104
+ - India-specific context: regional schemes (PM Surya Ghar, Swachh Bharat, FAME), local recycling norms, common household practices
105
+ 3. Write `IMPACT_TABLE: dict[str, dict]` β€” keyed by action slug, values: `{co2_kg_year, water_L_day, waste_kg_year, label}`; cover actions like: cloth bags, LED bulbs, solar panels, composting, public transport, short showers, rainwater harvesting, etc.
106
+ 4. Write `_build_system_prompt(profile: dict) -> str` β€” injects profile (location, members, habits) into the system message alongside `AGENT_INSTRUCTIONS`
107
+ 5. Write `get_eco_answer(messages: list[dict], profile: dict) -> str`:
108
+ - Builds `[system_msg] + messages` list
109
+ - Calls `ModelInference.chat()` with `max_tokens=800, temperature=0.7`
110
+ - Returns `response["choices"][0]["message"]["content"]`
111
+ 6. Write `get_recycling_guide(material: str, location: str) -> str` β€” a focused single-turn call to Granite to return recycling instructions for a material in an Indian city context
112
+ 7. Startup validation: raise `EnvironmentError` if `WATSONX_API_KEY` or `WATSONX_URL` is missing
113
+
114
+ **Relevant Context:**
115
+ - SDK: `ibm_watsonx_ai` >= 1.1.15
116
+ - `Credentials(url=WATSONX_URL, api_key=WATSONX_API_KEY)`
117
+ - `APIClient(credentials, project_id)`
118
+ - `ModelInference(model_id="ibm/granite-4-h-small", credentials=creds, project_id=WATSONX_PROJECT_ID)`
119
+ - Chat response: `response["choices"][0]["message"]["content"]`
120
+ - `WATSONX_PROJECT_ID` may be empty during dev β€” handle gracefully
121
+
122
+ **Status:** `[x] completed`
123
+
124
+ ---
125
+
126
+ ### Sub-Task 3: Create `app.py` β€” Gradio Blocks UI
127
+
128
+ **Intent:** Full Gradio Blocks application with 4 tabs, ultra-light eco green theme, and session state.
129
+
130
+ **Expected Outcomes:**
131
+ - `python app.py` launches on `http://localhost:7860`
132
+ - Chat tab works end-to-end with Granite via `watsonx_client.get_eco_answer()`
133
+ - Dashboard tab shows live session-based eco score + cumulative COβ‚‚/water/waste savings
134
+ - Recycling tab has a material input + location dropdown + Granite-powered guide output
135
+ - Profile tab captures household members, location (Indian cities), habits
136
+ - Mobile-responsive via CSS
137
+ - All state is session-based (gr.State)
138
+
139
+ **Todo List:**
140
+
141
+ **3a. Custom CSS/JS:**
142
+ 1. Define `CUSTOM_CSS` string: ultra-light eco green palette (`#fcfcfd` background, `#2e7d50` primary accent), card shadows, chat bubble styles, animated eco score ring
143
+ 2. Define eco score calculation: `score = min(100, len(logged_actions) * 10)` per session
144
+
145
+ **3b. Chat Tab:**
146
+ 1. `gr.Chatbot` with welcome placeholder and structured content blocks for Gradio 6.x
147
+ 2. Message input + Send button
148
+ 3. Under the chat: "🌱 Log this action" checkbox group β†’ increments session action counter β†’ updates eco score
149
+ 4. Eco score display: circular progress indicator (CSS-only, value from gr.State)
150
+ 5. Weekly streak counter (session days active β€” session-based approximation)
151
+ 6. `chat_submit(message, history, profile_state, actions_state)` β†’ calls `get_eco_answer()` β†’ returns updated history + updated state
152
+
153
+ **3c. Dashboard Tab:**
154
+ 1. Header: "Your Eco Impact This Session"
155
+ 2. Three metric cards: COβ‚‚ saved (kg), Water saved (L), Waste diverted (kg) β€” computed from logged actions via `IMPACT_TABLE`
156
+ 3. Eco score gauge (HTML/CSS ring)
157
+ 4. Household summary: member count, location
158
+ 5. `update_dashboard(actions_state, profile_state)` β†’ returns HTML string of metric cards
159
+
160
+ **3d. Recycling Guide Tab:**
161
+ 1. Material dropdown: Paper, Plastic, Glass, E-waste, Metal, Organic, Batteries, Clothing
162
+ 2. City dropdown: 15 major Indian cities
163
+ 3. "Get Guide" button β†’ calls `get_recycling_guide()` β†’ renders in `gr.Markdown`
164
+ 4. Below: eco-friendly product recommendation section (hardcoded lookup per material category)
165
+
166
+ **3e. Profile Tab:**
167
+ 1. Household name input
168
+ 2. Location dropdown (Indian states + major cities)
169
+ 3. Member count slider (1–10)
170
+ 4. Habits checklist: vegetarian diet, uses public transport, has solar panels, composts, uses cloth bags, avoids single-use plastic, harvests rainwater
171
+ 5. "Save Profile" button β†’ updates `profile_state`
172
+ 6. Profile summary card shown after save
173
+
174
+ **3f. App assembly:**
175
+ 1. `gr.Blocks()` with `css` parameter in `launch()` for Gradio 6.x compatibility
176
+ 2. All tabs inside `gr.Tabs`
177
+ 3. `demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))`
178
+ 4. `if __name__ == "__main__":` guard
179
+
180
+ **Status:** `[x] completed`
181
+
182
+ ---
183
+
184
+ ### Sub-Task 4: Update `requirements.txt`
185
+
186
+ **Intent:** Minimal, pinned dependency list for the new architecture.
187
+
188
+ **Expected Outcomes:**
189
+ - Only packages actually used are listed
190
+ - Installs cleanly on Python 3.10+ in a fresh HF Spaces environment
191
+
192
+ **Todo List:**
193
+ 1. Keep: `gradio`, `ibm-watsonx-ai`, `python-dotenv`, `requests`
194
+ 2. Remove: `langchain`, `langchain-community`, `faiss-cpu`, `sentence-transformers` (not used)
195
+ 3. Pin versions based on what is installed in `.venv`
196
+
197
+ **Status:** `[x] completed`
198
+
199
+ ---
200
+
201
+ ### Sub-Task 5: Update `README.md` and `AGENTS.md`
202
+
203
+ **Intent:** Update docs to reflect the new architecture. HF Spaces front-matter, new env var names, deployment steps.
204
+
205
+ **Todo List:**
206
+ 1. Update `README.md` HF Spaces YAML: keep gradio SDK, update description
207
+ 2. Document the 3 new env vars (`WATSONX_API_KEY`, `WATSONX_PROJECT_ID`, `WATSONX_URL`)
208
+ 3. Add "How to get a `WATSONX_PROJECT_ID`" section (watsonx.ai Studio β†’ create project β†’ copy ID)
209
+ 4. Update `AGENTS.md` to note `rag_pipeline.py` is deleted, new entry point is `watsonx_client.py`
210
+
211
+ **Status:** `[x] completed`
212
+
213
+ ---
214
+
215
+ ## Implementation Order
216
+
217
+ ```
218
+ Sub-Task 1 (.env) βœ“ completed
219
+ ↓
220
+ Sub-Task 2 (watsonx_client.py) βœ“ completed
221
+ ↓
222
+ Sub-Task 3 (app.py) βœ“ completed
223
+ ↓
224
+ Sub-Task 4 (requirements.txt) βœ“ completed
225
+ ↓
226
+ Sub-Task 5 (README + AGENTS.md) βœ“ completed
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Files Deleted After Implementation
232
+
233
+ - `rag_pipeline.py` β€” replaced by `watsonx_client.py`
234
+ - `embed.txt` β€” was a debugging artifact, no longer needed
235
+ - `ibm-credentials.env` β€” replaced by `.env`
236
+ - `__pycache__/` β€” stale cache from old modules
237
+
238
+ ---
239
+
240
+ ## UI Theme
241
+
242
+ Ultra-light eco green theme (implemented in `app.py`):
243
+ - Background: `#fcfcfd` (near white)
244
+ - Primary accent: `#2e7d50` (green)
245
+ - Cards: `#ffffff` with subtle shadows
246
+ - Text: `#1c1c1e` (near black), muted: `#4a4a4e`
247
+ - Borders: `#e4e4e7` (light gray)
248
+ - CheckboxGroup styled as selectable pills/chips
249
+ - Chatbot with welcome placeholder and white-flash prevention CSS
250
+
251
+ ---
252
+
253
+ ## Prompt Engineering Pattern
254
+
255
+ This project uses **prompt engineering** to transform IBM Granite into a domain-specific eco advisor:
256
+
257
+ - **System Prompt:** 86-line `AGENT_INSTRUCTIONS` defining persona, output format, focus areas, and guardrails
258
+ - **Static Knowledge:** `IMPACT_TABLE` (20 eco actions) and `PRODUCT_RECS` (8 material categories) injected via prompt
259
+ - **Dynamic Context:** Household profile (members, location, habits) injected per session
260
+ - **Output Format:** Fixed 4-part structure (Quick Tip β†’ Why it Matters β†’ Impact β†’ Optional Resource)
261
+ - **Guardrails:** Never invent stats, label [Lookup] vs [Estimate], no medical/financial advice
262
+
263
+ ---
264
+
265
+ ## Open Questions / Notes
266
+
267
+ 1. **`WATSONX_PROJECT_ID`** is required by the SDK for `ModelInference`. The user must create a project in watsonx.ai Studio (https://eu-de.dataplatform.cloud.ibm.com/) and copy the project ID. The app shows a clear setup error if it is missing.
268
+ 2. **`ibm/granite-4-h-small`** is the primary model. Fallback: `ibm/granite-3-3-8b-instruct` if granite-4-h-small is not available.
269
+ 3. **Session state**: All eco score and action logging is in-memory `gr.State` β€” no database. This resets on page refresh, which is acceptable per the spec ("session-based is fine").
270
+ 4. **Impact numbers in `IMPACT_TABLE`**: Sourced from well-known lifecycle assessment references (IPCC, EPA, Indian BEE data). Each entry notes its source category (lookup vs estimate).
271
+ 5. **Gradio 6.x compatibility**: `theme` and `css` parameters go in `launch()`, not `Blocks()` constructor. History uses structured content blocks, not plain string pairs.
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -----------------------------------------------------------------------
2
+ # EcoAgent v2 β€” Python dependencies
3
+ # Python 3.10+ | Target: Hugging Face Spaces (CPU tier)
4
+ # Install: pip install -r requirements.txt
5
+ # or: uv pip install -r requirements.txt
6
+ # -----------------------------------------------------------------------
7
+
8
+ # Gradio UI framework β€” pinned to installed version
9
+ gradio==6.20.0
10
+
11
+ # IBM watsonx.ai SDK (ModelInference + Credentials)
12
+ ibm-watsonx-ai>=1.1.15
13
+
14
+ # HTTP client (used by ibm-watsonx-ai internally and for any direct calls)
15
+ requests>=2.32.3
16
+
17
+ # Credential / .env loading
18
+ python-dotenv>=1.0.1
watsonx_client.py ADDED
@@ -0,0 +1,520 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ watsonx_client.py
3
+ -----------------
4
+ IBM watsonx.ai SDK client for EcoAgent.
5
+
6
+ Provides:
7
+ - AGENT_INSTRUCTIONS : Editable agent behaviour / persona block
8
+ - IMPACT_TABLE : CO2/water/waste lookup for 20 common eco actions
9
+ - get_eco_answer() : Multi-turn chat via IBM Granite
10
+ - get_recycling_guide(): Single-turn recycling lookup for Indian cities
11
+
12
+ Region: eu-de (Frankfurt) | Model: ibm/granite-4-h-small
13
+ """
14
+
15
+ import os
16
+ import logging
17
+ from dotenv import load_dotenv
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Load credentials from .env
21
+ # ---------------------------------------------------------------------------
22
+ load_dotenv(".env")
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ===========================================================================
27
+ # AGENT INSTRUCTIONS
28
+ # Edit this block to customise persona, tone, focus areas, and rules.
29
+ # ===========================================================================
30
+ AGENT_INSTRUCTIONS = """
31
+ You are EcoAgent β€” a friendly, knowledgeable, and action-focused eco lifestyle
32
+ advisor specialised in the Indian context. Your goal is to help Indian households
33
+ live more sustainably through practical, affordable, and culturally relevant advice.
34
+
35
+ ## Persona & Tone
36
+ - Warm, encouraging, and non-preachy
37
+ - Concise: lead with the action, not the theory
38
+ - Use simple English; avoid jargon
39
+ - Celebrate small wins β€” every action counts
40
+
41
+ ## Answer Structure (ALWAYS follow this)
42
+ 1. **Quick Tip** (1–2 sentences): the specific action the user should take
43
+ 2. **Why it Matters** (1 sentence): the environmental/health/cost benefit
44
+ 3. **Impact** (1 line): if the action is in the impact table, state the exact figure
45
+ and label it "[Lookup]"; otherwise estimate and label it "[Estimate]"
46
+ 4. **Optional Resource** (1 line): a relevant Indian scheme, website, or product
47
+ β€” only include if genuinely useful, never invent URLs
48
+
49
+ ## Sustainability Focus Areas
50
+ - Plastic reduction and single-use alternatives
51
+ - Energy efficiency (LED, appliances, solar)
52
+ - Water conservation (short showers, rainwater harvesting, drip irrigation)
53
+ - Eco-friendly travel (public transport, cycling, EVs under FAME scheme)
54
+ - Food choices (reduce meat, local/seasonal produce, reduce food waste)
55
+ - Waste management (segregation, composting, e-waste disposal)
56
+
57
+ ## India-Specific Context
58
+ - Always reference Indian government schemes where applicable:
59
+ * PM Surya Ghar Muft Bijli Yojana (rooftop solar, up to 300 units free/month)
60
+ * FAME II / PM e-DRIVE (EV subsidies for 2W and 3W vehicles)
61
+ * Swachh Bharat Mission (waste management, ODF)
62
+ * Jal Jeevan Mission (clean water, conservation)
63
+ * UJALA scheme (LED bulb distribution at subsidised prices)
64
+ * National Biogas Programme (biogas plants for households)
65
+ - Reference Indian brands and local alternatives where helpful
66
+ (e.g., Bamboo India, Bare Necessities, The Better India marketplace)
67
+ - Recycling norms vary by city β€” acknowledge this and advise accordingly
68
+ - Common Indian household practices to acknowledge:
69
+ * Pressure cookers, clay pots, steel utensils (already eco-friendly)
70
+ * Festivals with high waste (Diwali crackers, Holi colours)
71
+ * Joint family structures β†’ household-level advice is very relevant
72
+
73
+ ## Safety & Accuracy Rules
74
+ - NEVER invent statistics or make up government scheme details
75
+ - If you are not sure of a specific number, say "approximately" and label [Estimate]
76
+ - Do not recommend products with specific prices (prices change)
77
+ - If the user's question is outside your eco domain, politely redirect
78
+ - Do not provide medical, legal, or financial advice
79
+
80
+ ## Household Context
81
+ When a household profile is provided, tailor advice to:
82
+ - The number of family members (scale savings accordingly)
83
+ - Current habits (avoid advising things they already do)
84
+ - Location (city-specific recycling facilities, local schemes)
85
+ - Specific constraints (e.g., rented accommodation β†’ skip solar panel advice)
86
+ """
87
+
88
+ # ===========================================================================
89
+ # CARBON / RESOURCE IMPACT LOOKUP TABLE
90
+ # Sources: IPCC AR6, EPA GHG equivalencies, BEE India, WRI India reports,
91
+ # Central Pollution Control Board (CPCB) India data.
92
+ # Each action maps to annual savings for ONE person unless noted.
93
+ # ===========================================================================
94
+ IMPACT_TABLE: dict[str, dict] = {
95
+ "cloth_bags": {
96
+ "label": "Switch to cloth/jute bags",
97
+ "co2_kg_year": 3.0,
98
+ "water_L_day": 0,
99
+ "waste_kg_year": 5.0,
100
+ "source": "Lookup",
101
+ "note": "Avoids ~150 plastic bags/year @ 20g CO2 each",
102
+ },
103
+ "led_bulbs": {
104
+ "label": "Replace all bulbs with LED",
105
+ "co2_kg_year": 45.0,
106
+ "water_L_day": 0,
107
+ "waste_kg_year": 0,
108
+ "source": "Lookup",
109
+ "note": "Avg Indian home 8 bulbs; 60W→9W LED, 6h/day, Indian grid 0.82 kg CO2/kWh",
110
+ },
111
+ "solar_panels": {
112
+ "label": "Install rooftop solar (1 kW)",
113
+ "co2_kg_year": 820.0,
114
+ "water_L_day": 0,
115
+ "waste_kg_year": 0,
116
+ "source": "Lookup",
117
+ "note": "1 kW @ 4.5 peak sun hours, 0.82 kg CO2/kWh displaced",
118
+ },
119
+ "composting": {
120
+ "label": "Compost kitchen waste",
121
+ "co2_kg_year": 120.0,
122
+ "water_L_day": 0,
123
+ "waste_kg_year": 150.0,
124
+ "source": "Lookup",
125
+ "note": "Avg 400g/day organic waste; avoids landfill methane",
126
+ },
127
+ "public_transport": {
128
+ "label": "Use public transport instead of car",
129
+ "co2_kg_year": 1200.0,
130
+ "water_L_day": 0,
131
+ "waste_kg_year": 0,
132
+ "source": "Lookup",
133
+ "note": "20 km/day commute; 180g CO2/km (petrol car) vs 30g CO2/km (metro/bus)",
134
+ },
135
+ "short_shower": {
136
+ "label": "Reduce shower time by 2 minutes",
137
+ "co2_kg_year": 12.0,
138
+ "water_L_day": 20.0,
139
+ "waste_kg_year": 0,
140
+ "source": "Lookup",
141
+ "note": "10 L/min showerhead; 2 min Γ— 10 L = 20 L/day saved",
142
+ },
143
+ "rainwater_harvesting": {
144
+ "label": "Install rainwater harvesting",
145
+ "co2_kg_year": 8.0,
146
+ "water_L_day": 80.0,
147
+ "waste_kg_year": 0,
148
+ "source": "Lookup",
149
+ "note": "Avg 100 sqm roof; 800mm annual rainfall region",
150
+ },
151
+ "vegetarian_diet": {
152
+ "label": "Switch to vegetarian diet",
153
+ "co2_kg_year": 550.0,
154
+ "water_L_day": 800.0,
155
+ "waste_kg_year": 0,
156
+ "source": "Lookup",
157
+ "note": "Meat diet 2.5 kg CO2/day vs veg 1.0 kg CO2/day; water footprint halved",
158
+ },
159
+ "no_plastic_bottles": {
160
+ "label": "Use refillable steel/copper water bottle",
161
+ "co2_kg_year": 6.5,
162
+ "water_L_day": 0,
163
+ "waste_kg_year": 8.0,
164
+ "source": "Lookup",
165
+ "note": "Avoids ~500 plastic bottles/year; 13g CO2 per PET bottle",
166
+ },
167
+ "drip_irrigation": {
168
+ "label": "Switch to drip irrigation (garden/farm)",
169
+ "co2_kg_year": 0,
170
+ "water_L_day": 200.0,
171
+ "waste_kg_year": 0,
172
+ "source": "Lookup",
173
+ "note": "Drip uses 30–50% less water than flood irrigation; 40% saving assumed",
174
+ },
175
+ "smart_powerstrip": {
176
+ "label": "Use smart power strip / switch off standby",
177
+ "co2_kg_year": 28.0,
178
+ "water_L_day": 0,
179
+ "waste_kg_year": 0,
180
+ "source": "Lookup",
181
+ "note": "Standby power ~10% of home electricity; 350 kWh/year at 0.82 kg CO2/kWh",
182
+ },
183
+ "electric_two_wheeler": {
184
+ "label": "Switch from petrol 2W to electric",
185
+ "co2_kg_year": 380.0,
186
+ "water_L_day": 0,
187
+ "waste_kg_year": 0,
188
+ "source": "Lookup",
189
+ "note": "30 km/day; petrol scooter 70g CO2/km vs EV 15g CO2/km (Indian grid)",
190
+ },
191
+ "reusable_bags_produce": {
192
+ "label": "Use mesh bags for fruits/vegetables",
193
+ "co2_kg_year": 1.5,
194
+ "water_L_day": 0,
195
+ "waste_kg_year": 3.0,
196
+ "source": "Lookup",
197
+ "note": "Avoids ~150 thin plastic produce bags/year",
198
+ },
199
+ "fix_water_leaks": {
200
+ "label": "Fix dripping taps and leaking pipes",
201
+ "co2_kg_year": 3.0,
202
+ "water_L_day": 30.0,
203
+ "waste_kg_year": 0,
204
+ "source": "Lookup",
205
+ "note": "A dripping tap wastes ~15 L/day; 2 taps assumed",
206
+ },
207
+ "line_dry_clothes": {
208
+ "label": "Line-dry clothes instead of electric dryer",
209
+ "co2_kg_year": 100.0,
210
+ "water_L_day": 0,
211
+ "waste_kg_year": 0,
212
+ "source": "Lookup",
213
+ "note": "Electric dryer ~3 kWh/load, 3 loads/week; 0.82 kg CO2/kWh",
214
+ },
215
+ "seasonal_local_produce": {
216
+ "label": "Buy seasonal and locally grown produce",
217
+ "co2_kg_year": 60.0,
218
+ "water_L_day": 0,
219
+ "waste_kg_year": 0,
220
+ "source": "Lookup",
221
+ "note": "Reduces food transport emissions; avg 200g CO2/km per tonne",
222
+ },
223
+ "segregate_waste": {
224
+ "label": "Segregate wet/dry/hazardous waste at home",
225
+ "co2_kg_year": 90.0,
226
+ "water_L_day": 0,
227
+ "waste_kg_year": 200.0,
228
+ "source": "Lookup",
229
+ "note": "Enables recycling of 55% of household waste; avoids landfill methane",
230
+ },
231
+ "pressure_cooker": {
232
+ "label": "Use pressure cooker instead of open pot",
233
+ "co2_kg_year": 18.0,
234
+ "water_L_day": 0,
235
+ "waste_kg_year": 0,
236
+ "source": "Lookup",
237
+ "note": "70% faster cooking β†’ 70% less LPG; 1 kg LPG = 3 kg CO2",
238
+ },
239
+ "no_single_use_plastic": {
240
+ "label": "Eliminate single-use plastics (cutlery, straws, cups)",
241
+ "co2_kg_year": 5.0,
242
+ "water_L_day": 0,
243
+ "waste_kg_year": 10.0,
244
+ "source": "Lookup",
245
+ "note": "India banned SUP Jul 2022; alternatives: bamboo, steel, areca leaf",
246
+ },
247
+ "organic_farming": {
248
+ "label": "Switch to organic / natural farming inputs",
249
+ "co2_kg_year": 200.0,
250
+ "water_L_day": 50.0,
251
+ "waste_kg_year": 0,
252
+ "source": "Estimate",
253
+ "note": "Avoids synthetic fertiliser (4 kg CO2 per kg N); varies widely by crop",
254
+ },
255
+ }
256
+
257
+
258
+ # ===========================================================================
259
+ # ECO-FRIENDLY PRODUCT RECOMMENDATIONS BY CATEGORY
260
+ # (Used in the Recycling & Products tab)
261
+ # ===========================================================================
262
+ PRODUCT_RECS: dict[str, list[str]] = {
263
+ "Plastic": [
264
+ "Bamboo India β€” bamboo toothbrushes, combs, straws",
265
+ "Bare Necessities β€” zero-waste personal care products",
266
+ "StorTi β€” stainless steel food storage containers",
267
+ "Paperwala β€” kraft paper bags for shopping",
268
+ ],
269
+ "Paper": [
270
+ "Use both sides before recycling",
271
+ "Switch to digital billing to reduce paper waste",
272
+ "Recycled paper products: Haathi Chaap (elephant-dung paper crafts)",
273
+ "Paper log briquettes for biomass energy",
274
+ ],
275
+ "Glass": [
276
+ "Milkbasket / local dairy β€” refillable glass bottles",
277
+ "Borosil glass containers as plastic-free food storage",
278
+ "Reuse glass jars for storage (zero cost!)",
279
+ ],
280
+ "E-waste": [
281
+ "E-Parisaraa β€” India's first e-waste recycler (Bangalore)",
282
+ "Karma Recycling β€” e-waste pick-up across major Indian cities",
283
+ "Attero Recycling β€” certified e-waste management",
284
+ "Check manufacturer take-back: Dell, HP, Samsung have return programmes",
285
+ ],
286
+ "Metal": [
287
+ "Scrap dealers (kabadiwala) for steel, copper, aluminium",
288
+ "Steel Recycling Institute of India (SRII) facility locator",
289
+ "Avoid single-use aluminium foil; use beeswax wraps instead",
290
+ ],
291
+ "Organic": [
292
+ "Daily Dump β€” home composting kits (Bangalore, ships PAN India)",
293
+ "Kambha composting pots β€” traditional Indian clay composters",
294
+ "SBI (Solid Biomass India) β€” biogas kits for kitchen waste",
295
+ "Vermi-composting kits via TNAU / KVK agricultural centres",
296
+ ],
297
+ "Batteries": [
298
+ "Exide / Amaron authorised collection centres for lead-acid batteries",
299
+ "Panasonic / Duracell β€” collect at Croma / Reliance Digital stores",
300
+ "Switch to rechargeable NiMH batteries (Envie brand India)",
301
+ "Solar lanterns: Greenlight Planet / Minda (avoid disposables)",
302
+ ],
303
+ "Clothing": [
304
+ "ThriftMyFashion / The Loom (pre-owned clothing platforms)",
305
+ "Ekgaon β€” organic cotton and natural dye clothing",
306
+ "Upasana Design Studio β€” sustainable handloom fashion",
307
+ "Goonj β€” donate old clothes for rural upcycling",
308
+ "Repair before discarding: local darzi (tailor) network",
309
+ ],
310
+ }
311
+
312
+ # Indian cities for recycling guide
313
+ INDIAN_CITIES = [
314
+ "Mumbai", "Delhi", "Bangalore", "Hyderabad", "Chennai",
315
+ "Kolkata", "Pune", "Ahmedabad", "Jaipur", "Lucknow",
316
+ "Kochi", "Chandigarh", "Bhopal", "Indore", "Surat",
317
+ ]
318
+
319
+ # ===========================================================================
320
+ # watsonx.ai CLIENT SETUP
321
+ # Uses APIClient pattern with set_default_project(), matching IBM example code.
322
+ # ===========================================================================
323
+ _WATSONX_API_KEY = os.environ.get("WATSONX_API_KEY", "")
324
+ _WATSONX_URL = os.environ.get("WATSONX_URL", "https://eu-de.ml.cloud.ibm.com")
325
+ _WATSONX_PROJECT_ID = os.environ.get("WATSONX_PROJECT_ID", "")
326
+
327
+ # IBM Granite 4 H Small β€” official watsonx.ai model ID for the Granite 4 "H" (tiny) series.
328
+ # The SDK fetches the live model list at runtime; no enum entry is required.
329
+ # Fallback to Granite 3.3 if the project plan does not include Granite 4 access.
330
+ _MODEL_ID_PRIMARY = "ibm/granite-4-h-small"
331
+ _MODEL_ID_FALLBACK = "ibm/granite-3-3-8b-instruct"
332
+
333
+ _model = None # ModelInference instance β€” lazy-initialised on first call
334
+ _api_client = None # APIClient instance β€” reused across calls
335
+
336
+
337
+ def _get_model():
338
+ """Return a cached ModelInference instance, initialising on first call.
339
+
340
+ Uses the APIClient + set_default_project() pattern so the client is
341
+ authenticated once and reused for every subsequent chat call.
342
+ """
343
+ global _model, _api_client
344
+ if _model is not None:
345
+ return _model
346
+
347
+ if not _WATSONX_API_KEY:
348
+ raise EnvironmentError(
349
+ "WATSONX_API_KEY is not set. "
350
+ "Add it to your .env file (see .env.example)."
351
+ )
352
+ if not _WATSONX_PROJECT_ID:
353
+ raise EnvironmentError(
354
+ "WATSONX_PROJECT_ID is not set.\n"
355
+ "How to get it:\n"
356
+ " 1. Go to https://eu-de.dataplatform.cloud.ibm.com\n"
357
+ " 2. Open your project -> Manage tab -> General -> copy Project ID\n"
358
+ " 3. Add WATSONX_PROJECT_ID=<uuid> to your .env file"
359
+ )
360
+
361
+ try:
362
+ from ibm_watsonx_ai import APIClient, Credentials
363
+ from ibm_watsonx_ai.foundation_models import ModelInference
364
+ except ImportError as exc:
365
+ raise ImportError(
366
+ "ibm-watsonx-ai is not installed. Run: pip install ibm-watsonx-ai"
367
+ ) from exc
368
+
369
+ # Build credentials and APIClient β€” mirrors the IBM example code exactly:
370
+ # credentials = Credentials(url=..., api_key=...)
371
+ # api_client = APIClient(credentials, space_id)
372
+ # api_client.set.default_project(space_id)
373
+ credentials = Credentials(url=_WATSONX_URL, api_key=_WATSONX_API_KEY)
374
+ _api_client = APIClient(credentials, _WATSONX_PROJECT_ID)
375
+ _api_client.set.default_project(_WATSONX_PROJECT_ID)
376
+ logger.info("watsonx APIClient initialised (project=%s)", _WATSONX_PROJECT_ID)
377
+
378
+ # Try primary model, fall back silently if unavailable in this project
379
+ for model_id in (_MODEL_ID_PRIMARY, _MODEL_ID_FALLBACK):
380
+ try:
381
+ _model = ModelInference(
382
+ model_id=model_id,
383
+ api_client=_api_client,
384
+ )
385
+ logger.info("watsonx ModelInference initialised: %s", model_id)
386
+ return _model
387
+ except Exception as exc: # noqa: BLE001
388
+ logger.warning(
389
+ "Model %s unavailable (%s) β€” trying fallback", model_id, exc
390
+ )
391
+
392
+ raise RuntimeError(
393
+ f"Neither {_MODEL_ID_PRIMARY!r} nor {_MODEL_ID_FALLBACK!r} could be "
394
+ "initialised. Check your watsonx.ai project has access to these models."
395
+ )
396
+
397
+
398
+ def _build_system_prompt(profile: dict) -> str:
399
+ """Inject household profile context into the system message."""
400
+ profile_block = ""
401
+ if profile:
402
+ members = profile.get("members", 1)
403
+ location = profile.get("location", "India")
404
+ habits = profile.get("habits", [])
405
+ name = profile.get("name", "")
406
+ profile_block = (
407
+ f"\n\n## Current Household Profile\n"
408
+ f"- Household name: {name or 'Not provided'}\n"
409
+ f"- Location: {location}\n"
410
+ f"- Members: {members}\n"
411
+ f"- Current eco habits: {', '.join(habits) if habits else 'None specified'}\n"
412
+ f"\nScale all impact estimates to {members} person(s) where relevant. "
413
+ f"Do not re-recommend habits the household already practises."
414
+ )
415
+ return AGENT_INSTRUCTIONS.strip() + profile_block
416
+
417
+
418
+ def get_eco_answer(messages: list[dict], profile: dict | None = None) -> str:
419
+ """Send a multi-turn conversation to Granite and return the reply.
420
+
421
+ Args:
422
+ messages: List of {"role": "user"|"assistant", "content": str} dicts.
423
+ Do NOT include a system message β€” this function prepends it.
424
+ profile: Optional household profile dict from the Profile tab.
425
+
426
+ Returns:
427
+ The assistant's reply as a plain string.
428
+
429
+ Raises:
430
+ EnvironmentError: Missing credentials (caught by app.py).
431
+ RuntimeError: API call failure (caught by app.py).
432
+ """
433
+ model = _get_model()
434
+ system_prompt = _build_system_prompt(profile or {})
435
+
436
+ full_messages = [{"role": "system", "content": system_prompt}] + messages
437
+
438
+ try:
439
+ response = model.chat(
440
+ messages=full_messages,
441
+ params={
442
+ "max_tokens": 800,
443
+ "temperature": 0.7,
444
+ "top_p": 0.95,
445
+ },
446
+ )
447
+ return response["choices"][0]["message"]["content"].strip()
448
+ except KeyError as exc:
449
+ raise RuntimeError(
450
+ f"Unexpected response format from watsonx.ai: missing key {exc}. "
451
+ f"Raw response: {str(response)[:300]}"
452
+ ) from exc
453
+ except Exception as exc: # noqa: BLE001
454
+ raise RuntimeError(f"watsonx.ai call failed: {exc}") from exc
455
+
456
+
457
+ def get_recycling_guide(material: str, city: str) -> str:
458
+ """Ask Granite for recycling instructions for a specific material and city.
459
+
460
+ Args:
461
+ material: One of the material categories (e.g. "E-waste", "Plastic").
462
+ city: Indian city name for local context.
463
+
464
+ Returns:
465
+ Formatted recycling guide as a markdown string.
466
+ """
467
+ prompt = (
468
+ f"Provide a practical recycling guide for **{material}** waste in {city}, India. "
469
+ f"Include:\n"
470
+ f"1. How to prepare/segregate this waste at home\n"
471
+ f"2. Where to drop it off or how to get it collected in {city}\n"
472
+ f"3. What happens to it after collection (briefly)\n"
473
+ f"4. One eco-friendly alternative to reduce this waste type\n"
474
+ f"Keep the response concise, practical, and India-specific. "
475
+ f"Use bullet points. Label any uncertain details as [Estimate]."
476
+ )
477
+ model = _get_model()
478
+ try:
479
+ response = model.chat(
480
+ messages=[
481
+ {"role": "system", "content": AGENT_INSTRUCTIONS.strip()},
482
+ {"role": "user", "content": prompt},
483
+ ],
484
+ params={"max_tokens": 500, "temperature": 0.4},
485
+ )
486
+ return response["choices"][0]["message"]["content"].strip()
487
+ except Exception as exc: # noqa: BLE001
488
+ raise RuntimeError(f"Recycling guide call failed: {exc}") from exc
489
+
490
+
491
+ def compute_session_impact(logged_actions: list[str], members: int = 1) -> dict:
492
+ """Aggregate CO2/water/waste savings for a list of logged action slugs.
493
+
494
+ Args:
495
+ logged_actions: List of action slug strings from IMPACT_TABLE keys.
496
+ members: Household size to scale savings.
497
+
498
+ Returns:
499
+ Dict with keys: co2_kg_year, water_L_day, waste_kg_year, eco_score (0–100).
500
+ """
501
+ co2 = 0.0
502
+ water = 0.0
503
+ waste = 0.0
504
+ unique = set(logged_actions)
505
+
506
+ for slug in unique:
507
+ entry = IMPACT_TABLE.get(slug)
508
+ if entry:
509
+ co2 += entry.get("co2_kg_year", 0) * members
510
+ water += entry.get("water_L_day", 0) * members
511
+ waste += entry.get("waste_kg_year", 0) * members
512
+
513
+ eco_score = min(100, len(unique) * 8) # 8 pts per unique action, cap 100
514
+ return {
515
+ "co2_kg_year": round(co2, 1),
516
+ "water_L_day": round(water, 1),
517
+ "waste_kg_year": round(waste, 1),
518
+ "eco_score": eco_score,
519
+ "actions_count": len(unique),
520
+ }