"""Solar, a voice-first solar advisor with an illustrated Nigerian home. Pick your language (shown in its own script), then speak or type your appliances. It detects what you said, fills the appliances, sizes over the real catalog for your location, draws your home with the solar panels and your appliances arranged in the rooms, and speaks the result back in your language. Sizing is exact Python. """ from __future__ import annotations import base64 import difflib import hashlib import json import os import pathlib import re import sys import threading import time HERE = pathlib.Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) try: import buildsmall # noqa except ModuleNotFoundError: sys.path.insert(0, str(HERE.parents[1] / "_platform")) import gradio as gr import catalog # noqa import data import dataset import engine import locations import store # durable accounts + per-user sizing history (shared with the /classic SPA) import strings # seed testimonials for the reviews wall from buildsmall import asr, config, llm, sessions, tts, vision # ── user tracking: count + feedback + optional email ────────────────────────── # Appends a local JSONL. On a Space, set HF_TOKEN + EVENTS_DATASET and a CommitScheduler # mirrors it to a private Dataset every few minutes so usage survives restarts. Pull the # full data privately from that Dataset; only aggregate counts are ever shown in-app. _DATA_DIR = pathlib.Path(os.environ.get("BUILDSMALL_DATA_DIR", str(HERE / "user_data"))) _EVENTS = _DATA_DIR / "events.jsonl" _TRACK_LOCK = threading.Lock() _TRACK_INIT = False def _init_tracking(): global _TRACK_INIT if _TRACK_INIT: return _TRACK_INIT = True try: _DATA_DIR.mkdir(parents=True, exist_ok=True) repo, token = os.environ.get("EVENTS_DATASET"), os.environ.get("HF_TOKEN") if repo and token: from huggingface_hub import CommitScheduler CommitScheduler(repo_id=repo, repo_type="dataset", folder_path=str(_DATA_DIR), path_in_repo="data", every=5, token=token, private=True) except Exception: pass def track_event(etype, data=None): try: rec = {"type": etype, "data": data or {}, "ts": int(time.time())} with _TRACK_LOCK, open(_EVENTS, "a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") except Exception: pass def get_stats(): n = {"sizings": 0, "emails": 0, "feedback": 0} m = {"sizing": "sizings", "email": "emails", "feedback": "feedback"} try: with open(_EVENTS, encoding="utf-8") as f: for line in f: try: ty = json.loads(line).get("type") except Exception: continue k = m.get(ty) if k: n[k] += 1 except FileNotFoundError: pass return n def count_html(): try: n = get_stats()["sizings"] except Exception: n = 0 if not n: return "" return ('
%s ' 'system%s sized with Naija Solar
') % (format(n, ","), "" if n == 1 else "s") def submit_feedback(rating, comment): track_event("feedback", {"rating": rating, "comment": (comment or "").strip()[:400]}) return '
🙏 Thank you, your feedback helps make Naija Solar better.
' def submit_email(email): e = (email or "").strip() if not e: return "" if "@" not in e or "." not in e.split("@")[-1] or len(e) > 120 or " " in e: return '
That email doesn\'t look right, please check it, or leave it blank.
' track_event("email", {"email": e}) return '
✅ You\'re on the list, only useful solar updates, no spam.
' UI = { "en": {"title": "Power your home with the sun", "sub": "Say or type your appliances. I size it, draw your home, and tell you out loud.", "type": "or type: 1 fridge, 2 fans, 6 bulbs", "fine": "Estimates from current Nigerian prices. Confirm with a licensed installer.", "empty": "Your home appears here"}, "pcm": {"title": "Power your house with sun", "sub": "Talk or type your appliances. I go size am, draw your house, and yarn am for you.", "type": "or type: 1 fridge, 2 fans, 6 bulbs", "fine": "Na estimate from current Naija price. Confirm with one licensed installer.", "empty": "Your house go show here"}, "yo": {"title": "Fi oòrùn pèsè iná ilé rẹ", "sub": "Sọ tàbí kọ àwọn ohun-èlò rẹ. Màá ṣe ìwọ̀n, yá ilé rẹ, kí n sọ ọ́ sókè.", "type": "tàbí kọ: 1 fridge, 2 fan, 6 bulb", "fine": "Ìfojúsùn láti owó ọjà Nàìjíríà. Jẹ́rìí pẹ̀lú òṣìṣẹ́ tó ní ìwé-àṣẹ.", "empty": "Ilé rẹ yóò hàn níbí"}, "ha": {"title": "Bayar da wutar gidanka da rana", "sub": "Faɗa ko rubuta kayan aikinka. Zan auna, zana gidanka, in faɗa maka.", "type": "ko rubuta: 1 fridge, 2 fans, 6 bulbs", "fine": "Kiyasi daga farashin Najeriya na yanzu. Tabbatar da kwararren mai shigarwa.", "empty": "Gidanka zai bayyana nan"}, "ig": {"title": "Jiri anyanwụ nye ụlọ gị ọkụ", "sub": "Kwuo ma ọ bụ pịnye ngwa gị. M ga-atụle, sere ụlọ gị, kwuo ya n'olu.", "type": "ma ọ bụ pịnye: 1 fridge, 2 fans, 6 bulbs", "fine": "Atụmatụ site na ọnụahịa Naịjirịa ugbu a. Kwado ya na onye ọrụ nwere ikike.", "empty": "Ụlọ gị ga-apụta ebe a"}, } LANG_NAME = {"en": "English", "pcm": "Pidgin", "yo": "Yorùbá", "ha": "Hausa", "ig": "Igbo"} # Interface labels that also switch with the language (modes + action buttons). LABELS = { "en": {"mv": "Speak your appliances", "mt": "Or type them", "mp": "Or snap / upload photos", "bs": "Size it", "bd": "Detect appliances"}, "pcm": {"mv": "Talk your appliances", "mt": "Or type am", "mp": "Or snap / upload photo", "bs": "Size am", "bd": "Detect appliances"}, "yo": {"mv": "Sọ àwọn ohun-èlò rẹ", "mt": "Tàbí kọ wọ́n", "mp": "Tàbí ya fọ́tò", "bs": "Ṣe ìwọ̀n", "bd": "Wá ohun-èlò"}, "ha": {"mv": "Faɗa kayan aikinka", "mt": "Ko rubuta su", "mp": "Ko ɗauki hoto", "bs": "Auna shi", "bd": "Nemo kayan aiki"}, "ig": {"mv": "Kwuo ngwa gị", "mt": "Ma ọ bụ pịnye ha", "mp": "Ma ọ bụ sere foto", "bs": "Tụọ ya", "bd": "Chọta ngwa"}, } # Scope guardrail: shown when the input is not about appliances or solar. GUARD = { "en": "I only size solar systems from home appliances, so I do not know that and I am not trained for it. Try: one fridge, two fans, six bulbs.", "pcm": "Na only your appliances I sabi use size solar, so I no know that one and dem no train me for am. Try: one fridge, two fans, six bulbs.", "yo": "Awon ohun-elo ile nikan ni mo le fi se isiro oorun, nitori naa emi ko mo nkan yen, won ko si ko mi fun un. Gbiyanju: firiji kan, fan meji, bulb mefa.", "ha": "Kayan aikin gida kawai nake iya auna hasken rana da su, don haka ban san wannan ba kuma ba a horar da ni a kai ba. Gwada: firiji daya, fanka biyu, kwararan fitila shida.", "ig": "Naani ngwa ulo ka m ji atule anyanwu, ya mere amaghi m nke ahu na-azughi m maka ya. Nwaa: otu friji, fan abuo, baolb isii.", } EMOJI = {"Fridge (small)": "🧊", "Chest freezer": "🧊", "Ceiling fan": "🌀", "Standing fan": "🌀", 'TV (32–43" LED)': "📺", "TV (large)": "📺", "Decoder (DStv/GOtv)": "📡", "Sound system": "🔊", "LED bulb": "💡", "Energy-saver bulb": "💡", "Security light": "💡", "Laptop": "💻", "Desktop PC": "🖥️", "Phone charger": "📱", "Wifi router": "📶", "Air conditioner (1HP)": "❄️", "Air conditioner (1.5HP)": "❄️", "Air conditioner (2HP)": "❄️", "Water pump (0.5HP)": "🚰", "Water pump (1HP)": "🚰", "Electric iron": "👔", "Microwave": "🍲", "Blender": "🥤", "Electric kettle": "☕", "Washing machine": "🧺", "Water heater": "♨️"} # ── appliance language parsing (deterministic) ───────────────────────────────── NUM = {"a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10} KW = [("ceiling fan", "Ceiling fan"), ("standing fan", "Standing fan"), ("security light", "Security light"), ("air condition", "Air conditioner (1HP)"), ("washing machine", "Washing machine"), ("water heater", "Water heater"), ("water pump", "Water pump (1HP)"), ("pumping machine", "Water pump (1HP)"), ("sound system", "Sound system"), ("deep freezer", "Chest freezer"), ("chest freezer", "Chest freezer"), ("phone charger", "Phone charger"), ("flat screen", 'TV (32–43" LED)'), ("television", 'TV (32–43" LED)'), ("decoder", "Decoder (DStv/GOtv)"), ("dstv", "Decoder (DStv/GOtv)"), ("gotv", "Decoder (DStv/GOtv)"), ("microwave", "Microwave"), ("blender", "Blender"), ("kettle", "Electric kettle"), ("pressing iron", "Electric iron"), ("electric iron", "Electric iron"), ("desktop", "Desktop PC"), ("computer", "Desktop PC"), ("laptop", "Laptop"), ("router", "Wifi router"), ("wifi", "Wifi router"), ("freezer", "Chest freezer"), ("fridge", "Fridge (small)"), ("refrigerator", "Fridge (small)"), ("ac", "Air conditioner (1HP)"), ("pump", "Water pump (1HP)"), ("iron", "Electric iron"), ("fan", "Standing fan"), ("tv", 'TV (32–43" LED)'), ("bulb", "LED bulb"), ("light", "LED bulb"), ("lamp", "LED bulb"), ("phone", "Phone charger"), ("charger", "Phone charger"), ("aircon", "Air conditioner (1HP)"), ("washer", "Washing machine"), ("telly", 'TV (32–43" LED)'), ("tele", 'TV (32–43" LED)'), ("freeze", "Chest freezer")] KW2 = [(k, a) for k, a in KW if " " in k] KW1 = [(k, a) for k, a in KW if " " not in k] _KW1_MAP = dict(KW1) _FUZZ_KEYS = [k for k, _ in KW1 if len(k) >= 5] # long keys only, safe to fuzzy-match def _base(t): return t[:-1] if (t.endswith("s") and len(t) > 2) else t def _fuzz(t): """Closest appliance keyword for a misspelled token (e.g. 'frige' -> fridge, 'televison' -> TV).""" if len(t) < 5: return None m = difflib.get_close_matches(t, _FUZZ_KEYS, n=1, cutoff=0.82) return _KW1_MAP[m[0]] if m else None def _m1(t): for k, a in KW1: if t == k or _base(t) == k: return a return _fuzz(_base(t)) def _m2(a, b): for k, ap in KW2: w0, w1 = k.split(" ", 1) if a == w0 and (b == w1 or _base(b) == w1 or (len(w1) >= 5 and difflib.SequenceMatcher(None, _base(b), w1).ratio() >= 0.82)): return ap return None def parse_appliances(text): toks = re.sub(r"[^\w\s]", " ", (text or "").lower()).split() sel, pending, i = {}, None, 0 while i < len(toks): tok = toks[i] if tok.isdigit(): pending = (int(tok), i); i += 1; continue if tok in NUM: pending = (NUM[tok], i); i += 1; continue appl, adv = None, 1 if i + 1 < len(toks): appl = _m2(tok, toks[i + 1]) if appl: adv = 2 appl = appl or _m1(tok) if appl: qty = pending[0] if (pending and i - pending[1] <= 3) else 1 sel[appl] = max(sel.get(appl, 0), qty) pending = None; i += adv; continue i += 1 return sel def extract(text): sel = parse_appliances(text) if sel: return sel try: out = llm.json(f"List home appliances and integer quantities from: '{text}'. Use ONLY names from " f"this list: {list(data.APPLIANCES)}. Reply a JSON object name to count.", model="Qwen/Qwen3-1.7B") # Qwen reads messy free-text reliably (MiniCPM5-1B overthinks structured output) if isinstance(out, dict): return {k: int(v) for k, v in out.items() if k in data.APPLIANCES and str(v).strip().lstrip('-').isdigit()} except Exception: pass return sel def detect_language(text): t = (text or "").lower() for code, marks in (("yo", ("ẹ", "ọ", "ṣ", "bawo", "jọwọ", "ilé", "mo ní")), ("ig", ("kedu", "biko", "achọrọ", "ụlọ", "enwere")), ("ha", ("ina", "kana", "yaya", "gida", "muna", "ina so")), ("pcm", ("abeg", "wetin", " dey ", " na ", "wahala", "oga", "i get"))): if any(m in t for m in marks): return code return "en" def _cost_phrase(n): """A speech-friendly amount: 1,924,000 -> '1.9 million', 950,000 -> '950 thousand'. Clearer to read and to hear than a long string of digits with commas.""" if n >= 1_000_000: return f"{n / 1_000_000:.1f}".rstrip("0").rstrip(".") + " million" if n >= 1000: return f"{round(n / 1000)} thousand" return str(n) def _template(r, lang): p, kva = r["panel"]["count"], r["inverter"]["kva"] b, kwh = r["batteries"]["durable"]["count"], r["daily_kwh"] cost = _cost_phrase(r["batteries"]["durable"]["total"]) pw, bw = ("panel" if p == 1 else "panels"), ("battery" if b == 1 else "batteries") T = { "en": f"You use about {kwh} kilowatt hours a day. I recommend {p} solar {pw}, a {kva} kVA inverter, and {b} {bw}. The system costs about {cost} naira. Please confirm with a licensed installer.", "pcm": f"You dey use about {kwh} kilowatt every day. You go need {p} solar {pw}, one {kva} kVA inverter, and {b} {bw}. E go cost around {cost} naira. Make you confirm with a licensed installer.", "yo": f"O ń lo tó {kwh} kilowatt lójúmọ́. Mo dábàá panel oòrùn {p}, inverter {kva} kVA kan, àti bátìrì {b}. Ètò náà yóò ná nǹkan bíi náírà {cost}. Jọ̀wọ́ bèèrè lọ́wọ́ onímọ̀ tó ní ìwé àṣẹ.", "ha": f"Kana amfani da kusan {kwh} kilowatt a kullum. Ina ba da shawarar panel hasken rana {p}, inverter {kva} kVA ɗaya, da batir {b}. Tsarin zai kai kusan naira {cost}. Don Allah ka tabbatar da ƙwararren mai shigarwa.", "ig": f"Ị na-eji ihe dịka {kwh} kilowatt kwa ụbọchị. Ana m atụ aro panel anyanwụ {p}, otu inverter {kva} kVA, na batrị {b}. Usoro a ga-efu ihe dịka naịra {cost}. Biko kwado ya na onye ọrụ nwere ikike.", } return T.get(lang, T["en"]) # ── speech-friendly narration for the Nigerian languages ────────────────────── # SoroTTS is a Yorùbá/Hausa/Igbo voice: it reads pure prose beautifully but stumbles on English # digits and units ("2.66", "1.5 kVA") read mid-sentence, which is what made the voice sound off. # So for yo/ha/ig the narration spells counts as words, drops the hardest units, and rounds the cost # to a spoken approximation. The exact figures still show in the result tiles, and because this same # text is both shown and read, "you hear what you read" still holds. English/Pidgin keep the precise # wording (their voices read digits fine). _NUM = { "yo": ["", "ọ̀kan", "méjì", "mẹ́ta", "mẹ́rin", "márùn-ún", "mẹ́fà", "méje", "mẹ́jọ", "mẹ́sàn-án", "mẹ́wàá", "mọ́kànlá", "méjìlá", "mẹ́tàlá", "mẹ́rìnlá", "mẹ́ẹ̀ẹ́dógún", "mẹ́rìndínlógún", "mẹ́tàdínlógún", "méjìdínlógún", "mọ́kàndínlógún", "ogún"], "ha": ["", "ɗaya", "biyu", "uku", "huɗu", "biyar", "shida", "bakwai", "takwas", "tara", "goma", "goma sha ɗaya", "goma sha biyu", "goma sha uku", "goma sha huɗu", "goma sha biyar", "goma sha shida", "goma sha bakwai", "goma sha takwas", "goma sha tara", "ashirin"], "ig": ["", "otu", "abụọ", "atọ", "anọ", "ise", "isii", "asaa", "asatọ", "itoolu", "iri", "iri na otu", "iri na abụọ", "iri na atọ", "iri na anọ", "iri na ise", "iri na isii", "iri na asaa", "iri na asatọ", "iri na itoolu", "iri abụọ"], } def _num_words(n, lang): t = _NUM.get(lang) try: n = int(n) except (TypeError, ValueError): return str(n) return t[n] if (t and 1 <= n <= 20) else str(n) def _cost_words(total, lang): """A spoken, mostly-round amount in millions (sub-million numerals are unspeakable in these languages). The exact naira figure is shown in the cost card.""" m = round((total or 0) / 1_000_000) if m < 1: return {"yo": "ìdajì mílíọ̀nù náírà", "ha": "rabin miliyan naira", "ig": "ọkara nde naịra"}[lang] w = _num_words(m, lang) return {"yo": f"mílíọ̀nù náírà {w}", "ha": f"naira miliyan {w}", "ig": f"naịra nde {w}"}[lang] def _template_tts(r, lang): """Speech-friendly narration for yo/ha/ig (numbers as words, hard units dropped, cost rounded); English/Pidgin fall back to the precise template.""" if lang not in ("yo", "ha", "ig"): return _template(r, lang) p, b = r["panel"]["count"], r["batteries"]["durable"]["count"] kw = max(1, round(r["daily_kwh"])) P, B, KW = _num_words(p, lang), _num_words(b, lang), _num_words(kw, lang) C = _cost_words(r["batteries"]["durable"]["total"], lang) if lang == "yo": return (f"Iná tí o ń lò tó ìwọ̀n {KW} lójoojúmọ́. Mo dábàá pánẹ́ẹ̀lì oòrùn {P}, " f"ẹ̀rọ̀ amúná ọ̀kan, àti bátìrì {B}. Ètò náà yóò ná tó {C}. " f"Jọ̀wọ́ bèèrè lọ́wọ́ onímọ̀ tó ní ìwé àṣẹ.") if lang == "ha": return (f"Kana amfani da wuta kusan {KW} a kullum. Ina ba da shawarar panel hasken rana {P}, " f"inverter ɗaya, da batir {B}. Tsarin zai kai kusan {C}. " f"Don Allah ka tabbatar da ƙwararren mai shigarwa.") return (f"Ị na-eji ọkụ dịka {KW} kwa ụbọchị. Ana m atụ aro panel anyanwụ {P}, " f"otu inverter, na batrị {B}. Usoro a ga-efu ihe dịka {C}. " f"Biko kwado ya na onye ọrụ nwere ikike.") def _clean(t): return re.sub(r".*?", "", t or "", flags=re.DOTALL).strip() def narrate(r, lang): """The plan in one localized paragraph. It is shown on screen AND read aloud, so the written words and the spoken words are always identical. It is a deterministic template, so it is instant (no model call before the voice), reusable from cache, and dependable in all five languages, which a 1.7B model is not for Yoruba, Hausa or Igbo. For yo/ha/ig the wording is speech-friendly (numbers as words, no bare digits/units), which is what the voice reads cleanly; the exact figures live in the result tiles.""" return _template_tts(r, lang) def _for_tts(text): """Light touch so the voice reads the plan clearly: speak 'kVA' as letters, never as a word.""" return (text or "").replace("kVA", "k V A").replace("kWh", "kilowatt hours") # ── original illustrated artwork (SVG, comic style, renders as an image) ───────── def _img(svg, cls): return f'' def _sun(cx, cy, r, col="#ffd23f", out="#e8a200"): rl = "".join(f'' for a in range(0, 360, 45)) return (f'' f'{rl}') def _palm(x): return (f'' f'' f'' f'' f'' f'' f'') def banner_svg(): def home(x, w, body, roof="#a8553c"): cx = x + w // 2 return ('' '' '' '' '' '' '' ) % (x, w, body, x - 6, cx, x + w + 6, roof, x + 6, w - 12, cx, cx, cx - 5, x + w - 16) def tree(x, s=1.0): return ('' '' '' ) % (x, x + 3, int(15 * s), x - 6, int(11 * s), x + 11, int(12 * s)) glow = ('' '') svg = ('' '' '' '' '' '' '' '' '' + glow + _sun(566, 60, 30) + '' + '' + tree(300) + home(340, 56, "#f0ead8") + home(430, 62, "#e3efe6") + tree(516, 0.9) + home(556, 54, "#f3e3c8") + '' + '') return base64.b64encode(svg.encode()).decode() BANNER = banner_svg() def house_scene(panels, batts, sel, empty=""): p = max(0, int(panels)); show = min(p, 12) cells = "" for i in range(show): c, row = i % 4, i // 3 x, y = 236 + c * 30, 152 + row * 13 cells += ('' '') % (x, y, x, y) label = ('%d solar panels' % p) if p else "" cols, rowsy = [194, 303, 413], [232, 312] floors = ('' '') if sel else "" div = ('' '') if sel else "" icons = "" for idx, (name, qty) in enumerate(list(sel.items())[:6]): cx, cy = cols[idx % 3], rowsy[idx // 3] icons += '%s' % (cx, cy + 10, EMOJI.get(name, "🔌")) if qty > 1: icons += ('' '%d' ) % (cx + 25, cy - 18, cx + 25, cy - 14, qty) extra = '+%d more in the list below' % (len(sel) - 6) if len(sel) > 6 else "" ph = '%s' % empty if not sel else "" batt = "" for j in range(min(int(batts), 4)): batt += '' % (90 + (j % 2) * 23, 322 - (j // 2) * 31) svg = ('' '' '' '' '' '' '' '' '' '' '' '' '' + _sun(556, 74, 28) + '' '' '' + _palm(602) + '' '' '' '' + cells + label + # rooftop water tank on a stand (common in Nigerian homes) '' '' # house body (cutaway) '' + floors + div + icons + extra + ph + # outdoor AC condenser on the grass '' # inverter + batteries (left exterior) '' + batt + '') return _img(svg, "home") def chips_html(sel): items = "".join( '
%s%d%s
' % (EMOJI.get(k, "🔌"), v, k.split(" (")[0]) for k, v in sel.items()) return '
✅ Your appliances
%s
' % items def day_chart_svg(r): """Signature 24-hour visualization: solar generation vs the home's usage.""" import math W, H, L, Rr, T, B = 600, 248, 46, 14, 30, 30 cw, ch = W - L - Rr, H - T - B array_kw = max(0.4, r["array_w"] / 1000.0) daily = max(0.5, r["daily_kwh"]) def gen(h): return array_kw * (math.sin(math.pi * (h - 6) / 13.5) ** 1.5) if 6 < h < 19.5 else 0.0 def shape(h): return 0.3 + 1.0 * math.exp(-((h - 7.5) ** 2) / 5.0) + 1.7 * math.exp(-((h - 20.0) ** 2) / 6.0) hrs = [i * 0.5 for i in range(49)] sc = daily / max(sum(shape(h) for h in hrs) * 0.5, 0.1) def load(h): return shape(h) * sc ymax = max(array_kw, max(load(h) for h in hrs)) * 1.16 X = lambda h: L + (h / 24.0) * cw Y = lambda v: T + ch - (v / ymax) * ch def apath(fn): return ("M%.1f,%.1f" % (X(0), Y(0)) + "".join("L%.1f,%.1f" % (X(h), Y(fn(h))) for h in hrs) + "L%.1f,%.1fZ" % (X(24), Y(0))) def lpath(fn): return "M" + "L".join("%.1f,%.1f" % (X(h), Y(fn(h))) for h in hrs) grid = "" for hh, lab in [(0, "12am"), (6, "6am"), (12, "12pm"), (18, "6pm"), (24, "12am")]: grid += ('' '%s' % (X(hh), T, X(hh), T + ch, X(hh), H - 9, lab)) svg = ('' % (W, H) + '' '' '' '' '' '' + grid + '' % apath(gen) + '' % apath(load) + '' % lpath(gen) + '' % lpath(load) + '' % (X(12.7), Y(gen(12.7))) + '') legend = ('
Solar generation' 'Your usage
') return '
☀️ Your day: sun vs usage
%s%s
' % (svg, legend) def system_view(r): return day_chart_svg(r) + energy_flow_svg(r) def home_2d_svg(r): """Clean 2D illustrated home + system view, a labelled companion to the 3D (always renders).""" panels, kva = r["panel"]["count"], r["inverter"]["kva"] d = r["batteries"]["durable"] items = r.get("profile", {}).get("items", [])[:8] appl = "" for i, it in enumerate(items): cx, cy = 84 + (i % 4) * 60, 198 + (i // 4) * 52 appl += ('%s' 'x%d' % (cx, cy, EMOJI.get(it["name"], "\U0001f50c"), cx, cy + 15, it["qty"])) pan = "".join('' % (62 + i * 37) for i in range(min(panels, 6))) svg = ('' + _sun(536, 52, 26) + '' + pan + '' + 'Your appliances' + appl + '' + 'Power system' + '' + '\U0001f50c %s kVA inverter' % kva + '' + '\U0001f50b %s kWh backup' % d["backup_kwh"] + '☀️ %d solar panels' % panels + '' + '') return '
\U0001f5bc️ Your home & system (2D)
%s
' % svg def energy_flow_svg(r): """B: a live engineering view, energy flowing sun to panels to battery to inverter to loads.""" panels, arrayw, kva = r["panel"]["count"], r["array_w"], r["inverter"]["kva"] bk = r["batteries"]["durable"] backup, nb, loadw, daily = bk["backup_kwh"], bk["count"], r["peak_w"], r["daily_kwh"] rays = "".join('' % a for a in range(0, 360, 45)) def flow(x1, x2, color): dots = "".join('' % (color, k * 0.6, x1, x2) for k in range(3)) return '%s' % (x1, x2, dots) def node(x, w, fill, title, big, sub): cx = x + w / 2 return ('' '%s' '%s' '%s' ) % (x, w, fill, cx, title, cx, big, cx, sub) parts = (flow(66, 150, "#ffd23f") + node(150, 108, "#1f4fd0", "Solar", str(panels), "panels %sW" % arrayw) + flow(258, 312, "#7cf2c0") + node(312, 108, "#1f9d4a", "Battery", str(backup), "kWh %su" % nb) + flow(420, 474, "#c9a3ff") + node(474, 108, "#6b3fd0", "Inverter", str(kva), "kVA") + flow(582, 624, "#ffb15a") + node(624, 86, "#b4530a", "Loads", str(loadw), "W %skWh/d" % daily)) svg = ('' '' '' '' '' '' '' '' + rays + '' + parts + '') return _img(svg, "flow") def load_breakdown_html(sel): rows = sorted(((k, data.APPLIANCES[k][0] * v, v) for k, v in sel.items()), key=lambda x: -x[1]) mx = max((w for _, w, _ in rows), default=1) bars = "".join('
%s %s x%d
' '
%d W
' % (EMOJI.get(k, "🔌"), k.split(" (")[0], v, max(6, round(100 * w / mx)), w) for k, w, v in rows) return '
Load breakdown
%s
' % bars def stat_tiles(r): d = r["batteries"]["durable"] cells = [(f"{r['daily_kwh']}", "kWh / day"), (f"{r['inverter']['kva']} kVA", "system"), (f"₦{d['total']:,}", "estimate")] return '
' + "".join( f'
{v}
{l}
' for v, l in cells) + '
' def _gen_card(r): """For small loads, suggest a plug-and-play portable solar generator as an alternative.""" if r["peak_w"] > 2400 or r["daily_kwh"] > 4: return "" g = dataset.pick_generator(r["peak_w"], r["daily_kwh"]) if not g: return "" return ('
🧳
Or a portable option: %s %s
' '
%d Wh · %d W · about ₦%s, plug-and-play, add a panel to recharge
') % ( g["brand"], g["model"], g["wh"], g["w"], format(g["price_ngn"], ",")) def rec_cards(r): d = r["batteries"]["durable"] rows = [("☀️", f"{r['panel']['count']} solar panels", f"{r['panel']['name']}, about {r['array_w']} W"), ("🔌", "Inverter", r["inverter"]["name"]), ("🔋", f"{d['count']} battery", f"{d['name']}, {d['backup_kwh']} kWh backup")] cards = "".join( f'
{i}
{t}
{s}
' for i, t, s in rows) return '
' + cards + _gen_card(r) + '
' def vendors_html(): fin = {"PAYG": "Pay small-small", "lease": "Lease monthly", "yes": "Installments"} def card(v): tag = fin.get(v.get("financing"), "") chip = ('💳 %s' % tag) if tag else "" offers = ", ".join(v.get("offers", [])[:2]) return ('' '
%s%s
%s
' '
📍 %s
') % ( v["website"], v["name"], chip, offers, v.get("coverage", "Nigeria")) fv = [v for v in dataset.VENDORS if v.get("financing") in fin] dv = [v for v in dataset.VENDORS if v.get("financing") not in fin] out = ('
Vetted Nigerian sellers. The ones marked 💳 let you spread the cost over time, ' 'which makes a system like this far easier to afford. Always confirm current prices and warranty.
') if fv: out += ('
💳 Pay over time (financing)
%s
' % "".join(card(v) for v in fv[:8])) out += '
🛒 Buy direct
%s
' % "".join(card(v) for v in dv[:8]) return out def logo_html(): return ('') # Localized first-time guide (yo/ha/ig are best-effort and deserve a native review). GUIDE_TEXT = { "en": {"why": "Why Naija Solar? Millions of Nigerian homes and shops face daily blackouts and burn money on petrol and diesel. A real solar consultation costs time and money, so most people guess or overpay. Naija Solar gives you a free first estimate in your own language: say, type, or snap your appliances and it sizes a proper solar system with real Nigerian prices.", "steps": [("🗣️", "1. Pick your language", "Tap a language above."), ("🎤", "2. Say, type, or snap your appliances", "Use voice, the text box, or up to 5 photos."), ("🏡", "3. See your home and hear the plan", "Your home, the system, the cost, spoken aloud.")], "privacy": "🔒 Private, nothing is saved to an account. I only size solar from appliances, not wiring, medical, financial, or legal advice. Estimates only."}, "pcm": {"why": "Why Naija Solar? Plenty Naija house and shop dey see blackout every day and dey waste money for fuel and diesel. To call solar person come check fit cost time and money, so most people just dey guess or pay too much. Naija Solar go give you free first estimate for your own language: talk, type, or snap your gadgets and e go size correct solar system with real Naija price.", "steps": [("🗣️", "1. Choose your language", "Tap one language for up."), ("🎤", "2. Talk, type, or snap your things", "Use voice, the text box, or up to 5 photo."), ("🏡", "3. See your house, hear the plan", "Your house, the system, the price, e go talk am.")], "privacy": "🔒 Private, we no dey save anything. I dey only size solar from your gadgets, no be wiring, medical, money, or law advice. Na estimate."}, "yo": {"why": "Kí ló dé Naija Solar? Ọ̀pọ̀ ilé àti ṣọ́ọ̀bù ní Nàìjíríà ni iná máa ń kú lójoojúmọ́, wọ́n sì ń ná owó lórí epo. Pípe ọ̀jọ̀gbọ́n solar lè ná owó àti àkókò, torí náà ọ̀pọ̀ ènìyàn máa ń fojú díwọ̀n tàbí san jù. Naija Solar yóò fún ọ ní ìdíwọ̀n àkọ́kọ́ ọ̀fẹ́ ní èdè rẹ: sọ, kọ, tàbí ya àwòrán ohun-èlò rẹ.", "steps": [("🗣️", "1. Yan èdè rẹ", "Tẹ èdè kan lókè."), ("🎤", "2. Sọ, kọ, tàbí ya àwòrán", "Lo ohùn, àpótí ọ̀rọ̀, tàbí àwòrán márùn-ún."), ("🏡", "3. Rí ilé rẹ, gbọ́ ètò náà", "Ilé rẹ, ètò náà, iye owó, a ó sọ ọ́.")], "privacy": "🔒 Àdáni, kò sí ohun tí a fipamọ́. Mo ń díwọ̀n solar láti inú ohun-èlò nìkan, kì í ṣe ìmọ̀ràn waya, ìṣègùn, owó, tàbí òfin. Ìdíwọ̀n nìkan."}, "ha": {"why": "Me ya sa Naija Solar? Gidaje da shaguna da yawa a Najeriya na fuskantar yankewar wuta kullum suna kuma kashe kuɗi a kan man fetur da dizal. Kawo masanin solar na iya ɗaukar lokaci da kuɗi, don haka mutane da yawa suna ƙididdiga ko biya fiye da kima. Naija Solar zai ba ka kiyasi na farko kyauta a yarenka: faɗa, rubuta, ko ɗauki hoton kayanka.", "steps": [("🗣️", "1. Zaɓi yarenka", "Danna yare a sama."), ("🎤", "2. Faɗa, rubuta, ko ɗauki hoto", "Yi amfani da murya, akwatin rubutu, ko hotuna biyar."), ("🏡", "3. Ga gidanka, ji shirin", "Gidanka, tsarin, farashin, za a faɗa da murya.")], "privacy": "🔒 Na sirri, ba a ajiye komai ba. Ina auna solar daga kayan aiki kawai, ba shawarar waya, lafiya, kuɗi, ko doka ba. Kiyasi kawai."}, "ig": {"why": "Gịnị mere Naija Solar? Ọtụtụ ụlọ na ụlọ ahịa na Naịjirịa na-enwe nkwụsị ọkụ kwa ụbọchị ma na-emefu ego na mmanụ. Ịkpọ ọkachamara solar nwere ike iwe oge na ego, ya mere ọtụtụ mmadụ na-eche echiche ma ọ bụ na-akwụ karịa. Naija Solar ga-enye gị atụmatụ mbụ n'efu n'asụsụ gị: kwuo, dee, ma ọ bụ see foto ngwa gị.", "steps": [("🗣️", "1. Họrọ asụsụ gị", "Pịa asụsụ n'elu."), ("🎤", "2. Kwuo, dee, ma ọ bụ see foto", "Jiri olu, igbe ederede, ma ọ bụ foto ise."), ("🏡", "3. Hụ ụlọ gị, nụ atụmatụ", "Ụlọ gị, usoro ahụ, ọnụahịa, a ga-akpọ ya.")], "privacy": "🔒 Nzuzo, ọ dịghị ihe echekwara. Ana m atụ solar naanị site na ngwa, ọ bụghị ndụmọdụ waya, ahụike, ego, ma ọ bụ iwu. Naanị atụmatụ."}, } def steps_html(lang="en"): g = GUIDE_TEXT.get(lang, GUIDE_TEXT["en"]) cards = "".join('
%s
%s
' '
%s
' % s for s in g["steps"]) return ('
%s
%s
%s
' % (g["why"], cards, g["privacy"])) GUIDE_TITLE = {"en": "New here? How Naija Solar works", "pcm": "You new here? How Naija Solar dey work", "yo": "Tuntun níbí? Bí Naija Solar ṣe ń ṣiṣẹ́", "ha": "Sabo ne? Yadda Naija Solar ke aiki", "ig": "Ọ̀hụrụ ebe a? Otú Naija Solar si arụ ọrụ"} def hero_html(lang): u = UI.get(lang, UI["en"]) return (f'') # ── handlers ────────────────────────────────────────────────────────────────── def _sel_to_df(sel): return [[k, v] for k, v in sel.items()] def _df_to_sel(df): rows = df.values.tolist() if hasattr(df, "values") else (df or []) out = {} for row in rows: try: if str(row[0]).strip() in data.APPLIANCES and int(float(row[1])) > 0: out[str(row[0]).strip()] = int(float(row[1])) except (ValueError, IndexError, TypeError): pass return out def get_session(s): return s if s is not None else sessions.start("Solar") def _psh(state, geolat): return locations.psh_from_lat(geolat) if (geolat or "").strip() else locations.psh_for_state(state) HIDE, SHOW = gr.update(visible=False), gr.update(visible=True) def run(audio, text, state, geolat, uilang, sess): sess = get_session(sess) src, outlang = (text or ""), uilang if audio: try: src = asr.transcribe(audio) if uilang == "en": # respect an explicit language choice; auto-detect only from the default outlang = detect_language(src) sess.event("asr", transcript=src) except Exception: return gr.update(), gr.update(), gr.update(), "", gr.update(), "Could not hear that. Try again or type.", sess, None, uilang, HIDE, gr.update() sel = extract(src) sess.event("input", text=src, lang=outlang, found=len(sel)) if not sel: msg = GUARD.get(outlang, GUARD["en"]) if (src or "").strip() else "" return gr.update(), gr.update(), gr.update(), "", gr.update(), msg, sess, None, outlang, HIDE, gr.update() r = engine.size(sel, _psh(state, geolat)) sess.event("sized", kwh=r["daily_kwh"], panels=r["panel"]["count"], cost=r["batteries"]["durable"]["total"]) content = chips_html(sel) + stat_tiles(r) + rec_cards(r) return (_house_data(r, sel), system_view(r), load_breakdown_html(sel), content, applist_html(_sel_to_df(sel)), "", sess, r, outlang, SHOW, _sel_to_df(sel)) # Persistent on /data so cached audio survives Space restarts (was ./traces, which is wiped on # every rebuild, so the slow first-generation was paid again after each restart). _TTS_CACHE = str(_DATA_DIR / "tts_cache") # Bump this whenever the voice itself changes (e.g. MMS -> SoroTTS). It is part of the cache key, # so old audio rendered by a previous voice is never reused. Without it, a cached Yoruba clip from # the old MMS voice would keep playing for the same plan even after the voice was upgraded. _TTS_VERSION = os.environ.get("TTS_CACHE_VERSION", "sorotts-3") _TTS_CACHE_KEEP = int(os.environ.get("TTS_CACHE_KEEP", "800")) # cap clips on disk (LRU eviction) def _prune_tts_cache(keep=None): """Keep the audio cache bounded on the persistent disk: evict the least-recently-used clips.""" keep = _TTS_CACHE_KEEP if keep is None else keep try: files = [os.path.join(_TTS_CACHE, f) for f in os.listdir(_TTS_CACHE) if f.endswith(".wav")] if len(files) <= keep: return files.sort(key=os.path.getmtime) # oldest (least recently used) first for p in files[:len(files) - keep]: try: os.remove(p) except OSError: pass except OSError: pass def speak(r, lang, sess): if not r: return None, sess sess = get_session(sess) words = narrate(r, lang) # The narration is deterministic, so identical (result + language) reuses the audio. os.makedirs(_TTS_CACHE, exist_ok=True) key = hashlib.md5(("%s|%s|%s" % (_TTS_VERSION, lang, words)).encode("utf-8")).hexdigest() path = os.path.join(_TTS_CACHE, key + ".wav") if os.path.exists(path) and os.path.getsize(path) > 1200: try: os.utime(path, None) # mark recently used so it is kept (LRU) except OSError: pass sess.event("walkthrough", lang=lang, cached=True) return path, sess audio = None try: audio = tts.speak(_for_tts(words), lang=lang, out_path=path) _prune_tts_cache() except Exception: audio = None sess.event("walkthrough", lang=lang, cached=False) return audio, sess def recalc(df, state, geolat, sess): sess = get_session(sess) sel = _df_to_sel(df) if not sel: return gr.update(), gr.update(), gr.update(), "", "Add an appliance.", sess, None, HIDE r = engine.size(sel, _psh(state, geolat)) return (_house_data(r, sel), system_view(r), load_breakdown_html(sel), chips_html(sel) + stat_tiles(r) + rec_cards(r), "", sess, r, SHOW) def from_photos(files, camera, state, geolat, uilang, sess): """Up to 5 uploaded photos or a camera snap: the vision model lists the appliances.""" sess = get_session(sess) paths = [] if camera: paths.append(camera if isinstance(camera, str) else getattr(camera, "name", None)) for f in (files or []): paths.append(f if isinstance(f, str) else getattr(f, "name", None)) paths = [p for p in paths if p][:5] if not paths: return gr.update(), gr.update(), gr.update(), "", [], "Open the camera or add a photo of your appliances.", sess, None, uilang, HIDE seen = {} for path in paths: try: desc = vision.describe(path, "List every household electrical appliance you can see, with counts. " "Use simple names like fridge, fan, bulb, TV, AC, freezer, laptop.") sess.event("vision", desc=desc[:140]) for k, v in extract(desc).items(): seen[k] = seen.get(k, 0) + v except Exception: pass if not seen: return gr.update(), gr.update(), gr.update(), "", gr.update(), "Could not spot appliances. Try a clearer photo or type them.", sess, None, uilang, HIDE, gr.update() r = engine.size(seen, _psh(state, geolat)) sess.event("sized_photos", panels=r["panel"]["count"]) return (_house_data(r, seen), system_view(r), load_breakdown_html(seen), chips_html(seen) + stat_tiles(r) + rec_cards(r), applist_html(_sel_to_df(seen)), "Detected from your photo. Open 'Adjust appliances' to select or fix.", sess, r, uilang, SHOW, _sel_to_df(seen)) def applist_html(rows): """Render the current appliance list as chips (HTML always re-renders reliably).""" if not rows: return '
No appliances yet. Pick one above and tap Add.
' chips = "".join( '%s %s ×%s' % ( EMOJI.get(r[0], "🔌"), str(r[0]).split(" (")[0], r[1]) for r in rows) return '
%s
' % chips def add_appliance(name, qty, cur): """Add or update one appliance in the state-backed list (the reliable source of truth).""" rows = [list(x) for x in (cur or [])] if not name: return rows, applist_html(rows) try: q = max(1, min(99, int(float(qty or 1)))) except (ValueError, TypeError): q = 1 for r in rows: if str(r[0]).strip().lower() == str(name).strip().lower(): r[1] = q break else: rows.append([name, q]) return rows, applist_html(rows) def clear_appliances(): return [], applist_html([]) def set_lang(code): u = UI.get(code, UI["en"]) b = LABELS.get(code, LABELS["en"]) return (code, hero_html(code), gr.update(placeholder=u["type"]), f'
{u["fine"]}
', '
🎤 %s tap Record, speak, then Stop, it sizes itself
' % b["mv"], '
⌨️ %s
' % b["mt"], '
📷 %s
' % b["mp"], gr.update(value=b["bs"]), gr.update(value=b["bd"]), steps_html(code), gr.update(label=GUIDE_TITLE.get(code, GUIDE_TITLE["en"]))) GEO_JS = ("() => new Promise(res => { if(!navigator.geolocation){res('');return;} " "navigator.geolocation.getCurrentPosition(p => res(''+p.coords.latitude), () => res('')); })") def geo_note(lat): return f"📍 {locations.psh_from_lat(lat)} sun hours" if (lat or "").strip() else "" CSS = """ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700;800&display=swap'); :root{--cream:#fffbf2;--panel:#ffffff;--ink:#1b1b1b;--muted:#6b7280;--sun:#ff8a00;--amber:#f4a300;--green:#1f7a4d;--green-d:#0c4a30;--border:#eee2cf} *{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif} .banner h1,.logo .wm,.tile .v,.vsec,.apphd,.sumbar,h1,h2,h3{font-family:'Space Grotesk','Inter',sans-serif;letter-spacing:-.02em} .gradio-container{max-width:700px!important;margin:0 auto!important; background:linear-gradient(180deg,#fffbf2 0%,#fff6e8 42%,#fdf5ea 100%)!important; background-image:linear-gradient(180deg,#fffbf2 0%,#fff6e8 42%,#fdf5ea 100%),url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='104' height='104'%3E%3Cg fill='none' stroke='%23f4a300' stroke-width='1.3' opacity='0.08'%3E%3Crect x='12' y='16' width='34' height='22' rx='2'/%3E%3Cline x1='23' y1='16' x2='23' y2='38'/%3E%3Cline x1='34.5' y1='16' x2='34.5' y2='38'/%3E%3Cline x1='12' y1='27' x2='46' y2='27'/%3E%3C/g%3E%3Ccircle cx='76' cy='72' r='7.5' fill='%23ff8a00' opacity='0.09'/%3E%3Cg stroke='%23ff8a00' stroke-width='1.3' opacity='0.09'%3E%3Cline x1='76' y1='57' x2='76' y2='61'/%3E%3Cline x1='76' y1='83' x2='76' y2='87'/%3E%3Cline x1='61' y1='72' x2='65' y2='72'/%3E%3Cline x1='87' y1='72' x2='91' y2='72'/%3E%3C/g%3E%3C/svg%3E")!important; background-size:auto,104px 104px!important} footer{display:none!important} .logo{display:flex;align-items:center;justify-content:center;gap:11px;padding-top:16px} .logobadge{display:inline-flex;align-items:center;justify-content:center;width:46px;height:46px;border-radius:50%;background:radial-gradient(circle at 40% 35%,#fff6dd,#ffe9b0);box-shadow:0 6px 16px rgba(251,133,0,.28),inset 0 0 0 1.5px #ffd98a} .livepill{display:inline-flex;align-items:center;gap:6px;font-size:.64rem;font-weight:700;color:#0c4a30;background:#eaf7ee;border:1px solid #bfe3cf;border-radius:999px;padding:4px 9px;letter-spacing:.07em;text-transform:uppercase} .livedot{width:7px;height:7px;border-radius:50%;background:#22c55e;animation:livepulse 2s infinite} @keyframes livepulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.55)}70%{box-shadow:0 0 0 7px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}} .logo .wm{font-weight:800;font-size:1.5rem;color:var(--green-d);letter-spacing:-.02em;line-height:1} .logo .tag{display:block;font-size:.6rem;font-weight:700;color:#059669;letter-spacing:.1em;text-transform:uppercase;margin-top:3px} .langrow{justify-content:center;gap:8px;margin-top:12px}.langrow .wrap{justify-content:center;gap:8px;flex-wrap:wrap} .langrow label{border:1.6px solid #bfe3d0!important;border-radius:999px!important;padding:7px 16px!important;background:#fff!important;font-weight:600!important;color:#15803d!important;transition:transform .16s ease,box-shadow .16s ease,background .16s ease} .langrow label:hover{border-color:#34d399!important;transform:translateY(-1px);box-shadow:0 4px 12px rgba(5,150,105,.16)} .langrow label.selected,.langrow label:has(input:checked){background:linear-gradient(135deg,#059669,#047857)!important;color:#fff!important;border-color:#047857!important;box-shadow:0 5px 14px rgba(5,150,105,.32)} .langlabel{text-align:center;color:#15803d;font-weight:700;font-size:.82rem;letter-spacing:.02em;margin-top:8px;opacity:.85} .banner{border-radius:28px;background-size:cover;background-position:center;padding:30px 28px;margin-top:12px;min-height:152px;display:flex;align-items:center; box-shadow:0 18px 44px rgba(20,90,50,.28);overflow:hidden;position:relative} .banner .bwrap{max-width:66%;text-shadow:0 2px 12px rgba(0,0,0,.45);position:relative;z-index:2} .banner h1{margin:0;color:#fff;font-weight:800;font-size:1.85rem;letter-spacing:-.02em;line-height:1.12} .banner p{margin:10px 0 0;color:#eafff0;font-size:1rem;line-height:1.45;font-weight:500} .steps{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:14px 0 6px} .step{display:flex;gap:10px;align-items:flex-start;background:rgba(255,255,255,.78);border:1px solid #eee2cf;border-radius:16px;padding:12px} .step .se{font-size:20px}.step .st{font-weight:700;color:#064e3b;font-size:.84rem}.step .ss{color:#5b7a64;font-size:.77rem;margin-top:2px} .privacy{text-align:center;color:#5b7a64;font-size:.8rem;margin:4px 0 2px} .locrow{justify-content:center;gap:8px;margin-top:6px}.locnote{text-align:center;color:#5b7a64;font-size:.82rem} .miccard{background:rgba(255,255,255,.92);backdrop-filter:blur(8px);border:1.5px solid #eee2cf;border-radius:24px; padding:14px 16px;box-shadow:0 10px 30px rgba(40,110,60,.12);margin-top:12px} .typein textarea,.typein input{border:none!important;background:#eef6ef!important;border-radius:14px!important;font-size:1rem!important} .status{text-align:center;color:#5b7a64;font-size:.9rem;min-height:1em;margin:8px 0} .home{width:100%;border-radius:24px;display:block;box-shadow:0 10px 28px rgba(40,110,60,.16);border:1.5px solid #eee2cf;margin-top:10px} .chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin:16px 0 4px} .chip{display:flex;align-items:center;gap:6px;background:#fff;border:1.5px solid #eee2cf;border-radius:999px;padding:7px 13px;font-size:.92rem;box-shadow:0 2px 6px rgba(40,110,60,.08)} .chip .ce{font-size:1.1rem}.chip b{color:#064e3b}.chip .cn{color:#5b7a64} .tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:16px 0} .tile{background:linear-gradient(165deg,#ffffff,#f2fbf6);border:1.5px solid #cdeadb;border-radius:20px;padding:20px 10px;text-align:center;box-shadow:0 8px 20px rgba(40,110,60,.10);position:relative;overflow:hidden} .tile::before{content:"";position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,#34d399,#059669)} .tile .v{font-size:1.65rem;font-weight:800;color:#059669;letter-spacing:-.02em} .tile .l{color:#5b7a64;font-size:.76rem;margin-top:4px;font-weight:600} .recs{display:grid;gap:10px} .rcard{background:#fff;border:1.5px solid #eee2cf;border-radius:18px;padding:14px 16px;display:flex;gap:14px;align-items:center;box-shadow:0 4px 12px rgba(40,110,60,.07)} .rcard .ic{font-size:22px;width:46px;height:46px;border-radius:14px;background:linear-gradient(160deg,#ecfdf5,#d1fae5);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:inset 0 0 0 1px #bbf0d4}.rcard .t{font-weight:700;color:#064e3b}.rcard .s{color:#5b7a64;font-size:.86rem;margin-top:1px} .fine{color:#8aa394;font-size:.78rem;text-align:center;margin-top:14px} .flow{width:100%;border-radius:24px;display:block;box-shadow:0 12px 30px rgba(20,40,30,.28);margin-top:10px} .flowph{height:170px;border-radius:24px;background:#0f1830;color:#7e8db0;display:flex;align-items:center;justify-content:center;font-size:.95rem;margin-top:10px} .breakdown{margin-top:14px}.bdh{font-weight:700;color:#064e3b;font-size:.9rem;margin-bottom:8px} .bd{display:grid;grid-template-columns:1fr 110px 56px;align-items:center;gap:10px;margin:7px 0} .bn{font-size:.86rem;color:#33402c}.bbar{background:#e3efe3;border-radius:6px;height:9px;overflow:hidden} .bbar i{display:block;height:100%;background:linear-gradient(90deg,#43a047,#059669);border-radius:6px} .bw{text-align:right;color:#5b7a64;font-size:.82rem;font-weight:700} .vendors{display:grid;grid-template-columns:1fr 1fr;gap:10px} .vcard{display:block;background:#fff;border:1.5px solid #eee2cf;border-radius:16px;padding:12px 14px;text-decoration:none} .vtop{display:flex;justify-content:space-between;align-items:center;gap:8px}.vtop b{color:#064e3b;font-size:.92rem} .vfin{background:linear-gradient(135deg,#d1fae5,#a7f3d0);color:#047857;font-size:.66rem;font-weight:700;padding:3px 9px;border-radius:999px;white-space:nowrap;box-shadow:0 1px 4px rgba(5,150,105,.18)} .vintro{color:#3c5a47;font-size:.84rem;line-height:1.45;margin:2px 2px 12px;background:#f1faf4;border:1px solid #eee2cf;border-radius:14px;padding:11px 14px} .vsec{font-weight:800;color:#064e3b;font-size:.92rem;margin:16px 2px 9px;letter-spacing:-.01em} .audiolabel{font-weight:700;color:#064e3b;font-size:.9rem;margin:6px 2px 4px} /* refine gradio default components for a cohesive, premium feel */ .label-wrap{padding:14px 16px!important;border-radius:16px!important;background:#fff!important;border:1.5px solid #e3eee8!important;box-shadow:0 3px 10px rgba(40,110,60,.05)!important;transition:border-color .16s ease,box-shadow .16s ease} .label-wrap:hover{border-color:#bfe3d0!important;box-shadow:0 6px 16px rgba(40,110,60,.1)!important} .label-wrap>span:first-child{font-weight:700!important;color:#064e3b!important;font-size:.96rem!important} .audio-container{border-radius:16px!important;overflow:hidden} .gradio-container .gap{gap:12px} .chcard{background:#fff;border:1.5px solid var(--border);border-radius:20px;padding:16px;box-shadow:0 8px 22px rgba(120,80,20,.07);margin-bottom:12px} .chttl{font-weight:800;color:#064e3b;font-size:1rem;margin-bottom:8px;font-family:'Space Grotesk','Inter',sans-serif} .chcard svg{width:100%;height:auto;display:block} .chlegend{display:flex;gap:18px;justify-content:center;margin-top:10px;font-size:.82rem;color:#5b5444;font-weight:600} .chlegend span{display:inline-flex;align-items:center;gap:6px} .chlegend i{width:15px;height:4px;border-radius:2px;display:inline-block} /* premium voice input: big Gemini-style record button, no device-picker clutter */ .voicein .audio-container{background:linear-gradient(135deg,#f1fbf6,#ffffff)!important;border:1.5px solid #cdeadb!important;border-radius:18px!important;box-shadow:inset 0 1px 4px rgba(40,110,60,.04)} .voicein button.record{background:linear-gradient(135deg,#10b981,#059669)!important;color:#fff!important;border:none!important;border-radius:999px!important;padding:11px 26px!important;font-weight:700!important;box-shadow:0 6px 18px rgba(5,150,105,.34)!important;font-size:.98rem!important;letter-spacing:.01em;transition:transform .15s,filter .15s} .voicein button.record:hover{filter:brightness(1.06)!important;transform:translateY(-1px)} .voicein button.stop-button{background:linear-gradient(135deg,#f87171,#ef4444)!important;color:#fff!important;border-radius:999px!important;border:none!important;box-shadow:0 6px 18px rgba(239,68,68,.32)!important} .voicein select,.voicein .source-selection{display:none!important} /* premium photo drop zones (fully custom look) */ .photofile .wrap,.camerabox .wrap,.camerabox .upload-container{border:2px dashed #cdeadb!important;border-radius:18px!important;background:linear-gradient(135deg,#f6fcf9,#ffffff)!important;transition:border-color .18s,background .18s,box-shadow .18s!important} .photofile .wrap:hover,.camerabox .wrap:hover,.camerabox .upload-container:hover{border-color:#34d399!important;background:#f0fbf5!important;box-shadow:0 8px 18px rgba(5,150,105,.12)!important} .photofile .center,.camerabox .center,.photofile .wrap span,.camerabox .wrap span{color:#059669!important;font-weight:600} .photofile svg,.camerabox svg{color:#10b981!important} /* one-tap example chips */ .exrow{gap:8px!important;flex-wrap:wrap!important} .exbtn{background:#fff!important;border:1.5px solid #cdeadb!important;border-radius:14px!important;font-weight:600!important;color:#0c4a30!important;box-shadow:0 3px 10px rgba(40,110,60,.06)!important;transition:transform .14s,border-color .14s,box-shadow .14s!important;min-width:0!important} .exbtn:hover{border-color:#34d399!important;transform:translateY(-1px);box-shadow:0 7px 16px rgba(40,110,60,.14)!important;background:#f3fbf6!important} /* 3D card: sizing HUD chips + interaction hint */ .house3d{position:relative} .h3dhud{position:absolute;top:12px;left:12px;right:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;pointer-events:none;z-index:5} .h3dhud span{background:rgba(255,255,255,.93);border:1px solid #e3eee8;border-radius:999px;padding:5px 13px;font-size:.8rem;font-weight:700;color:#0c4a30;box-shadow:0 3px 10px rgba(40,110,60,.14);font-family:'Space Grotesk','Inter',sans-serif} .h3dhud .h3dhint{margin-left:auto;background:rgba(6,78,59,.86);color:#fff;font-weight:600;font-family:'Inter',sans-serif;border:none} .chsub{font-weight:500;color:#8a9a7c;font-size:.78rem;margin-left:8px;font-family:'Inter',sans-serif} /* user tracking: social-proof count pill + feedback/email */ #ucount .ucount{text-align:center;margin:7px auto 2px;font-size:.82rem;color:#0c4a30;font-weight:600;display:flex;align-items:center;justify-content:center;gap:7px} #ucount b{font-family:'Space Grotesk','Inter',sans-serif;color:#059669;font-size:.98rem} .udot{width:7px;height:7px;border-radius:50%;background:#10b981;box-shadow:0 0 0 0 rgba(16,185,129,.5);animation:livepulse 2s infinite;display:inline-block} .fbbtn{background:#fff!important;border:1.5px solid #cdeadb!important;border-radius:12px!important;font-weight:600!important;color:#0c4a30!important} .fbbtn:hover{background:#f0fbf5!important;border-color:#34d399!important} .ehint{color:#5b7a64;font-size:.82rem;margin:12px 2px 4px;line-height:1.4} .okmsg{background:#eafaf1;border:1px solid #b7ebcf;border-radius:12px;padding:9px 12px;color:#0c4a30;font-weight:600;font-size:.88rem;margin-top:8px} .warnmsg{background:#fff4e8;border:1px solid #f3d3a8;border-radius:12px;padding:9px 12px;color:#7a4a12;font-weight:600;font-size:.88rem;margin-top:8px} .voff{color:#5b7a64;font-size:.78rem;margin-top:4px;line-height:1.3}.vcov{color:#9bb0a2;font-size:.72rem;margin-top:4px} .story{background:#f3f9f3;border:1px solid #eee2cf;border-radius:14px;padding:12px 14px;font-size:.85rem;color:#3c4a3e;line-height:1.5;margin-bottom:12px}.story b{color:#064e3b} .imode{font-weight:700;color:#064e3b;font-size:.88rem;margin:14px 2px 7px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}.imode span{font-weight:500;color:#8a9a7c;font-size:.74rem} .gobtn{background:linear-gradient(135deg,#10b981,#059669 55%,#047857)!important;color:#fff!important;border:none!important;font-weight:700!important;border-radius:14px!important;box-shadow:0 6px 18px rgba(5,150,105,.34)!important;letter-spacing:.01em} .addbar .wrap{border-radius:14px!important}.adjnote{color:#8a9a7c;font-size:.78rem;margin:2px 0 8px} button,.gobtn,a.vcard,.chip,.langrow label{cursor:pointer} button:focus-visible,.typein textarea:focus-visible,a:focus-visible,.langrow input:focus-visible+span{outline:3px solid #34d399!important;outline-offset:2px!important;border-radius:8px} .gobtn{transition:transform .15s ease,box-shadow .15s ease,filter .15s ease} .gobtn:hover{transform:translateY(-2px);box-shadow:0 12px 26px rgba(5,150,105,.42)!important;filter:brightness(1.06)} .rcard,.tile,.chip,.vcard,.step{transition:transform .18s ease,box-shadow .18s ease} .rcard:hover,.vcard:hover{transform:translateY(-2px);box-shadow:0 8px 22px rgba(5,150,105,.16)} @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}} .house3d{width:100%;min-height:420px;border-radius:24px;background:radial-gradient(120% 95% at 50% 0%,#eaf6ff,#f1faf4);border:1.5px solid #eee2cf;box-shadow:0 12px 30px rgba(40,110,60,.16);margin-top:10px;overflow:hidden;display:flex;align-items:center;justify-content:center} .appbox{background:#fff;border:1.5px solid #eee2cf;border-radius:18px;padding:14px 16px;margin:14px 0 6px;box-shadow:0 4px 14px rgba(40,110,60,.08)} .apphd{font-weight:700;color:#064e3b;font-size:.95rem;margin-bottom:10px} .status{color:#059669!important;font-weight:600!important;font-size:.95rem!important} .house3d canvas{display:block;border-radius:24px} #sumbar{position:fixed;left:0;right:0;bottom:10px;z-index:200;display:flex;justify-content:center;padding:0 12px;pointer-events:none} .sumbar{pointer-events:none;max-width:640px;width:100%;background:rgba(6,78,59,.97);color:#fff;border-radius:16px;padding:11px 16px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;justify-content:center;font-size:.92rem;box-shadow:0 10px 28px rgba(5,80,50,.42)} .sumbar b{color:#a7f3d0;font-weight:700}.sumbar span{opacity:.45;margin:0 4px} .gradio-container{padding-bottom:66px!important} .rcard.alt{border-color:#a7f3d0;border-style:dashed;background:#f1faf4} .applchips{margin:8px 0} .achip{display:inline-flex;align-items:center;gap:5px;background:#ecfdf5;border:1.5px solid #a7f3d0;color:#064e3b;border-radius:999px;padding:6px 12px;margin:3px;font-size:.9rem;font-weight:600} .achip b{color:#059669} .photofile,.camerabox{min-height:230px} .camerabox [data-testid="image"],.camerabox .image-container{border-radius:14px;overflow:hidden} @keyframes micglow{0%,100%{box-shadow:0 10px 30px rgba(16,185,129,.12)}50%{box-shadow:0 12px 38px rgba(16,185,129,.26)}} .miccard{animation:micglow 3.4s ease-in-out infinite} .flow,.home{max-width:100%}.tabs{margin-top:6px} @media(max-width:540px){ .banner h1{font-size:1.2rem}.banner p{font-size:.82rem;max-width:100%}.banner .bwrap{max-width:100%} .tile .v{font-size:1.15rem}.tile{padding:14px 6px}.bd{grid-template-columns:1fr 60px 44px} .gradio-container{max-width:100%!important} } @media(max-width:640px){.steps{grid-template-columns:1fr}.vendors{grid-template-columns:1fr}} """ # Styles for the home extras added on top of the sizer: a stats strip, the "powered by" model wall, # the reviews wall, and the account panel. Appended so both demo.launch() and the FastAPI mount get them. CSS += """ .statstrip{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin:2px 0 6px} .statstrip .sp{background:#fff;border:1.5px solid #f0e6cf;border-radius:999px;padding:7px 15px;font-size:.85rem;font-weight:600;color:#6b5b3a;box-shadow:0 3px 10px rgba(180,150,60,.08)} .statstrip .sp b{color:#1b1b1b} .sectitle{display:flex;align-items:center;flex-wrap:wrap;gap:4px 9px;font-family:'Space Grotesk',system-ui,sans-serif;margin:20px 2px 9px} .sectitle .si{font-size:1.18rem;line-height:1;display:inline-flex;align-items:center} .sectitle .st{font-weight:700;font-size:1.05rem;color:#1b1b1b;line-height:1.2} .sectitle .ss{font-weight:500;font-size:.8rem;color:#8a8f99;line-height:1.2} .modelwall{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px} .mw{display:block;text-decoration:none;background:linear-gradient(135deg,#f7fcf9,#fff);border:1.5px solid #d9ecdf;border-radius:16px;padding:14px 16px;transition:transform .12s,box-shadow .12s} .mw:hover{transform:translateY(-2px);box-shadow:0 8px 22px rgba(16,140,90,.12)} .mw.bmb{background:linear-gradient(135deg,#f5f8ff,#fff);border-color:#cdd8ff} .mw.ours{background:linear-gradient(135deg,#fff8ec,#fff);border-color:#f3dcae} .mw .mr{font-size:.74rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#0a8a52} .mw.bmb .mr{color:#3b53cf}.mw.ours .mr{color:#b9760a} .mw .mn{font-family:'Space Grotesk',sans-serif;font-weight:700;font-size:1rem;color:#1b1b1b;margin-top:3px} .mw .mp{font-size:.74rem;color:#6b7280;margin-top:4px;font-weight:600} .reviews{margin-top:6px} .rvgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px} .rvcard{background:#fff;border:1.5px solid #eee7d6;border-radius:16px;padding:14px 16px;box-shadow:0 4px 14px rgba(40,40,20,.05)} .rvstars{color:#f4a300;letter-spacing:1px;font-size:.95rem} .rvtext{color:#33312b;font-size:.9rem;line-height:1.5;margin:7px 0} .rvwho{color:#5b6470;font-size:.82rem;font-weight:700;display:flex;gap:6px;align-items:baseline;flex-wrap:wrap} .rvlang{color:#aeb2bb;font-weight:500} .rvempty{background:linear-gradient(135deg,#fff8ec,#fff);border:1.5px dashed #f0dcb0;border-radius:14px;padding:18px;text-align:center;color:#9a7b3a;font-weight:600} .rvform{background:linear-gradient(135deg,#fbfdff,#fff)!important;border:1.5px solid #e6ecf5!important;border-radius:16px!important;padding:14px 16px 12px!important;margin-top:14px} .rvformhd{display:flex;align-items:center;flex-wrap:wrap;gap:4px 9px;margin-bottom:8px} .rvformhd .si{font-size:1.12rem;line-height:1} .rvformhd .st{font-family:'Space Grotesk',sans-serif;font-weight:700;font-size:1rem;color:#1b1b1b} .rvformhd .ss{font-weight:500;font-size:.8rem;color:#8a8f99} .rvstarsin label{color:#f4a300!important;font-size:1.02rem!important;letter-spacing:1px} .accwelcome{background:linear-gradient(135deg,#f1fbf6,#fff);border:1.5px solid #cdeadb;border-radius:14px;padding:12px 16px;font-size:.95rem;line-height:1.5;color:#1b5e3f;margin-bottom:8px} .histlist{display:flex;flex-direction:column;gap:8px} .histrow{display:flex;justify-content:space-between;align-items:center;gap:10px;background:#fff;border:1.4px solid #ececf3;border-radius:12px;padding:10px 14px;flex-wrap:wrap} .histrow .hl{font-weight:600;color:#1b1b1b;font-size:.9rem} .histrow .hm{color:#0a8a52;font-weight:700;font-size:.86rem} .histrow .hd{color:#9aa0aa;font-size:.76rem} .histempty{color:#8a8f99;font-size:.88rem;padding:6px 2px} .accmsg-ok{color:#0a8a52;font-weight:600;font-size:.88rem;padding:4px 2px} .accmsg-err{color:#d4493a;font-weight:600;font-size:.88rem;padding:4px 2px} """ THEME = gr.themes.Soft( primary_hue=gr.themes.colors.emerald, secondary_hue=gr.themes.colors.amber, neutral_hue=gr.themes.colors.stone, radius_size=gr.themes.sizes.radius_lg, font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"]).set( block_radius="20px", block_shadow="0 8px 24px rgba(120,80,20,.07)", button_large_radius="14px", button_small_radius="12px") def show_thinking(): return "🔎 Listening and sizing your system..." def show_photo_thinking(): return "🖼️ Reading your photos with the vision model..." def set_summary(r): if not r: return "", "", count_html() track_event("sizing", {"panels": r["panel"]["count"], "kva": r["inverter"]["kva"], "kwh": r["daily_kwh"]}) d = r["batteries"]["durable"] summary = ('
☀️ %d panels·🔌 %s kVA·' '🔋 %d battery·💰 ₦%s
') % ( r["panel"]["count"], r["inverter"]["kva"], d["count"], format(d["total"], ",")) return summary, home_2d_svg(r), count_html() _QA_CACHE = {} def ask(question, r, lang, sess): """Answer a follow-up question about the sized system (cached so repeats are instant).""" sess = get_session(sess) q = (question or "").strip() if not q: return "Ask about your plan, like 'why these panels?' or 'what would an AC add?'", sess if not r: return "Size your appliances first, then I can answer questions about your plan.", sess key = hashlib.md5(("%s|%s|%s" % (q, _house_data(r, None), lang)).encode("utf-8")).hexdigest() if key in _QA_CACHE: sess.event("qa", q=q[:60], cached=True) return _QA_CACHE[key], sess d = r["batteries"]["durable"] ctx = ("Daily use %s kWh. Plan: %d %s (about %s W array), a %s kVA inverter, %d %s with %s kWh backup, " "total about %s naira, at %s peak-sun hours." % ( r["daily_kwh"], r["panel"]["count"], r["panel"]["name"], r["array_w"], r["inverter"]["kva"], d["count"], d["name"], d["backup_kwh"], format(d["total"], ","), r["sun_hours"])) if config.is_mock(): ans = "Connect a model (env/solar.sh) and I will answer using your plan above." else: # Qwen3-1.7B writes fluent English and readable Pidgin, but garbles free-form Yoruba/Hausa/Igbo. # Answer those questions in clear English so the advice is always usable; the spoken plan and the # whole interface stay in the chosen language, so the experience is still theirs. ans_lang = lang if lang in ("en", "pcm") else "en" prompt = ( "You are a warm, practical Nigerian solar advisor talking to a homeowner about their solar plan.\n" "Their plan: %s\n" "Answer their question in 2 to 4 short, friendly sentences in %s. Be concrete and encouraging, and " "use the plan's numbers when they help. You can help with anything about their plan, the panels, " "batteries, the inverter, sunlight and weather, cost and savings, running or adding specific " "appliances later, comparing solar with a generator, maintenance, or general home solar and " "electricity advice for Nigeria. Only if the question is clearly nothing to do with energy, power, " "money or the home, reply in one short friendly line and invite them to ask about their solar setup. " "Question: %s /no_think" % (ctx, LANG_NAME.get(ans_lang, "English"), q)) # The small model very occasionally returns an empty turn (only think-tags, or a truncated # generation). Retry once, and never hand back a blank, fall back to a useful plan summary. ans = "" for _ in range(2): try: ans = _clean(llm.complete(prompt, max_tokens=230, timeout=190, model="Qwen/Qwen3-1.7B")) # Qwen answers follow-up questions except Exception: ans = "" if len(ans) >= 8: break if len(ans) < 8: ans = ("Here is the short version: your plan is %d %s (about %s W of panels), a %s kVA inverter and " "%d %s for backup, around %s naira in total. Ask me anything about adding appliances, how long " "it runs, your savings, or where to buy." % ( r["panel"]["count"], r["panel"]["name"], r["array_w"], r["inverter"]["kva"], d["count"], d["name"], format(d["total"], ","))) sess.event("qa", q=q[:60], cached=False, empty=True) return ans, sess _QA_CACHE[key] = ans sess.event("qa", q=q[:60], cached=False) return ans, sess APPLIANCE_ABBR = { "Fridge (small)": "fridge", "Chest freezer": "freezer", "Ceiling fan": "cfan", "Standing fan": "fan", 'TV (32–43" LED)': "tv", "TV (large)": "tv", "Decoder (DStv/GOtv)": "decoder", "Sound system": "sound", "LED bulb": "bulb", "Energy-saver bulb": "bulb", "Security light": "bulb", "Laptop": "laptop", "Desktop PC": "desktop", "Air conditioner (1HP)": "ac", "Air conditioner (1.5HP)": "ac", "Air conditioner (2HP)": "ac", "Microwave": "micro", "Washing machine": "wash", "Water pump (1HP)": "pump", "Water pump (0.5HP)": "pump", "Electric iron": "iron", "Electric kettle": "kettle", "Water heater": "heater", "Phone charger": "phone", "Wifi router": "wifi", } def _house_data(r, sel=None): appl = ",".join("%s:%d" % (APPLIANCE_ABBR.get(k, "box"), v) for k, v in (sel or {}).items()) return "%d|%d|%s" % (r["panel"]["count"], r["batteries"]["durable"]["count"], appl) # Three.js loaded in the page (Gradio doesn't sanitize head), drawing a real 3D # flat-roof home into a persistent canvas via renderHouse(panels, batteries). THREE_HEAD = """ """ RENDER_JS = "(s) => { if(s){ var a=(''+s).split('|'); if(window.renderHouse){ window.renderHouse(parseInt(a[0])||0, parseInt(a[1])||0, a[2]||''); } } }" # ── home extras: stats strip, model wall, reviews wall, and accounts ────────── _VALID_LANGS = ("en", "pcm", "yo", "ha", "ig") def _esc(s): return str(s or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) def stats_html(): return ('
🌍 5 languages' '🧠 4 models, all under 4B' '🔋 free & open · scales to zero
') def models_html(): cards = [ ("", "Understands you & answers", "Qwen3-1.7B", "1.7B", ""), ("", "Hears your speech", "Whisper small", "0.24B", ""), ("ours", "Speaks your language", "SoroTTS ↗", "3B · our fine-tune", "https://huggingface.co/Shinzmann/sorotts"), ("bmb", "Reads your photos", "MiniCPM-V-2 ↗", "3.4B · OpenBMB", "https://huggingface.co/openbmb/MiniCPM-V-2"), ] out = [] for cls, role, name, params, href in cards: tag = ("mw " + cls).strip() inner = ('
%s
%s
%s
' % (_esc(role), _esc(name), _esc(params))) out.append('%s' % (tag, href, inner) if href else '
%s
' % (tag, inner)) return '
%s
' % "".join(out) def _stars(n): n = max(1, min(5, int(n or 5))) return "★" * n + "☆" * (5 - n) def reviews_html(): try: real = store.STORE.list_testimonials(60) except Exception: real = [] items, seen = [], set() for t in real: txt = (t.get("text") or "").strip() if not txt: continue k = ((t.get("name") or "").strip().lower(), txt.lower()) if k in seen: continue seen.add(k) items.append(t) if not items: return ('
No reviews yet. ' 'Be the first to share your experience, just below.
') cards = [] for t in items[:24]: lang = t.get("lang", "en") tag = ('in %s' % LANG_NAME.get(lang, "")) if lang and lang != "en" else "" cards.append('
%s
' '
%s
' '
%s%s
' % (_stars(t.get("rating", 5)), _esc(t.get("text", "")), _esc(t.get("name", "A user")), tag)) return '
%s
' % "".join(cards) def reviews_title(): try: n = len([t for t in store.STORE.list_testimonials(60) if (t.get("text") or "").strip()]) except Exception: n = 0 sub = ("%d review%s from people who used it" % (n, "" if n == 1 else "s")) if n else "be the first to leave one below" return ('
💬What people are saying' '%s
' % sub) # ── accounts ────────────────────────────────────────────────────────────────── def _rec_history(sel, r, state, lang): return {"appliances": sel, "panels": r["panel"]["count"], "kva": r["inverter"]["kva"], "kwh": r["daily_kwh"], "cost": r["batteries"]["durable"]["total"], "state": state, "lang": lang, "label": ", ".join("%d %s" % (v, k.split(" (")[0]) for k, v in list(sel.items())[:4])} def acc_greeting(user): if not user: return "" n = user.get("count", 0) return ('
👋 Welcome, %s · %d saved sizing%s. ' 'New sizings are saved here automatically.
' % (_esc(user.get("name", "")), n, "" if n == 1 else "s")) def acc_history_html(user): if not user: return "" try: recs = store.STORE.list_sizings(user["email"]) except Exception: recs = [] if not recs: return '
No saved sizings yet. Size a system and it lands here.
' rows = [] for rec in recs[:20]: when = time.strftime("%d %b %Y", time.localtime(rec.get("ts", 0))) if rec.get("ts") else "" rows.append('
%s' '%d panels · %s kVA · ₦%s%s
' % (_esc(rec.get("label", "sizing")), int(rec.get("panels", 0)), _esc(rec.get("kva", "")), format(int(rec.get("cost", 0)), ","), when)) return '
%s
' % "".join(rows) def _accmsg(text, ok): return '
%s
' % ("ok" if ok else "err", _esc(text)) def do_signup(email, pw, name): user, err = store.STORE.create_user(email, pw, name) if err: return None, _accmsg(err, False), SHOW, HIDE, "", "" return user, _accmsg("Account created, welcome to Naija Solar!", True), HIDE, SHOW, acc_greeting(user), acc_history_html(user) def do_login(email, pw): user, err = store.STORE.login(email, pw) if err: return None, _accmsg(err, False), SHOW, HIDE, "", "" return user, _accmsg("Signed in.", True), HIDE, SHOW, acc_greeting(user), acc_history_html(user) def do_logout(): return None, "", SHOW, HIDE, "", "" def save_after_size(r, df, user, state, lang): """When a signed-in user sizes a system, save it to their history and refresh the account panel.""" if user and r: try: sel = _df_to_sel(df) if sel: store.STORE.add_sizing(user["email"], _rec_history(sel, r, state, lang)) user = store.STORE.get(user["email"]) or user # refresh the saved count except Exception: pass return (acc_greeting(user) if user else ""), (acc_history_html(user) if user else ""), user def post_review(name, stars, text, user, lang): """A visitor posts a public review (stars + words). Refreshes the wall and clears the box.""" text = (text or "").strip() if len(text) < 4: return _accmsg("Please write a few words about your experience.", False), gr.update(), gr.update(), gr.update() who = (name or "").strip() or (user.get("name") if user else "") or "A Naija Solar user" try: store.STORE.add_testimonial({"name": who[:40], "text": text[:240], "rating": int(stars or 5), "lang": lang if lang in _VALID_LANGS else "en"}) except Exception: pass return _accmsg("Thank you, your review is now live below.", True), reviews_title(), reviews_html(), "" def build(): with gr.Blocks(title="Naija Solar", analytics_enabled=False) as demo: # css/theme/head are applied at launch()/mount in Gradio 6 sess, result, lang = gr.State(None), gr.State(None), gr.State("en") auth_state = gr.State(None) gr.HTML(logo_html()) usercount = gr.HTML(count_html(), elem_id="ucount") with gr.Accordion("👤 Account, sign in to save your sizings", open=False): acc_msg = gr.HTML() with gr.Column(visible=True) as acc_out: with gr.Row(): acc_email = gr.Textbox(placeholder="you@email.com", show_label=False, scale=2, elem_classes="typein") acc_pass = gr.Textbox(placeholder="password (6+ chars)", type="password", show_label=False, scale=2, elem_classes="typein") acc_name = gr.Textbox(placeholder="your name (only needed for a new account)", show_label=False, elem_classes="typein") with gr.Row(): login_btn = gr.Button("Sign in", elem_classes="gobtn", scale=1) signup_btn = gr.Button("Create account", variant="primary", elem_classes="gobtn", scale=1) with gr.Column(visible=False) as acc_in: acc_greet = gr.HTML() gr.HTML('
🗂️My saved sizings
') acc_hist = gr.HTML() with gr.Row(): logout_btn = gr.Button("Log out", scale=1) refresh_btn = gr.Button("Refresh", size="sm", scale=1) summary_bar = gr.HTML(elem_id="sumbar") gr.HTML('
🌍 CHOOSE YOUR LANGUAGE
') lang_sel = gr.Radio([("English", "en"), ("Naijá Pidgin", "pcm"), ("Yorùbá", "yo"), ("Hausa", "ha"), ("Ìgbò", "ig")], value="en", show_label=False, elem_classes="langrow") hero = gr.HTML(hero_html("en")) gr.HTML(stats_html()) with gr.Accordion("New here? How Naija Solar works", open=False) as guide_acc: guide = gr.HTML(steps_html()) with gr.Row(elem_classes="locrow"): state = gr.Dropdown(locations.STATES, value="Lagos", container=False, scale=2, show_label=False) geo_btn = gr.Button("Use my location", size="sm", scale=1) geolat = gr.Textbox(visible=False) loc_note = gr.Markdown(elem_classes="locnote") EXAMPLES = [ ("🏠 Small home", "1 fridge, 2 standing fans, 6 bulbs, 1 TV"), ("🏪 Corner shop", "1 chest freezer, 1 air conditioner, 4 bulbs, 1 TV, 1 decoder"), ("👨‍👩‍👧 Family house", "1 fridge, 1 air conditioner, 3 fans, 8 bulbs, 1 TV, 1 washing machine"), ] with gr.Group(elem_classes="miccard"): mode_v = gr.HTML('
🎤 Speak your appliances tap Record, speak, then Stop, it sizes itself
') voice = gr.Audio(sources=["microphone"], type="filepath", show_label=False, elem_classes="voicein") mode_t = gr.HTML('
⌨️ Or type them
') with gr.Row(): text = gr.Textbox(placeholder=UI["en"]["type"], show_label=False, container=False, lines=1, scale=4, elem_classes="typein") text_btn = gr.Button("Size it", variant="primary", scale=1, min_width=96, elem_classes="gobtn") gr.HTML('
✨ New here? Tap an example a full result in one click, no mic or typing
') with gr.Row(elem_classes="exrow"): ex_btns = [gr.Button(lbl, elem_classes="exbtn", size="sm") for lbl, _ in EXAMPLES] mode_p = gr.HTML('
📷 Or snap / upload photos
') with gr.Row(equal_height=True): photos = gr.File(file_count="multiple", file_types=["image"], scale=1, label="Upload up to 5 photos", elem_classes="photofile") camera = gr.Image(sources=["webcam"], type="filepath", scale=1, label="Open camera", height=216, elem_classes="camerabox") photo_btn = gr.Button("Detect appliances from photo", variant="primary", elem_classes="gobtn") status = gr.Markdown("", elem_classes="status") with gr.Tabs(): with gr.Tab("🏠 3D Home"): house = gr.HTML('
🏠 Your home in 3D ' 'drag to look around · scroll to zoom
' '
') hdata = gr.Textbox(visible=False) with gr.Tab("🖼️ 2D View"): home2d = gr.HTML('
Your home and system will appear here in 2D
') with gr.Tab("⚡ System"): flow = gr.HTML('
Your energy system will animate here
') breakdown = gr.HTML() with gr.Column(visible=False) as panel: gr.HTML('
🔊 Your plan, spoken aloud (tap play to hear it again)
') walk_audio = gr.Audio(autoplay=True, show_label=False, container=False) content = gr.HTML() fine = gr.HTML(f'
{UI["en"]["fine"]}
') with gr.Accordion("Adjust appliances (add or edit manually)", open=False): gr.HTML('
Pick an appliance and quantity, then tap Add. Add as many as you like, ' 'then tap Update sizing.
') with gr.Row(): picker = gr.Dropdown(list(data.APPLIANCES), multiselect=False, value=None, filterable=True, label="Appliance", scale=3, elem_classes="addbar") qty_in = gr.Number(value=1, minimum=1, maximum=99, step=1, label="Qty", scale=1) add_btn = gr.Button("➕ Add", variant="primary", elem_classes="gobtn", scale=1) applist = gr.HTML(elem_classes="applist") appl_state = gr.State([]) with gr.Row(): recalc_btn = gr.Button("⚡ Update sizing", variant="primary", elem_classes="gobtn", scale=3) clear_btn = gr.Button("Clear list", scale=1) with gr.Accordion("💬 Ask about your system", open=False): qa_in = gr.Textbox(placeholder="e.g. Why these panels? What would an AC add? How long does the battery last?", show_label=False, container=False, lines=1, elem_classes="typein") qa_btn = gr.Button("Ask", variant="primary", elem_classes="gobtn") qa_out = gr.Markdown() with gr.Accordion("Where to buy", open=False): gr.HTML(vendors_html()) with gr.Accordion("Activity", open=False): sess_btn = gr.Button("Refresh", size="sm") sess_html = gr.HTML() with gr.Accordion("💛 Was this helpful? Get solar updates", open=False, elem_classes="fbacc"): fb_comment = gr.Textbox(placeholder="Any comment to help us improve? (optional)", show_label=False, lines=2, elem_classes="typein") with gr.Row(): fb_up = gr.Button("👍 Helpful", elem_classes="fbbtn") fb_down = gr.Button("👎 Could be better", elem_classes="fbbtn") fb_msg = gr.HTML() gr.HTML('
📩 Want solar tips, or your result saved? Add your email, optional, no spam.
') with gr.Row(): email_in = gr.Textbox(placeholder="you@email.com", show_label=False, scale=3, elem_classes="typein") email_btn = gr.Button("Notify me", elem_classes="gobtn", scale=1) email_msg = gr.HTML() gr.HTML('
⚙️Powered by four small open models' 'every one under 4B, self-hosted on Modal, scaled to zero
') gr.HTML(models_html()) reviews_head = gr.HTML(reviews_title()) reviews_block = gr.HTML(reviews_html()) with gr.Group(elem_classes="rvform"): gr.HTML('
Leave a review' 'tell others how it went
') with gr.Row(): rv_name = gr.Textbox(placeholder="Your name (optional)", show_label=False, container=False, scale=2, elem_classes="typein") rv_stars = gr.Radio([("★★★★★", 5), ("★★★★", 4), ("★★★", 3), ("★★", 2), ("★", 1)], value=5, show_label=False, container=False, scale=3, elem_classes="rvstarsin") rv_text = gr.Textbox(placeholder="How was your experience with Naija Solar?", show_label=False, lines=2, container=False, elem_classes="typein") rv_post = gr.Button("Post review", variant="primary", elem_classes="gobtn") rv_msg = gr.HTML() gr.HTML('
☀️ Naija Solar  ·  ' 'the original hand-built UI ↗  ·  ' 'GitHub ↗  ·  ' 'SoroTTS voice ↗
') ins = [voice, text, state, geolat, lang, sess] outs = [hdata, flow, breakdown, content, applist, status, sess, result, lang, panel, appl_state] spk = (speak, [result, lang, sess], [walk_audio, sess]) save = (save_after_size, [result, appl_state, auth_state, state, lang], [acc_greet, acc_hist, auth_state]) ls_outs = [lang, hero, text, fine, mode_v, mode_t, mode_p, text_btn, photo_btn, guide, guide_acc] voice.stop_recording(show_thinking, None, status).then(run, ins, outs).then(set_summary, result, [summary_bar, home2d, usercount]).then(*save).then(*spk) text.submit(show_thinking, None, status).then(run, ins, outs).then(set_summary, result, [summary_bar, home2d, usercount]).then(*save).then(*spk) text_btn.click(show_thinking, None, status).then(run, ins, outs).then(set_summary, result, [summary_bar, home2d, usercount]).then(*save).then(*spk) for _btn, (_lbl, _val) in zip(ex_btns, EXAMPLES): # one-tap examples: fill, clear mic, then size _btn.click(lambda v=_val: (v, None), None, [text, voice]).then( show_thinking, None, status).then(run, ins, outs).then( set_summary, result, [summary_bar, home2d, usercount]).then(*save).then(*spk) lang_sel.change(set_lang, lang_sel, ls_outs) hdata.change(None, hdata, None, js=RENDER_JS) geo_btn.click(None, None, geolat, js=GEO_JS) geolat.change(geo_note, geolat, loc_note) add_btn.click(add_appliance, [picker, qty_in, appl_state], [appl_state, applist]) clear_btn.click(clear_appliances, None, [appl_state, applist]) photo_btn.click(show_photo_thinking, None, status).then( from_photos, [photos, camera, state, geolat, lang, sess], outs).then(set_summary, result, [summary_bar, home2d, usercount]).then(*save).then(*spk) recalc_btn.click(show_thinking, None, status).then( recalc, [appl_state, state, geolat, sess], [hdata, flow, breakdown, content, status, sess, result, panel]).then(set_summary, result, [summary_bar, home2d, usercount]).then(*save).then(*spk) sess_btn.click(lambda: sessions.render_html(12), None, sess_html) qa_btn.click(ask, [qa_in, result, lang, sess], [qa_out, sess]) qa_in.submit(ask, [qa_in, result, lang, sess], [qa_out, sess]) # accounts: sign in / create / out (shared with the /classic SPA via store.STORE) _acc_outs = [auth_state, acc_msg, acc_out, acc_in, acc_greet, acc_hist] login_btn.click(do_login, [acc_email, acc_pass], _acc_outs) signup_btn.click(do_signup, [acc_email, acc_pass, acc_name], _acc_outs) logout_btn.click(do_logout, None, _acc_outs) refresh_btn.click(lambda u: (acc_greeting(u), acc_history_html(u)), auth_state, [acc_greet, acc_hist]) # public reviews: anyone can post one; it appears on the wall immediately rv_post.click(post_review, [rv_name, rv_stars, rv_text, auth_state, lang], [rv_msg, reviews_head, reviews_block, rv_text]) # private feedback (thumbs) stays internal, separate from public reviews fb_up.click(lambda c: submit_feedback("up", c), fb_comment, fb_msg) fb_down.click(lambda c: submit_feedback("down", c), fb_comment, fb_msg) email_btn.click(submit_email, email_in, email_msg) demo.load(count_html, None, usercount) demo.load(reviews_title, None, reviews_head) # so reviews posted since startup show on every fresh load demo.load(reviews_html, None, reviews_block) return demo _init_tracking() # One-time reset of the public reviews wall, controlled by the BUILDSMALL_RESET_REVIEWS Space variable. # Runs before build() reads the wall. The applied value is stored next to the testimonials, so it # clears exactly once per new value: a later restart will not wipe reviews collected since. _RV_NONCE = os.environ.get("BUILDSMALL_RESET_REVIEWS", "") if _RV_NONCE: try: if store.STORE._get(store.STORE.testi, "_reset_nonce", "") != _RV_NONCE: store.STORE.reset_testimonials() store.STORE._put(store.STORE.testi, "_reset_nonce", _RV_NONCE) except Exception: pass # The custom FastAPI frontend (server.py) imports this module for its logic/generators only, # so it sets BUILDSMALL_NO_GRADIO=1 to skip building the (unused) Gradio Blocks. demo = None if os.environ.get("BUILDSMALL_NO_GRADIO") == "1" else build() if __name__ == "__main__": on_space = bool(os.environ.get("SPACE_ID")) demo.launch(css=CSS, theme=THEME, head=THREE_HEAD, server_name="0.0.0.0" if on_space else "127.0.0.1", server_port=int(os.environ.get("PORT", "7860")))