Spaces:
Running
Running
| """LifeOS — local-first personal assistant. | |
| Gradio 6 Server mode: Gradio's API engine (queuing + SSE streaming) with a | |
| fully hand-built frontend served from static/. All inference is local | |
| (Nemotron-3-Nano-4B + nomic-embed through llama.cpp); the browser makes | |
| zero external requests. | |
| """ | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import tempfile | |
| import threading | |
| from collections.abc import Iterator | |
| from fastapi import Request, UploadFile | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from gradio import Server | |
| import config | |
| import engine | |
| import memory as memory_store | |
| import rag | |
| from features import food, health, money, websearch | |
| logger = logging.getLogger(__name__) | |
| ROOT = os.path.dirname(os.path.abspath(__file__)) | |
| STATIC = os.path.join(ROOT, "static") | |
| app = Server(title="LifeOS") | |
| # ---------------------------------------------------------------- streaming | |
| def food_recommend(deals_json: str) -> Iterator[str]: | |
| deals = json.loads(deals_json) if deals_json else [] | |
| mem = memory_store.load() | |
| user_input = food.build_food_input(deals, mem) | |
| yield from engine.run_domain("food", user_input) | |
| def health_recommend() -> Iterator[str]: | |
| mem = memory_store.load() | |
| yield from engine.run_domain("health", health.build_health_input(mem)) | |
| def money_review(subs_json: str) -> Iterator[str]: | |
| subs = json.loads(subs_json) if subs_json else [] | |
| mem = memory_store.load() | |
| if subs: | |
| memory_store.set_subscriptions( | |
| [{"name": s["name"], "cost": s["cost"], "last_used": s.get("last_charged", "")} for s in subs] | |
| ) | |
| mem = memory_store.load() | |
| yield from engine.run_domain("money", money.build_money_input(subs, mem)) | |
| _REMEMBER_RE = re.compile(r"^\s*remember\b[,:]?\s*(.+)", re.IGNORECASE) | |
| def chat(message: str, history_json: str, use_web: str = "", refs_json: str = "") -> Iterator[str]: | |
| history = json.loads(history_json) if history_json else [] | |
| refs = json.loads(refs_json) if refs_json else [] | |
| domains = [{"kitchen": "food"}.get(r, r) for r in refs] or None | |
| mem = memory_store.load() | |
| # explicit long-term memory writes: "remember ..." | |
| m = _REMEMBER_RE.match(message) | |
| if m: | |
| rag.remember(f"{mem['user_profile']['name']}: {m.group(1).strip()}", kind="fact") | |
| extra = "" | |
| if use_web == "1": | |
| extra = websearch.format_results(websearch.search_web(message)) | |
| system, user = engine.build_prompt("chat", mem, message, domains=domains) | |
| messages = [system] + [h for h in history if h.get("role") in ("user", "assistant")][-8:] + [user] | |
| yield from engine.generate_stream(messages, domain="chat", extra_context=extra) | |
| def goal_chat(message: str, history_json: str, goal_json: str) -> Iterator[str]: | |
| """Socratic financial-goal coaching conversation.""" | |
| history = json.loads(history_json) if history_json else [] | |
| goal = json.loads(goal_json) if goal_json else {} | |
| mem = memory_store.load() | |
| user_input = message | |
| if goal: | |
| user_input = ( | |
| f"GOAL UNDER DISCUSSION: {goal.get('title', '?')} — target ${goal.get('target_amount', '?')}, " | |
| f"saved ${goal.get('saved', 0)}, deadline {goal.get('deadline', '?')}\n\n{message}" | |
| ) | |
| system, user = engine.build_prompt("goal", mem, user_input) | |
| messages = [system] + [h for h in history if h.get("role") in ("user", "assistant")][-8:] + [user] | |
| yield from engine.generate_stream(messages, domain="goal") | |
| def local_deals(city: str) -> Iterator[str]: | |
| """Search the web for local grocery deals and stream a food recommendation.""" | |
| mem = memory_store.load() | |
| city = city.strip() or mem["user_profile"].get("city", "Toronto") | |
| results = websearch.search_web(f"{city} grocery store weekly flyer deals") | |
| combined = "\n".join(f"{r['title']}\n{r['snippet']}" for r in results) | |
| deals = food.extract_deals_from_text(combined) | |
| if deals: | |
| user_input = ( | |
| f"These grocery deals were found online for {city} this week:\n" | |
| + food.build_food_input(deals, mem) | |
| ) | |
| yield from engine.run_domain("food", user_input) | |
| elif results: | |
| user_input = ( | |
| f"No structured prices were parsed, but here is what a web search found " | |
| f"for grocery deals in {city}. Summarize where {mem['user_profile']['name']} " | |
| f"should shop this week and why:\n\n" + websearch.format_results(results) | |
| ) | |
| yield from engine.run_domain("chat", user_input) | |
| else: | |
| yield ( | |
| f"I couldn't reach the web to find deals in {city} right now. " | |
| "Try again later, or paste a flyer in the Food tab." | |
| ) | |
| def meal_analyze(ocr_text: str, meal_label: str) -> Iterator[str]: | |
| """Analyze OCR text from a food/receipt photo; optionally log the meal.""" | |
| if meal_label.strip(): | |
| memory_store.log_meal(meal_label.strip(), []) | |
| user_input = "OCR TEXT FROM PHOTO:\n" + ocr_text.strip() | |
| if meal_label.strip(): | |
| user_input += f"\n\n(The user labeled this meal: {meal_label.strip()} — it has been logged.)" | |
| yield from engine.run_domain("meal_photo", user_input) | |
| # ------------------------------------------------------------ non-streaming | |
| def log_workout(workout_type: str, duration: float) -> str: | |
| mem = memory_store.log_workout(workout_type, int(duration)) | |
| rag.remember( | |
| f"{mem['user_profile']['name']} did a {workout_type} workout ({int(duration)} min) on {mem['workouts'][-1]['date']}", | |
| kind="event", | |
| ) | |
| return json.dumps(mem) | |
| def get_memory() -> str: | |
| return json.dumps(memory_store.load()) | |
| def set_profile(profile_json: str) -> str: | |
| fields = json.loads(profile_json) if profile_json else {} | |
| return json.dumps(memory_store.set_profile(fields)) | |
| def add_event(event_json: str) -> str: | |
| ev = json.loads(event_json) if event_json else {} | |
| return json.dumps(memory_store.add_event(ev)) | |
| def delete_event(event_id: str) -> str: | |
| return json.dumps(memory_store.delete_event(event_id)) | |
| def set_schedule(days_json: str, time: str) -> str: | |
| days = json.loads(days_json) if days_json else [] | |
| return json.dumps(memory_store.set_workout_schedule(days, time)) | |
| def set_payments(payments_json: str) -> str: | |
| payments = json.loads(payments_json) if payments_json else [] | |
| return json.dumps(memory_store.set_monthly_payments(payments)) | |
| def upsert_goal(goal_json: str) -> str: | |
| goal = json.loads(goal_json) if goal_json else {} | |
| return json.dumps(memory_store.upsert_goal(goal)) | |
| def payment_impact() -> Iterator[str]: | |
| """Stream a short note on how the current monthly payments affect the | |
| user's savings goal(s). Called after payments are saved.""" | |
| mem = memory_store.load() | |
| if not mem.get("goals"): | |
| yield "Add a savings goal in the **Goals** tab to see how your monthly payments affect your timeline." | |
| return | |
| yield from engine.run_domain("payment_impact", "") | |
| def delete_goal(goal_id: str) -> str: | |
| return json.dumps(memory_store.delete_goal(goal_id)) | |
| def edit_memory(section: str, items_json: str) -> str: | |
| """Replace a whitelisted memory section wholesale (manual editing).""" | |
| try: | |
| items = json.loads(items_json) if items_json else [] | |
| return json.dumps(memory_store.set_section(section, items)) | |
| except ValueError as e: | |
| return json.dumps({"error": str(e)}) | |
| def list_notes() -> str: | |
| return json.dumps(rag.list_notes()) | |
| def update_note(note_id: str, text: str) -> str: | |
| rag.update_note(note_id, text) | |
| return json.dumps({"ok": True}) | |
| def delete_note(note_id: str) -> str: | |
| rag.delete_note(note_id) | |
| return json.dumps({"ok": True}) | |
| def health_plan() -> str: | |
| """Deterministic plan: free workout slots on preferred days over the next | |
| 7 days that don't clash with calendar events (slot = time..time+1h).""" | |
| mem = memory_store.load() | |
| schedule = mem.get("workout_schedule", {"days": [], "time": "07:00"}) | |
| events = memory_store.events_in_window(7, mem) | |
| return json.dumps({ | |
| "schedule": schedule, | |
| "next_7_days_events": events, | |
| "recommendation_slots": health.plan_workout_slots(schedule, events), | |
| }) | |
| def reset_demo() -> str: | |
| """Load the sample persona. Only available when LIFEOS_DEMO=1 so it can't | |
| overwrite a real user's data in production.""" | |
| if not config.DEMO: | |
| return json.dumps({"error": "Demo mode is off. Set LIFEOS_DEMO=1 to use sample data."}) | |
| mem = memory_store.reset_to_seed() | |
| rag.reset_to_seed() | |
| return json.dumps(mem) | |
| def reset_account() -> str: | |
| """Wipe all stored data back to a blank slate (real-user reset).""" | |
| mem = memory_store.reset_to_empty() | |
| rag.reset_to_empty() | |
| return json.dumps(mem) | |
| # ------------------------------------------------------------- file uploads | |
| async def upload_flyer(file: UploadFile): | |
| suffix = os.path.splitext(file.filename or "flyer.pdf")[1] or ".pdf" | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: | |
| tmp.write(await file.read()) | |
| path = tmp.name | |
| try: | |
| deals = food.extract_deals(path) | |
| finally: | |
| os.unlink(path) | |
| return JSONResponse({"deals": deals}) | |
| async def upload_flyer_text(request: Request): | |
| body = await request.json() | |
| return JSONResponse({"deals": food.extract_deals_from_text(body.get("text", ""))}) | |
| async def upload_meal_photo(file: UploadFile): | |
| """Identify the food items in a meal/receipt photo using the local vision | |
| model. Falls back to OCR (for text-heavy receipts) if vision is | |
| unavailable. Returns {"text": ..., "source": "vision"|"ocr"} or {"error"}.""" | |
| suffix = os.path.splitext(file.filename or "photo.jpg")[1] or ".jpg" | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: | |
| tmp.write(await file.read()) | |
| path = tmp.name | |
| try: | |
| try: | |
| text = engine.describe_food_image(path) | |
| return JSONResponse({"text": text, "source": "vision"}) | |
| except Exception as e: # vision model unavailable — try OCR for receipts | |
| logger.warning("Vision model failed for meal photo: %s", e) | |
| try: | |
| text = food.ocr_image(path) | |
| return JSONResponse({"text": text, "source": "ocr"}) | |
| except Exception as e: # neither backend available / unreadable image | |
| return JSONResponse( | |
| {"error": f"Couldn't read that photo (vision + OCR both failed): {e}"} | |
| ) | |
| finally: | |
| os.unlink(path) | |
| async def upload_transactions(file: UploadFile): | |
| text = (await file.read()).decode("utf-8", errors="replace") | |
| subs = money.detect_recurring(money.parse_transactions(text)) | |
| return JSONResponse({"subscriptions": subs}) | |
| async def status(): | |
| """Model load state for the UI indicator: idle|loading|ready|error.""" | |
| return JSONResponse({**engine.status(), "demo": config.DEMO}) | |
| # ----------------------------------------------------------------- frontend | |
| class NoCacheStaticFiles(StaticFiles): | |
| """Serve assets with `Cache-Control: no-cache` so browsers always | |
| revalidate. Without this, StaticFiles sends no Cache-Control header and | |
| browsers apply heuristic freshness, serving a stale style.css/app.js for | |
| minutes/hours after an edit — which left the calendar modal rendered open | |
| on the landing page from an outdated stylesheet.""" | |
| async def get_response(self, path, scope): | |
| response = await super().get_response(path, scope) | |
| response.headers["Cache-Control"] = "no-cache" | |
| return response | |
| app.mount("/static", NoCacheStaticFiles(directory=STATIC), name="static") | |
| async def index(): | |
| return FileResponse( | |
| os.path.join(STATIC, "index.html"), | |
| headers={"Cache-Control": "no-cache"}, | |
| ) | |
| if __name__ == "__main__": | |
| logging.basicConfig( | |
| level=os.environ.get("LIFEOS_LOG_LEVEL", "INFO"), | |
| format="%(asctime)s %(levelname)s %(name)s: %(message)s", | |
| ) | |
| # Warm the models in the background so the UI is reachable immediately | |
| # while the GGUFs download/load on first boot. | |
| threading.Thread(target=engine.warmup, daemon=True).start() | |
| # config.HOST is 0.0.0.0 on Spaces, else None so Gradio binds loopback. | |
| app.launch(server_name=config.HOST, server_port=config.PORT) | |