Spaces:
Runtime error
Runtime error
| """Naija Solar — bespoke custom frontend served over FastAPI (the off-brand surface). | |
| This is a fully hand-built web app (web/index.html + web/app.js + web/shell.css) served by FastAPI. | |
| All the heavy lifting is reused from app.py: the deterministic sizing engine, every SVG/HTML | |
| generator, the Three.js scene, and the model clients. app.py is imported with BUILDSMALL_NO_GRADIO=1 | |
| so its (now unused) Gradio Blocks is never built. The Gradio app remains a one-line fallback. | |
| uvicorn server:app --host 0.0.0.0 --port 7860 # local + Docker entrypoint | |
| """ | |
| import os | |
| os.environ.setdefault("BUILDSMALL_NO_GRADIO", "1") | |
| import base64 | |
| import hashlib | |
| import hmac | |
| import pathlib | |
| import secrets as _secrets | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| HERE = pathlib.Path(__file__).resolve().parent | |
| sys.path.insert(0, str(HERE)) | |
| if not (HERE / "buildsmall").exists(): | |
| sys.path.insert(0, str(HERE.parents[1] / "_platform")) | |
| import app as core # noqa: E402 — reuse all logic/generators (Gradio build is skipped) | |
| import store # noqa: E402 — durable accounts + sizing history (Modal Dict) | |
| import strings # noqa: E402 — all UI text in 5 languages + seed testimonials | |
| from fastapi import FastAPI, File, Form, Request, UploadFile # noqa: E402 | |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response # noqa: E402 | |
| from fastapi.staticfiles import StaticFiles # noqa: E402 | |
| app = FastAPI(title="Naija Solar") | |
| WEB = HERE / "web" | |
| # ── sessions (HMAC-signed cookies; never stores a password, only a signed email) ────────────── | |
| # Keys live in the data dir (persisted on /data, never in a public repo or a shared-org secret), | |
| # so they survive restarts when persistent storage is on, and are private to this app. | |
| DATA_DIR = os.environ.get("BUILDSMALL_DATA_DIR", "user_data") | |
| def _persisted(name, gen): | |
| try: | |
| os.makedirs(DATA_DIR, exist_ok=True) | |
| p = os.path.join(DATA_DIR, name) | |
| if os.path.exists(p): | |
| return open(p).read().strip() | |
| v = gen() | |
| open(p, "w").write(v) | |
| return v | |
| except Exception: | |
| return gen() | |
| SECRET = (os.environ.get("SECRET_KEY") or _persisted(".session_key", lambda: _secrets.token_hex(32))).encode("utf-8") | |
| ADMIN_KEY = os.environ.get("ADMIN_KEY") or _persisted(".admin_key", lambda: "naija-" + _secrets.token_hex(8)) | |
| ON_SPACE = bool(os.environ.get("SPACE_ID")) | |
| COOKIE, TTL = "ns_session", 30 * 24 * 3600 | |
| print("[naija] storage data dir: %s | admin key (for /api/admin/users?key=...): %s" % (DATA_DIR, ADMIN_KEY)) | |
| # One-time wipe of testimonials accumulated during development and testing, so the home-page | |
| # carousel starts clean (only the curated seeds, then genuine user ratings). Runs once; the | |
| # marker in the persistent data dir keeps it from repeating. | |
| try: | |
| _mark = pathlib.Path(DATA_DIR) / ".testi_reset_v1" | |
| if not _mark.exists(): | |
| store.STORE.reset_testimonials() | |
| _mark.write_text("done") | |
| print("[naija] cleared development testimonials (one-time)") | |
| except Exception as _e: | |
| print("[naija] testimonial reset skipped:", _e) | |
| def _b64(b): | |
| return base64.urlsafe_b64encode(b).decode().rstrip("=") | |
| def _ub64(s): | |
| return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) | |
| def make_token(email): | |
| payload = ("%s|%d" % (email, int(time.time()) + TTL)).encode("utf-8") | |
| return _b64(payload) + "." + _b64(hmac.new(SECRET, payload, hashlib.sha256).digest()) | |
| def read_token(tok): | |
| try: | |
| p, s = (tok or "").split(".") | |
| payload = _ub64(p) | |
| if not hmac.compare_digest(_ub64(s), hmac.new(SECRET, payload, hashlib.sha256).digest()): | |
| return None | |
| email, exp = payload.decode().rsplit("|", 1) | |
| return email if int(exp) > time.time() else None | |
| except Exception: | |
| return None | |
| def current_user(request): | |
| email = read_token(request.cookies.get(COOKIE)) | |
| return store.STORE.get(email) if email else None | |
| def _set_cookie(resp, email): | |
| resp.set_cookie(COOKIE, make_token(email), max_age=TTL, httponly=True, samesite="lax", secure=ON_SPACE, path="/") | |
| def _rec(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 _save_if_user(request, sel, r, state, lang, bundle): | |
| u = current_user(request) | |
| if u: | |
| store.STORE.add_sizing(u["email"], _rec(sel, r, state, lang)) | |
| bundle["saved"] = True | |
| bundle["userCount"] = store.STORE.get(u["email"])["count"] # distinct from the global count pill | |
| # ── helpers ─────────────────────────────────────────────────────────────────── | |
| def _sess(): | |
| return core.get_session(None) | |
| def _size(appliances, state="Lagos", geolat=""): | |
| sel = {str(k): int(v) for k, v in (appliances or {}).items() if int(v) > 0} | |
| r = core.engine.size(sel, core._psh(state, geolat)) | |
| return sel, r | |
| def _bundle(sel, r, lang="en"): | |
| """Everything the frontend needs to paint a full result, reusing the existing generators.""" | |
| return { | |
| "ok": True, | |
| "sel": sel, | |
| "house": core._house_data(r, sel), | |
| "narration": core.narrate(r, lang), # the written explanation, shown instantly | |
| "summary": core.set_summary(r)[0], # also records the 'sizing' event + count | |
| "tiles": core.stat_tiles(r), | |
| "cards": core.rec_cards(r), | |
| "chips": core.chips_html(sel), | |
| "system": core.system_view(r), | |
| "twod": core.home_2d_svg(r), | |
| "breakdown": core.load_breakdown_html(sel), | |
| "vendors": core.vendors_html(), | |
| "count": core.count_html(), | |
| "panels": r["panel"]["count"], | |
| "kva": r["inverter"]["kva"], | |
| "kwh": r["daily_kwh"], | |
| "cost": r["batteries"]["durable"]["total"], | |
| } | |
| # ── API ─────────────────────────────────────────────────────────────────────── | |
| _LANGS = ["en", "pcm", "yo", "ha", "ig"] | |
| def config(): | |
| return { | |
| "ui": core.UI, | |
| "langName": core.LANG_NAME, | |
| "states": list(core.locations.STATES), | |
| "guideTitle": core.GUIDE_TITLE, | |
| "guard": core.GUARD, | |
| "count": core.count_html(), | |
| "appliances": list(core.data.APPLIANCES), | |
| "logo": core.logo_html(), | |
| "hero": {L: core.hero_html(L) for L in _LANGS}, | |
| "steps": {L: core.steps_html(L) for L in _LANGS}, | |
| "strings": strings.STRINGS, | |
| } | |
| async def api_size(payload: dict, request: Request): | |
| text = (payload or {}).get("text", "") | |
| lang = (payload or {}).get("lang", "en") | |
| sel = core.extract(text) | |
| if not sel: | |
| msg = core.GUARD.get(lang, core.GUARD["en"]) if (text or "").strip() else "" | |
| return {"ok": False, "msg": msg} | |
| state = payload.get("state", "Lagos") | |
| _, r = _size(sel, state, payload.get("geolat", "")) | |
| b = _bundle(sel, r, lang) | |
| _save_if_user(request, sel, r, state, lang, b) | |
| return b | |
| async def api_recalc(payload: dict, request: Request): | |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} | |
| if not sel: | |
| return {"ok": False, "msg": "Add an appliance first."} | |
| state = payload.get("state", "Lagos") | |
| lang = payload.get("lang", "en") | |
| _, r = _size(sel, state, payload.get("geolat", "")) | |
| b = _bundle(sel, r, lang) | |
| _save_if_user(request, sel, r, state, lang, b) | |
| return b | |
| async def api_asr(file: UploadFile = File(...)): | |
| data = await file.read() | |
| p = tempfile.mktemp(suffix=".wav") | |
| with open(p, "wb") as f: | |
| f.write(data) | |
| try: | |
| text = core.asr.transcribe(p) | |
| except Exception: | |
| text = "" | |
| return {"text": text or ""} | |
| async def api_vision(request: Request, files: list[UploadFile] = File(...), state: str = Form("Lagos"), | |
| lang: str = Form("en"), geolat: str = Form("")): | |
| seen = {} | |
| for f in (files or [])[:5]: | |
| data = await f.read() | |
| p = tempfile.mktemp(suffix=".png") | |
| with open(p, "wb") as fp: | |
| fp.write(data) | |
| try: | |
| desc = core.vision.describe(p, "List every household electrical appliance you can see, with " | |
| "counts. Use simple names like fridge, fan, bulb, TV, AC, freezer, laptop.") | |
| for k, v in core.extract(desc).items(): | |
| seen[k] = seen.get(k, 0) + v | |
| except Exception: | |
| pass | |
| if not seen: | |
| return {"ok": False, "msg": "Could not spot appliances. Try a clearer photo or type them."} | |
| _, r = _size(seen, state, geolat) | |
| b = _bundle(seen, r, lang) | |
| _save_if_user(request, seen, r, state, lang, b) | |
| return b | |
| _WARM_TS = [0.0] # last time we fired a warm-up | |
| _WARM_FILES = {} # tiny throwaway inputs for ASR/vision, made once | |
| def _warm_assets(): | |
| if not _WARM_FILES: | |
| import wave | |
| wp = tempfile.mktemp(suffix=".wav") | |
| try: | |
| with wave.open(wp, "w") as w: | |
| w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000) | |
| w.writeframes(b"\x00\x00" * 3200) # 0.2s of silence | |
| except Exception: | |
| wp = None | |
| ip = tempfile.mktemp(suffix=".png") | |
| try: | |
| from PIL import Image | |
| Image.new("RGB", (32, 32), (210, 210, 210)).save(ip) | |
| except Exception: | |
| ip = None | |
| _WARM_FILES["wav"], _WARM_FILES["img"] = wp, ip | |
| return _WARM_FILES | |
| async def api_warm(): | |
| """Wake EVERY model (text, speech, voice, vision) in the background when a visitor arrives, so | |
| the first real request is fast. The models scale to zero when idle, so this is throttled and | |
| fires again whenever someone returns after a lull. Returns warming=true when it actually fired.""" | |
| now = time.time() | |
| if now - _WARM_TS[0] < 60: | |
| return {"ok": True, "warming": False} | |
| _WARM_TS[0] = now | |
| a = _warm_assets() | |
| jobs = [lambda: core.llm.complete("Hi"), | |
| lambda: core.tts.speak("Ready.", lang="en", out_path=tempfile.mktemp(suffix=".wav"))] | |
| if a.get("wav"): | |
| jobs.append(lambda: core.asr.transcribe(a["wav"])) | |
| if a.get("img"): | |
| jobs.append(lambda: core.vision.describe(a["img"], "List appliances.")) | |
| def _run(fn): | |
| try: | |
| fn() | |
| except Exception: | |
| pass | |
| for fn in jobs: | |
| threading.Thread(target=_run, args=(fn,), daemon=True).start() | |
| return {"ok": True, "warming": True} | |
| async def api_narration(payload: dict): | |
| """The written explanation, the exact words the voice will read. Instant (no model).""" | |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} | |
| if not sel: | |
| return {"text": ""} | |
| _, r = _size(sel, payload.get("state", "Lagos"), payload.get("geolat", "")) | |
| return {"text": core.narrate(r, payload.get("lang", "en"))} | |
| async def api_narrate(payload: dict): | |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} | |
| if not sel: | |
| return JSONResponse({"ok": False}, status_code=400) | |
| _, r = _size(sel, payload.get("state", "Lagos"), payload.get("geolat", "")) | |
| audio, _ = core.speak(r, payload.get("lang", "en"), _sess()) | |
| if audio and os.path.exists(audio): | |
| return FileResponse(audio, media_type="audio/wav") | |
| return JSONResponse({"ok": False}, status_code=503) | |
| async def api_ask(payload: dict): | |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} | |
| if not sel: | |
| return {"answer": "Size your appliances first, then I can answer questions about your plan."} | |
| _, r = _size(sel, payload.get("state", "Lagos"), payload.get("geolat", "")) | |
| ans, _ = core.ask(payload.get("question", ""), r, payload.get("lang", "en"), _sess()) | |
| return {"answer": ans} | |
| async def api_feedback(payload: dict, request: Request): | |
| rating = payload.get("rating", "up") # "up" / "down" (thumbs) | |
| stars = payload.get("stars") # 1..5 (rating prompt), optional | |
| comment = (payload.get("comment") or "").strip() | |
| u = current_user(request) | |
| name = ((u["name"] if u else (payload.get("name") or "")) or "").strip() or "A Naija Solar user" | |
| lang = payload.get("lang", "en") | |
| core.track_event("feedback", {"rating": rating, "stars": stars, "comment": comment[:300], "lang": lang}) | |
| positive = (rating == "up") or (isinstance(stars, (int, float)) and stars >= 4) | |
| if comment and positive and len(comment) >= 6: # surfaces on the home-page carousel | |
| store.STORE.add_testimonial({"name": name[:40], "text": comment[:240], "rating": int(stars or 5), | |
| "lang": lang if lang in _LANGS else "en"}) | |
| return {"ok": True, "html": core.submit_feedback(rating if rating in ("up", "down") else "up", comment)} | |
| def api_testimonials(): | |
| real = store.STORE.list_testimonials(60) | |
| 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: # drop duplicates | |
| continue | |
| seen.add(k) | |
| items.append({"name": t.get("name", "A user"), "text": txt, | |
| "rating": t.get("rating", 5), "lang": t.get("lang", "en")}) | |
| seeds = [{"name": s["name"], "text": s["text"], "rating": s["rating"], "lang": s.get("lang", "en")} | |
| for s in strings.SEED_TESTIMONIALS] | |
| return {"items": (items + seeds)[:24], "realCount": len(items)} | |
| async def api_email(payload: dict): | |
| return {"html": core.submit_email(payload.get("email", ""))} | |
| async def api_event(payload: dict): | |
| core.track_event(payload.get("type", "event"), payload.get("data")) | |
| return {"ok": True} | |
| def api_stats(): | |
| return core.get_stats() | |
| async def api_geo(payload: dict): | |
| return {"note": core.geo_note(payload.get("geolat", ""))} | |
| # ── accounts: sign up / in / out, profile, sizing history ──────────────────── | |
| async def auth_signup(payload: dict): | |
| u, err = store.STORE.create_user(payload.get("email"), payload.get("password"), payload.get("name")) | |
| if err: | |
| return JSONResponse({"ok": False, "error": err}, status_code=400) | |
| core.track_event("signup", {"email": u["email"]}) | |
| resp = JSONResponse({"ok": True, "user": u}) | |
| _set_cookie(resp, u["email"]) | |
| return resp | |
| async def auth_login(payload: dict): | |
| u, err = store.STORE.login(payload.get("email"), payload.get("password")) | |
| if err: | |
| return JSONResponse({"ok": False, "error": err}, status_code=400) | |
| resp = JSONResponse({"ok": True, "user": u}) | |
| _set_cookie(resp, u["email"]) | |
| return resp | |
| async def auth_logout(): | |
| resp = JSONResponse({"ok": True}) | |
| resp.delete_cookie(COOKIE, path="/") | |
| return resp | |
| def auth_me(request: Request): | |
| return {"user": current_user(request)} | |
| def my_sizings(request: Request): | |
| u = current_user(request) | |
| if not u: | |
| return {"ok": False, "sizings": []} | |
| return {"ok": True, "sizings": store.STORE.list_sizings(u["email"])} | |
| async def my_save(payload: dict, request: Request): | |
| u = current_user(request) | |
| if not u: | |
| return {"ok": False} | |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} | |
| if not sel: | |
| return {"ok": False} | |
| _, r = _size(sel, payload.get("state", "Lagos"), payload.get("geolat", "")) | |
| store.STORE.add_sizing(u["email"], _rec(sel, r, payload.get("state", "Lagos"), payload.get("lang", "en"))) | |
| return {"ok": True, "count": store.STORE.get(u["email"])["count"]} | |
| def admin_users(key: str = ""): | |
| if not key or key != ADMIN_KEY: | |
| return JSONResponse({"error": "unauthorized"}, status_code=401) | |
| return store.STORE.admin_overview() | |
| # ── the bespoke frontend ────────────────────────────────────────────────────── | |
| def app_css(): | |
| return Response(core.CSS, media_type="text/css") | |
| _ASSET_V = None | |
| def _asset_version(): | |
| """A short hash of the frontend assets, appended as ?v=… so browsers never serve a stale | |
| app.js/CSS after a deploy (which can make features look broken). Recomputed each restart.""" | |
| global _ASSET_V | |
| if _ASSET_V is None: | |
| h = hashlib.md5() | |
| for p in (WEB / "app.js", WEB / "shell.css"): | |
| try: | |
| h.update(p.read_bytes()) | |
| except Exception: | |
| pass | |
| h.update(core.CSS.encode("utf-8")) | |
| _ASSET_V = h.hexdigest()[:10] | |
| return _ASSET_V | |
| def index(): | |
| shell = (WEB / "index.html").read_text(encoding="utf-8") | |
| # inject the reused design-system CSS and the Three.js / count-up head block + cache-bust assets | |
| html = shell.replace("<!--HEAD-->", core.THREE_HEAD).replace("__V__", _asset_version()) | |
| return HTMLResponse(html) | |
| app.mount("/web", StaticFiles(directory=str(WEB)), name="web") | |