lifeos / app.py
awaisaziz's picture
Add config, model status, and VLM support
0c4cd3b
Raw
History Blame Contribute Delete
13.5 kB
"""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
@app.api(name="food_recommend", concurrency_limit=1, stream_every=0.3)
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)
@app.api(name="health_recommend", concurrency_limit=1, stream_every=0.3)
def health_recommend() -> Iterator[str]:
mem = memory_store.load()
yield from engine.run_domain("health", health.build_health_input(mem))
@app.api(name="money_review", concurrency_limit=1, stream_every=0.3)
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)
@app.api(name="chat", concurrency_limit=1, stream_every=0.3)
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)
@app.api(name="goal_chat", concurrency_limit=1, stream_every=0.3)
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")
@app.api(name="local_deals", concurrency_limit=1, stream_every=0.3)
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."
)
@app.api(name="meal_analyze", concurrency_limit=1, stream_every=0.3)
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
@app.api(name="log_workout")
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)
@app.api(name="get_memory")
def get_memory() -> str:
return json.dumps(memory_store.load())
@app.api(name="set_profile")
def set_profile(profile_json: str) -> str:
fields = json.loads(profile_json) if profile_json else {}
return json.dumps(memory_store.set_profile(fields))
@app.api(name="add_event")
def add_event(event_json: str) -> str:
ev = json.loads(event_json) if event_json else {}
return json.dumps(memory_store.add_event(ev))
@app.api(name="delete_event")
def delete_event(event_id: str) -> str:
return json.dumps(memory_store.delete_event(event_id))
@app.api(name="set_schedule")
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))
@app.api(name="set_payments")
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))
@app.api(name="upsert_goal")
def upsert_goal(goal_json: str) -> str:
goal = json.loads(goal_json) if goal_json else {}
return json.dumps(memory_store.upsert_goal(goal))
@app.api(name="payment_impact", concurrency_limit=1, stream_every=0.3)
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", "")
@app.api(name="delete_goal")
def delete_goal(goal_id: str) -> str:
return json.dumps(memory_store.delete_goal(goal_id))
@app.api(name="edit_memory")
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)})
@app.api(name="list_notes")
def list_notes() -> str:
return json.dumps(rag.list_notes())
@app.api(name="update_note")
def update_note(note_id: str, text: str) -> str:
rag.update_note(note_id, text)
return json.dumps({"ok": True})
@app.api(name="delete_note")
def delete_note(note_id: str) -> str:
rag.delete_note(note_id)
return json.dumps({"ok": True})
@app.api(name="health_plan")
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),
})
@app.api(name="reset_demo")
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)
@app.api(name="reset_account")
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
@app.post("/upload/flyer")
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})
@app.post("/upload/flyer_text")
async def upload_flyer_text(request: Request):
body = await request.json()
return JSONResponse({"deals": food.extract_deals_from_text(body.get("text", ""))})
@app.post("/upload/meal_photo")
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)
@app.post("/upload/transactions")
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})
@app.get("/status")
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")
@app.get("/")
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)