Spaces:
Running
Running
| # LifeOS internal contracts | |
| Interfaces that feature modules, the server, and the frontend code against. | |
| Keep these stable; change only with a coordinated update. | |
| ## memory.py (short-term structured store) | |
| ```python | |
| load() -> dict # full memory dict (first call: blank, or demo persona if LIFEOS_DEMO=1) | |
| save(mem: dict) -> None | |
| empty_data() -> dict # blank store schema (new-user default) | |
| first_run_data() -> dict # empty_data(), or seed_data() when LIFEOS_DEMO=1 | |
| reset_to_empty() -> dict # wipe to blank slate (real-user reset) | |
| reset_to_seed() -> dict # load demo persona (used by demo mode) | |
| log_meal(dish: str, ingredients: list[str], when: str | None = None) -> dict | |
| log_workout(workout_type: str, duration_min: int, when: str | None = None) -> dict | |
| set_subscriptions(subs: list[dict]) -> dict # [{name, cost, last_used}] | |
| recent_meals(days=7, mem=None) -> list[dict] # [{date, dish, ingredients[]}] | |
| workouts_in_window(days=14, mem=None) -> list[dict] # [{date, type, duration_min}] sorted by date | |
| add_event(ev: dict) -> dict # fills id (uuid4 hex[:8]) if missing; returns full mem | |
| delete_event(event_id: str) -> dict | |
| set_workout_schedule(days: list[str], time: str) -> dict # days normalized to 3-letter lowercase | |
| set_monthly_payments(payments: list[dict]) -> dict # [{name, amount, due_day}] | |
| upsert_goal(goal: dict) -> dict # update by id; insert (new id) when id missing/unknown | |
| delete_goal(goal_id: str) -> dict | |
| set_section(section: str, items: list) -> dict # wholesale replace; sections: "meals" | "workouts" | "calendar" | "subscriptions" | "monthly_payments"; raises ValueError on unknown section | |
| set_profile(fields: dict) -> dict # merges into user_profile; "income_monthly" routes to finances; recomputes name = first_name + " " + last_name | |
| events_in_window(days=7, mem=None) -> list[dict] # calendar events today..today+days, sorted (date, start) | |
| ``` | |
| Schema: | |
| ```json | |
| { | |
| "meals": [{"date": "YYYY-MM-DD", "dish": str, "ingredients": [str]}], | |
| "workouts": [{"date": "YYYY-MM-DD", "type": str, "duration_min": int}], | |
| "calendar": [{"id": "8-char hex", "title": str, "date": "YYYY-MM-DD", "start": "HH:MM", "end": "HH:MM", "kind": "work|class|social|other"}], | |
| "workout_schedule": {"days": ["mon", "wed", "sat"], "time": "HH:MM"}, | |
| "goals": [{"id": "8-char hex", "title": str, "target_amount": num, "saved": num, "deadline": "YYYY-MM", "notes": str}], | |
| "finances": { | |
| "income_monthly": num, | |
| "monthly_payments": [{"name": str, "amount": num, "due_day": int}], | |
| "subscriptions": [{"name": str, "cost": num, "last_used": "YYYY-MM-DD"}] | |
| }, | |
| "user_profile": { | |
| "first_name": str, "last_name": str, | |
| "name": str, // derived: first_name + " " + last_name (kept for back-compat, recomputed on load/set_profile) | |
| "dietary_prefs": [str], "fitness_goal": str, "budget_weekly": num, | |
| "address": str, // street | |
| "city": str, "postal_code": str, "country": str | |
| } | |
| } | |
| ``` | |
| `load()` backfills missing new keys from `empty_data()` (never demo data), so | |
| old memory.json files upgrade in place. Legacy profiles with only `name` get it split on the first | |
| space into `first_name`/`last_name`; goals without an `id` get one (uuid4 | |
| hex[:8]). | |
| ## rag.py (long-term memory, local RAG) | |
| ```python | |
| remember(text: str, kind: str = "fact", meta: dict | None = None) -> dict # kind: fact|event|preference | |
| recall(query: str, k: int = 5, kind: str | None = None) -> list[dict] # [{text, kind, score}] | |
| embed(text: str, is_query: bool = False) -> np.ndarray # normalized; tests monkeypatch this to skip the embedder | |
| ensure_seeded() -> int # seeds demo notes (called only when LIFEOS_DEMO=1) | |
| reset_to_seed() -> int | |
| reset_to_empty() -> int # delete all notes (real-user reset) | |
| list_notes() -> list[dict] # [{id, text, kind}] — no vectors | |
| update_note(note_id: str, text: str) -> bool # re-embeds; preserves kind/meta; False if id unknown | |
| delete_note(note_id: str) -> bool | |
| ``` | |
| Every note has an `id` (8-char hex, backfilled on load for old stores). | |
| ## engine.py (reasoning) | |
| ```python | |
| build_prompt(domain: str, mem: dict, user_input: str, domains: list[str] | None = None) -> list[dict] # [system, user]; domains narrows the memory slice to just those domains + profile (None = default slice) | |
| slice_for_domains(mem: dict, domains: list[str]) -> dict # merged _slice_for_domain outputs; "kitchen" aliases to "food"; user_profile always included | |
| generate_stream(messages, max_tokens=1024, temperature=0.4, domain="chat", extra_context="") -> Iterator[str] # yields CUMULATIVE, reasoning-stripped text; extra_context (e.g. web results) is appended to the last user message; on model-load failure yields one friendly message instead of raising | |
| run_domain(domain: str, user_input: str = "", max_tokens=1024) -> Iterator[str] | |
| describe_food_image(path: str) -> str # vision model: deduped bulleted food items from a photo (raises ModelUnavailable if VLM can't load); downscales to config.VLM_MAX_IMAGE_SIDE first (~4x faster on the CPU path) | |
| status() -> dict # {state: idle|loading|ready|error, backend, error} | |
| warmup() -> None | |
| ``` | |
| Domains: `"food" | "health" | "money" | "chat" | "goal" | "meal_photo" | "payment_impact"`. | |
| `goal` is a Socratic financial-goal coach (one question per turn, plan after | |
| ~3-4 exchanges); `meal_photo` analyzes the food items a vision model | |
| identified in a photo (or text read from a receipt); `payment_impact` is a | |
| short note (streamed after saving monthly payments) on how those payments | |
| affect each savings goal's timeline. | |
| Memory slices: health gets calendar (next 7 days) + workout_schedule; | |
| money/goal/payment_impact get monthly_payments + goals + income; chat gets everything. | |
| Streams yield the full text-so-far each step (not deltas). Prompts handle a | |
| nameless (new) user gracefully. Tests inject a fake `engine._llm` and | |
| `rag.embed` rather than loading real models. | |
| This Nemotron GGUF reasons in plain prose and ignores `/no_think`. The prompt | |
| ends with a recency nudge telling it to jump straight to an `==ANSWER==` | |
| delimiter; `_clean_response` strips everything before the answer (delimiter or, | |
| as a fallback, the first markdown block), hiding reasoning during streaming so | |
| the UI shows its "thinking…" state until the real answer begins. | |
| ## Feature modules (Phase B deliverables) | |
| ### features/food.py | |
| ```python | |
| extract_deals(path: str) -> list[dict] # [{item: str, price_text: str}] from PDF (pdfplumber) or image (pytesseract) | |
| extract_deals_from_text(text: str) -> list[dict] # same, from pasted text | |
| shortlist(deals, recent_meals, profile) -> list[dict] # top 6 recipe candidates, deterministic | |
| build_food_input(deals, mem) -> str # the user_input string handed to engine.run_domain("food", ...) | |
| RECIPES: list[dict] # {name, main: str, ingredients: [str], tags: [str], protein_g: int, cost_tier: 1|2|3} | |
| ``` | |
| ### features/health.py | |
| ```python | |
| weekly_pattern(workouts: list[dict]) -> dict # {by_type: {...}, total_min, days_since_rest, last_7: [{date,type,duration_min}|None x7], consecutive_training_days} | |
| build_health_input(mem: dict) -> str | |
| plan_workout_slots(schedule: dict, events: list[dict], today=None) -> list[dict] | |
| # slots over next 7 days on preferred days; slot = time..time+1h; an event clashes | |
| # when [start,end) overlaps. [{date, day, time, free: bool, clashes: [{id,title,start,end}]}] | |
| ``` | |
| ### features/websearch.py | |
| ```python | |
| search_web(query: str, max_results: int = 5) -> list[dict] # [{title, snippet, url}]; [] on ANY failure (offline-safe) | |
| format_results(results: list[dict]) -> str # readable block for prompt injection ("" for []) | |
| ``` | |
| ### features/money.py | |
| ```python | |
| parse_transactions(csv_text: str) -> list[dict] # [{date: "YYYY-MM-DD", merchant: str, amount: float}] tolerant headers | |
| detect_recurring(txns: list[dict]) -> list[dict] # [{name, cost, occurrences, last_charged, cadence_days}] — >=2 charges, median interval 25-35d, amount stable ±15% | |
| build_money_input(subs: list[dict], mem: dict) -> str | |
| ``` | |
| All feature logic must be pure/deterministic and unit-tested in `tests/` | |
| (plain `assert`s runnable via `python tests/test_<name>.py`, no pytest dep, | |
| no model calls). | |
| ## Server endpoints (app.py — gr.Server) | |
| Streaming `@app.api` endpoints (SSE generators, cumulative text): | |
| | name | args (in order, all strings) | behavior | | |
| |---|---|---| | |
| | `food_recommend` | `deals_json` | 3 recipe picks from flyer deals | | |
| | `health_recommend` | — | tomorrow's workout recommendation | | |
| | `money_review` | `subs_json` | subscription CANCEL/KEEP/WATCH review | | |
| | `chat` | `message, history_json, use_web, refs_json` | general chat; `use_web="1"` injects DuckDuckGo results as extra context (`""` = off); `refs_json` = JSON array of domain refs like `["kitchen","health"]` ("kitchen" normalizes to "food"; allowed: kitchen/food, health, money) — when present the prompt contains ONLY those domains' memory slices + profile; `""` = full context (backward-compatible). NOTE: the wire protocol requires all 4 values in `data` — send `""` for refs off | | |
| | `goal_chat` | `message, history_json, goal_json` | Socratic goal coaching; `goal_json` is one goal object (or `""`); history format same as chat: `[{role, content}]` | | |
| | `local_deals` | `city` | web-searches "<city> grocery store weekly flyer deals", extracts deals, streams a food-domain recommendation (falls back to a search summary; offline-safe message if no results). `city=""` uses profile city | | |
| | `meal_analyze` | `ocr_text, meal_label` | meal_photo-domain analysis of OCR text; non-empty `meal_label` is logged via `log_meal` first | | |
| | `payment_impact` | — | streams a short note on how the saved monthly payments affect each savings goal's timeline; one line if no goals exist. Called by the UI right after `set_payments` | | |
| Non-streaming `@app.api` (each returns the FULL memory dict as a JSON string, | |
| except `get_memory`/`health_plan` as noted): | |
| | name | args | notes | | |
| |---|---|---| | |
| | `log_workout` | `workout_type, duration` | existing | | |
| | `get_memory` | — | full memory JSON | | |
| | `set_profile` | `profile_json` | merge fields into user_profile; accepts `first_name, last_name, address, city, postal_code, country, income_monthly, dietary_prefs, fitness_goal, budget_weekly`; `income_monthly` routes to finances; `name` recomputed from first/last | | |
| | `upsert_goal` | `goal_json` | one goal object `{id?, title, target_amount, saved, deadline, notes}`; updates by id, inserts (new id) when missing; returns full mem JSON | | |
| | `delete_goal` | `goal_id` | returns full mem JSON | | |
| | `edit_memory` | `section, items_json` | wholesale-replace a section (`meals`, `workouts`, `calendar`, `subscriptions`, `monthly_payments`); returns full mem JSON, or `{"error": "..."}` on unknown section | | |
| | `list_notes` | — | JSON array `[{id, text, kind}]` of long-term notes (no vectors) | | |
| | `update_note` | `note_id, text` | re-embeds; returns `{"ok": true}` | | |
| | `delete_note` | `note_id` | returns `{"ok": true}` | | |
| | `add_event` | `event_json` | `{title, date, start, end, kind}`; id auto-assigned | | |
| | `delete_event` | `event_id` | | | |
| | `set_schedule` | `days_json, time` | `days_json` = JSON array like `["mon","wed"]`, `time` = "HH:MM" | | |
| | `set_payments` | `payments_json` | JSON array `[{name, amount, due_day}]` | | |
| | `health_plan` | — | deterministic JSON string: `{"schedule": {...}, "next_7_days_events": [...], "recommendation_slots": [{date, day, time, free, clashes}]}` | | |
| | `reset_demo` | — | load demo persona; returns `{"error": ...}` unless `LIFEOS_DEMO=1` | | |
| | `reset_account` | — | wipe all data to a blank slate (real-user reset); returns full mem JSON | | |
| FastAPI routes: `POST /upload/flyer`, `POST /upload/flyer_text`, | |
| `POST /upload/transactions`, `POST /upload/meal_photo` (multipart image → | |
| `{"text": items, "source": "vision"|"ocr"}` — vision model identifies food | |
| items, OCR fallback for receipts — or `{"error": "..."}` if both fail), | |
| `GET /status` (`{state, backend, error, demo}`), static frontend at `/`. | |
| Frontend calls REST directly (no CDN client), protocol unchanged: | |
| `POST /gradio_api/call/<name>` with `{"data": [...]}` → `{event_id}`; then | |
| `GET /gradio_api/call/<name>/<event_id>` is an SSE stream of `data:` lines | |
| (JSON arrays of outputs). | |