lifeos / docs /CONTRACTS.md
awaisaziz's picture
Add config, model status, and VLM support
0c4cd3b
|
Raw
History Blame Contribute Delete
12.3 kB
# 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).