naija-solar / server.py
Shinzmann's picture
Warm all models on open + loading disclaimer (scale-to-zero UX)
4770cdb verified
Raw
History Blame Contribute Delete
18.2 kB
"""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"]
@app.get("/api/config")
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,
}
@app.post("/api/size")
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
@app.post("/api/recalc")
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
@app.post("/api/asr")
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 ""}
@app.post("/api/vision")
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
@app.post("/api/warm")
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}
@app.post("/api/narration")
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"))}
@app.post("/api/narrate")
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)
@app.post("/api/ask")
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}
@app.post("/api/feedback")
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)}
@app.get("/api/testimonials")
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)}
@app.post("/api/email")
async def api_email(payload: dict):
return {"html": core.submit_email(payload.get("email", ""))}
@app.post("/api/event")
async def api_event(payload: dict):
core.track_event(payload.get("type", "event"), payload.get("data"))
return {"ok": True}
@app.get("/api/stats")
def api_stats():
return core.get_stats()
@app.post("/api/geo")
async def api_geo(payload: dict):
return {"note": core.geo_note(payload.get("geolat", ""))}
# ── accounts: sign up / in / out, profile, sizing history ────────────────────
@app.post("/api/auth/signup")
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
@app.post("/api/auth/login")
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
@app.post("/api/auth/logout")
async def auth_logout():
resp = JSONResponse({"ok": True})
resp.delete_cookie(COOKIE, path="/")
return resp
@app.get("/api/auth/me")
def auth_me(request: Request):
return {"user": current_user(request)}
@app.get("/api/me/sizings")
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"])}
@app.post("/api/me/save")
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"]}
@app.get("/api/admin/users")
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 ──────────────────────────────────────────────────────
@app.get("/app.css")
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
@app.get("/", response_class=HTMLResponse)
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")