Spaces:
Paused
Paused
| """ | |
| watsonx_client.py | |
| ----------------- | |
| IBM watsonx.ai SDK client for EcoAgent. | |
| Provides: | |
| - AGENT_INSTRUCTIONS : Editable agent behaviour / persona block | |
| - IMPACT_TABLE : CO2/water/waste lookup for 20 common eco actions | |
| - get_eco_answer() : Multi-turn chat via IBM Granite | |
| - get_recycling_guide(): Single-turn recycling lookup for Indian cities | |
| Region: eu-de (Frankfurt) | Model: ibm/granite-4-h-small | |
| """ | |
| import os | |
| import logging | |
| from dotenv import load_dotenv | |
| # --------------------------------------------------------------------------- | |
| # Load credentials from .env | |
| # --------------------------------------------------------------------------- | |
| load_dotenv(".env") | |
| logger = logging.getLogger(__name__) | |
| # =========================================================================== | |
| # AGENT INSTRUCTIONS | |
| # Edit this block to customise persona, tone, focus areas, and rules. | |
| # =========================================================================== | |
| AGENT_INSTRUCTIONS = """ | |
| You are EcoAgent — a friendly, knowledgeable, and action-focused eco lifestyle | |
| advisor specialised in the Indian context. Your goal is to help Indian households | |
| live more sustainably through practical, affordable, and culturally relevant advice. | |
| ## Persona & Tone | |
| - Warm, encouraging, and non-preachy | |
| - Concise: lead with the action, not the theory | |
| - Use simple English; avoid jargon | |
| - Celebrate small wins — every action counts | |
| ## Answer Structure (ALWAYS follow this) | |
| 1. **Quick Tip** (1–2 sentences): the specific action the user should take | |
| 2. **Why it Matters** (1 sentence): the environmental/health/cost benefit | |
| 3. **Impact** (1 line): if the action is in the impact table, state the exact figure | |
| and label it "[Lookup]"; otherwise estimate and label it "[Estimate]" | |
| 4. **Optional Resource** (1 line): a relevant Indian scheme, website, or product | |
| — only include if genuinely useful, never invent URLs | |
| ## Sustainability Focus Areas | |
| - Plastic reduction and single-use alternatives | |
| - Energy efficiency (LED, appliances, solar) | |
| - Water conservation (short showers, rainwater harvesting, drip irrigation) | |
| - Eco-friendly travel (public transport, cycling, EVs under FAME scheme) | |
| - Food choices (reduce meat, local/seasonal produce, reduce food waste) | |
| - Waste management (segregation, composting, e-waste disposal) | |
| ## India-Specific Context | |
| - Always reference Indian government schemes where applicable: | |
| * PM Surya Ghar Muft Bijli Yojana (rooftop solar, up to 300 units free/month) | |
| * FAME II / PM e-DRIVE (EV subsidies for 2W and 3W vehicles) | |
| * Swachh Bharat Mission (waste management, ODF) | |
| * Jal Jeevan Mission (clean water, conservation) | |
| * UJALA scheme (LED bulb distribution at subsidised prices) | |
| * National Biogas Programme (biogas plants for households) | |
| - Reference Indian brands and local alternatives where helpful | |
| (e.g., Bamboo India, Bare Necessities, The Better India marketplace) | |
| - Recycling norms vary by city — acknowledge this and advise accordingly | |
| - Common Indian household practices to acknowledge: | |
| * Pressure cookers, clay pots, steel utensils (already eco-friendly) | |
| * Festivals with high waste (Diwali crackers, Holi colours) | |
| * Joint family structures → household-level advice is very relevant | |
| ## Safety & Accuracy Rules | |
| - NEVER invent statistics or make up government scheme details | |
| - If you are not sure of a specific number, say "approximately" and label [Estimate] | |
| - Do not recommend products with specific prices (prices change) | |
| - If the user's question is outside your eco domain, politely redirect | |
| - Do not provide medical, legal, or financial advice | |
| ## Household Context | |
| When a household profile is provided, tailor advice to: | |
| - The number of family members (scale savings accordingly) | |
| - Current habits (avoid advising things they already do) | |
| - Location (city-specific recycling facilities, local schemes) | |
| - Specific constraints (e.g., rented accommodation → skip solar panel advice) | |
| """ | |
| # =========================================================================== | |
| # CARBON / RESOURCE IMPACT LOOKUP TABLE | |
| # Sources: IPCC AR6, EPA GHG equivalencies, BEE India, WRI India reports, | |
| # Central Pollution Control Board (CPCB) India data. | |
| # Each action maps to annual savings for ONE person unless noted. | |
| # =========================================================================== | |
| IMPACT_TABLE: dict[str, dict] = { | |
| "cloth_bags": { | |
| "label": "Switch to cloth/jute bags", | |
| "co2_kg_year": 3.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 5.0, | |
| "source": "Lookup", | |
| "note": "Avoids ~150 plastic bags/year @ 20g CO2 each", | |
| }, | |
| "led_bulbs": { | |
| "label": "Replace all bulbs with LED", | |
| "co2_kg_year": 45.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "Avg Indian home 8 bulbs; 60W→9W LED, 6h/day, Indian grid 0.82 kg CO2/kWh", | |
| }, | |
| "solar_panels": { | |
| "label": "Install rooftop solar (1 kW)", | |
| "co2_kg_year": 820.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "1 kW @ 4.5 peak sun hours, 0.82 kg CO2/kWh displaced", | |
| }, | |
| "composting": { | |
| "label": "Compost kitchen waste", | |
| "co2_kg_year": 120.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 150.0, | |
| "source": "Lookup", | |
| "note": "Avg 400g/day organic waste; avoids landfill methane", | |
| }, | |
| "public_transport": { | |
| "label": "Use public transport instead of car", | |
| "co2_kg_year": 1200.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "20 km/day commute; 180g CO2/km (petrol car) vs 30g CO2/km (metro/bus)", | |
| }, | |
| "short_shower": { | |
| "label": "Reduce shower time by 2 minutes", | |
| "co2_kg_year": 12.0, | |
| "water_L_day": 20.0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "10 L/min showerhead; 2 min × 10 L = 20 L/day saved", | |
| }, | |
| "rainwater_harvesting": { | |
| "label": "Install rainwater harvesting", | |
| "co2_kg_year": 8.0, | |
| "water_L_day": 80.0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "Avg 100 sqm roof; 800mm annual rainfall region", | |
| }, | |
| "vegetarian_diet": { | |
| "label": "Switch to vegetarian diet", | |
| "co2_kg_year": 550.0, | |
| "water_L_day": 800.0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "Meat diet 2.5 kg CO2/day vs veg 1.0 kg CO2/day; water footprint halved", | |
| }, | |
| "no_plastic_bottles": { | |
| "label": "Use refillable steel/copper water bottle", | |
| "co2_kg_year": 6.5, | |
| "water_L_day": 0, | |
| "waste_kg_year": 8.0, | |
| "source": "Lookup", | |
| "note": "Avoids ~500 plastic bottles/year; 13g CO2 per PET bottle", | |
| }, | |
| "drip_irrigation": { | |
| "label": "Switch to drip irrigation (garden/farm)", | |
| "co2_kg_year": 0, | |
| "water_L_day": 200.0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "Drip uses 30–50% less water than flood irrigation; 40% saving assumed", | |
| }, | |
| "smart_powerstrip": { | |
| "label": "Use smart power strip / switch off standby", | |
| "co2_kg_year": 28.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "Standby power ~10% of home electricity; 350 kWh/year at 0.82 kg CO2/kWh", | |
| }, | |
| "electric_two_wheeler": { | |
| "label": "Switch from petrol 2W to electric", | |
| "co2_kg_year": 380.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "30 km/day; petrol scooter 70g CO2/km vs EV 15g CO2/km (Indian grid)", | |
| }, | |
| "reusable_bags_produce": { | |
| "label": "Use mesh bags for fruits/vegetables", | |
| "co2_kg_year": 1.5, | |
| "water_L_day": 0, | |
| "waste_kg_year": 3.0, | |
| "source": "Lookup", | |
| "note": "Avoids ~150 thin plastic produce bags/year", | |
| }, | |
| "fix_water_leaks": { | |
| "label": "Fix dripping taps and leaking pipes", | |
| "co2_kg_year": 3.0, | |
| "water_L_day": 30.0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "A dripping tap wastes ~15 L/day; 2 taps assumed", | |
| }, | |
| "line_dry_clothes": { | |
| "label": "Line-dry clothes instead of electric dryer", | |
| "co2_kg_year": 100.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "Electric dryer ~3 kWh/load, 3 loads/week; 0.82 kg CO2/kWh", | |
| }, | |
| "seasonal_local_produce": { | |
| "label": "Buy seasonal and locally grown produce", | |
| "co2_kg_year": 60.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "Reduces food transport emissions; avg 200g CO2/km per tonne", | |
| }, | |
| "segregate_waste": { | |
| "label": "Segregate wet/dry/hazardous waste at home", | |
| "co2_kg_year": 90.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 200.0, | |
| "source": "Lookup", | |
| "note": "Enables recycling of 55% of household waste; avoids landfill methane", | |
| }, | |
| "pressure_cooker": { | |
| "label": "Use pressure cooker instead of open pot", | |
| "co2_kg_year": 18.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 0, | |
| "source": "Lookup", | |
| "note": "70% faster cooking → 70% less LPG; 1 kg LPG = 3 kg CO2", | |
| }, | |
| "no_single_use_plastic": { | |
| "label": "Eliminate single-use plastics (cutlery, straws, cups)", | |
| "co2_kg_year": 5.0, | |
| "water_L_day": 0, | |
| "waste_kg_year": 10.0, | |
| "source": "Lookup", | |
| "note": "India banned SUP Jul 2022; alternatives: bamboo, steel, areca leaf", | |
| }, | |
| "organic_farming": { | |
| "label": "Switch to organic / natural farming inputs", | |
| "co2_kg_year": 200.0, | |
| "water_L_day": 50.0, | |
| "waste_kg_year": 0, | |
| "source": "Estimate", | |
| "note": "Avoids synthetic fertiliser (4 kg CO2 per kg N); varies widely by crop", | |
| }, | |
| } | |
| # =========================================================================== | |
| # ECO-FRIENDLY PRODUCT RECOMMENDATIONS BY CATEGORY | |
| # (Used in the Recycling & Products tab) | |
| # =========================================================================== | |
| PRODUCT_RECS: dict[str, list[str]] = { | |
| "Plastic": [ | |
| "Bamboo India — bamboo toothbrushes, combs, straws", | |
| "Bare Necessities — zero-waste personal care products", | |
| "StorTi — stainless steel food storage containers", | |
| "Paperwala — kraft paper bags for shopping", | |
| ], | |
| "Paper": [ | |
| "Use both sides before recycling", | |
| "Switch to digital billing to reduce paper waste", | |
| "Recycled paper products: Haathi Chaap (elephant-dung paper crafts)", | |
| "Paper log briquettes for biomass energy", | |
| ], | |
| "Glass": [ | |
| "Milkbasket / local dairy — refillable glass bottles", | |
| "Borosil glass containers as plastic-free food storage", | |
| "Reuse glass jars for storage (zero cost!)", | |
| ], | |
| "E-waste": [ | |
| "E-Parisaraa — India's first e-waste recycler (Bangalore)", | |
| "Karma Recycling — e-waste pick-up across major Indian cities", | |
| "Attero Recycling — certified e-waste management", | |
| "Check manufacturer take-back: Dell, HP, Samsung have return programmes", | |
| ], | |
| "Metal": [ | |
| "Scrap dealers (kabadiwala) for steel, copper, aluminium", | |
| "Steel Recycling Institute of India (SRII) facility locator", | |
| "Avoid single-use aluminium foil; use beeswax wraps instead", | |
| ], | |
| "Organic": [ | |
| "Daily Dump — home composting kits (Bangalore, ships PAN India)", | |
| "Kambha composting pots — traditional Indian clay composters", | |
| "SBI (Solid Biomass India) — biogas kits for kitchen waste", | |
| "Vermi-composting kits via TNAU / KVK agricultural centres", | |
| ], | |
| "Batteries": [ | |
| "Exide / Amaron authorised collection centres for lead-acid batteries", | |
| "Panasonic / Duracell — collect at Croma / Reliance Digital stores", | |
| "Switch to rechargeable NiMH batteries (Envie brand India)", | |
| "Solar lanterns: Greenlight Planet / Minda (avoid disposables)", | |
| ], | |
| "Clothing": [ | |
| "ThriftMyFashion / The Loom (pre-owned clothing platforms)", | |
| "Ekgaon — organic cotton and natural dye clothing", | |
| "Upasana Design Studio — sustainable handloom fashion", | |
| "Goonj — donate old clothes for rural upcycling", | |
| "Repair before discarding: local darzi (tailor) network", | |
| ], | |
| } | |
| # Indian cities for recycling guide | |
| INDIAN_CITIES = [ | |
| "Mumbai", "Delhi", "Bangalore", "Hyderabad", "Chennai", | |
| "Kolkata", "Pune", "Ahmedabad", "Jaipur", "Lucknow", | |
| "Kochi", "Chandigarh", "Bhopal", "Indore", "Surat", | |
| ] | |
| # =========================================================================== | |
| # watsonx.ai CLIENT SETUP | |
| # Uses APIClient pattern with set_default_project(), matching IBM example code. | |
| # =========================================================================== | |
| _WATSONX_API_KEY = os.environ.get("WATSONX_API_KEY", "") | |
| _WATSONX_URL = os.environ.get("WATSONX_URL", "https://eu-de.ml.cloud.ibm.com") | |
| _WATSONX_PROJECT_ID = os.environ.get("WATSONX_PROJECT_ID", "") | |
| # IBM Granite 4 H Small — official watsonx.ai model ID for the Granite 4 "H" (tiny) series. | |
| # The SDK fetches the live model list at runtime; no enum entry is required. | |
| # Fallback to Granite 3.3 if the project plan does not include Granite 4 access. | |
| _MODEL_ID_PRIMARY = "ibm/granite-4-h-small" | |
| _MODEL_ID_FALLBACK = "ibm/granite-3-3-8b-instruct" | |
| _model = None # ModelInference instance — lazy-initialised on first call | |
| _api_client = None # APIClient instance — reused across calls | |
| def _get_model(): | |
| """Return a cached ModelInference instance, initialising on first call. | |
| Uses the APIClient + set_default_project() pattern so the client is | |
| authenticated once and reused for every subsequent chat call. | |
| """ | |
| global _model, _api_client | |
| if _model is not None: | |
| return _model | |
| if not _WATSONX_API_KEY: | |
| raise EnvironmentError( | |
| "WATSONX_API_KEY is not set. " | |
| "Add it to your .env file (see .env.example)." | |
| ) | |
| if not _WATSONX_PROJECT_ID: | |
| raise EnvironmentError( | |
| "WATSONX_PROJECT_ID is not set.\n" | |
| "How to get it:\n" | |
| " 1. Go to https://eu-de.dataplatform.cloud.ibm.com\n" | |
| " 2. Open your project -> Manage tab -> General -> copy Project ID\n" | |
| " 3. Add WATSONX_PROJECT_ID=<uuid> to your .env file" | |
| ) | |
| try: | |
| from ibm_watsonx_ai import APIClient, Credentials | |
| from ibm_watsonx_ai.foundation_models import ModelInference | |
| except ImportError as exc: | |
| raise ImportError( | |
| "ibm-watsonx-ai is not installed. Run: pip install ibm-watsonx-ai" | |
| ) from exc | |
| # Build credentials and APIClient — mirrors the IBM example code exactly: | |
| # credentials = Credentials(url=..., api_key=...) | |
| # api_client = APIClient(credentials, space_id) | |
| # api_client.set.default_project(space_id) | |
| credentials = Credentials(url=_WATSONX_URL, api_key=_WATSONX_API_KEY) | |
| _api_client = APIClient(credentials, _WATSONX_PROJECT_ID) | |
| _api_client.set.default_project(_WATSONX_PROJECT_ID) | |
| logger.info("watsonx APIClient initialised (project=%s)", _WATSONX_PROJECT_ID) | |
| # Try primary model, fall back silently if unavailable in this project | |
| for model_id in (_MODEL_ID_PRIMARY, _MODEL_ID_FALLBACK): | |
| try: | |
| _model = ModelInference( | |
| model_id=model_id, | |
| api_client=_api_client, | |
| ) | |
| logger.info("watsonx ModelInference initialised: %s", model_id) | |
| return _model | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning( | |
| "Model %s unavailable (%s) — trying fallback", model_id, exc | |
| ) | |
| raise RuntimeError( | |
| f"Neither {_MODEL_ID_PRIMARY!r} nor {_MODEL_ID_FALLBACK!r} could be " | |
| "initialised. Check your watsonx.ai project has access to these models." | |
| ) | |
| def _build_system_prompt(profile: dict) -> str: | |
| """Inject household profile context into the system message.""" | |
| profile_block = "" | |
| if profile: | |
| members = profile.get("members", 1) | |
| location = profile.get("location", "India") | |
| habits = profile.get("habits", []) | |
| name = profile.get("name", "") | |
| profile_block = ( | |
| f"\n\n## Current Household Profile\n" | |
| f"- Household name: {name or 'Not provided'}\n" | |
| f"- Location: {location}\n" | |
| f"- Members: {members}\n" | |
| f"- Current eco habits: {', '.join(habits) if habits else 'None specified'}\n" | |
| f"\nScale all impact estimates to {members} person(s) where relevant. " | |
| f"Do not re-recommend habits the household already practises." | |
| ) | |
| return AGENT_INSTRUCTIONS.strip() + profile_block | |
| def get_eco_answer(messages: list[dict], profile: dict | None = None) -> str: | |
| """Send a multi-turn conversation to Granite and return the reply. | |
| Args: | |
| messages: List of {"role": "user"|"assistant", "content": str} dicts. | |
| Do NOT include a system message — this function prepends it. | |
| profile: Optional household profile dict from the Profile tab. | |
| Returns: | |
| The assistant's reply as a plain string. | |
| Raises: | |
| EnvironmentError: Missing credentials (caught by app.py). | |
| RuntimeError: API call failure (caught by app.py). | |
| """ | |
| model = _get_model() | |
| system_prompt = _build_system_prompt(profile or {}) | |
| full_messages = [{"role": "system", "content": system_prompt}] + messages | |
| try: | |
| response = model.chat( | |
| messages=full_messages, | |
| params={ | |
| "max_tokens": 800, | |
| "temperature": 0.7, | |
| "top_p": 0.95, | |
| }, | |
| ) | |
| return response["choices"][0]["message"]["content"].strip() | |
| except KeyError as exc: | |
| raise RuntimeError( | |
| f"Unexpected response format from watsonx.ai: missing key {exc}. " | |
| f"Raw response: {str(response)[:300]}" | |
| ) from exc | |
| except Exception as exc: # noqa: BLE001 | |
| raise RuntimeError(f"watsonx.ai call failed: {exc}") from exc | |
| def get_recycling_guide(material: str, city: str) -> str: | |
| """Ask Granite for recycling instructions for a specific material and city. | |
| Args: | |
| material: One of the material categories (e.g. "E-waste", "Plastic"). | |
| city: Indian city name for local context. | |
| Returns: | |
| Formatted recycling guide as a markdown string. | |
| """ | |
| prompt = ( | |
| f"Provide a practical recycling guide for **{material}** waste in {city}, India. " | |
| f"Include:\n" | |
| f"1. How to prepare/segregate this waste at home\n" | |
| f"2. Where to drop it off or how to get it collected in {city}\n" | |
| f"3. What happens to it after collection (briefly)\n" | |
| f"4. One eco-friendly alternative to reduce this waste type\n" | |
| f"Keep the response concise, practical, and India-specific. " | |
| f"Use bullet points. Label any uncertain details as [Estimate]." | |
| ) | |
| model = _get_model() | |
| try: | |
| response = model.chat( | |
| messages=[ | |
| {"role": "system", "content": AGENT_INSTRUCTIONS.strip()}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| params={"max_tokens": 500, "temperature": 0.4}, | |
| ) | |
| return response["choices"][0]["message"]["content"].strip() | |
| except Exception as exc: # noqa: BLE001 | |
| raise RuntimeError(f"Recycling guide call failed: {exc}") from exc | |
| def compute_session_impact(logged_actions: list[str], members: int = 1) -> dict: | |
| """Aggregate CO2/water/waste savings for a list of logged action slugs. | |
| Args: | |
| logged_actions: List of action slug strings from IMPACT_TABLE keys. | |
| members: Household size to scale savings. | |
| Returns: | |
| Dict with keys: co2_kg_year, water_L_day, waste_kg_year, eco_score (0–100). | |
| """ | |
| co2 = 0.0 | |
| water = 0.0 | |
| waste = 0.0 | |
| unique = set(logged_actions) | |
| for slug in unique: | |
| entry = IMPACT_TABLE.get(slug) | |
| if entry: | |
| co2 += entry.get("co2_kg_year", 0) * members | |
| water += entry.get("water_L_day", 0) * members | |
| waste += entry.get("waste_kg_year", 0) * members | |
| eco_score = min(100, len(unique) * 8) # 8 pts per unique action, cap 100 | |
| return { | |
| "co2_kg_year": round(co2, 1), | |
| "water_L_day": round(water, 1), | |
| "waste_kg_year": round(waste, 1), | |
| "eco_score": eco_score, | |
| "actions_count": len(unique), | |
| } | |