# Ooru — Architecture Migration Plan (v1 → v2)

> Goal: simplify Ooru to the og-coach (PocketBase) pattern — a stack already shipped,
> liked, and maintained at og.maahaa.dev. Deletes more code than it adds; kills the
> security bug and the fabrication bug structurally. Authored under maahaa.dev, 2026-06-09.
> Reference studied live: ~/SS_WRK/fitness/og-coach/.

## Why migrate

Ooru's flow is LINEAR: geocode → schools → metro → AQI → rank → LLM advisory. That does
not need LangGraph (graph orchestration), FastAPI+uvicorn, SQLAlchemy, hand-rolled JWT,
or external Postgres. og-coach proves the same class of app (auth + per-user data + one
server-side LLM endpoint + a PWA) ships as ONE PocketBase binary with one hook file.

The two worst Ooru bugs disappear by construction:
- Fabricated findings (0.1): in a single linear hook you assemble REAL data then call the
  LLM with only verified fields. There is no "node returns {}" indirection to silently
  drop schools/metro. The bug is structurally impossible.
- Hardcoded JWT secret (0.2): the whole hand-rolled auth (services.py) is deleted —
  PocketBase auth replaces it. No secret to leak.

## Flow: v1 (now) vs v2 (target)

**v1 — LangGraph pipeline. Schools/metro fetched then DISCARDED (the fabrication bug):**

```mermaid
flowchart TD
    A[landmark] --> B["POST /chat (JWT)"]
    B --> C[execute_chat_workflow]
    C --> D[LangGraph app.invoke]
    D --> N1[N1 GET_LOCATION_AND_LOCALITY<br/>Mapbox geocode]
    N1 --> N2[N2 FIND_NEARBY_SCHOOLS<br/>Foursquare]
    N1 --> N3[N3 FIND_NEARBY_PLACES<br/>Foursquare metro]
    N1 --> N4[N4 GET_AQI<br/>Open-Meteo]
    N4 --> N5[N5 RANK_BY_AQI]
    N2 -. "return {}  — printed, discarded" .-> X[(dropped)]
    N3 -. "return {}  — printed, discarded" .-> X
    N5 --> LLM["Kimi advisory<br/>prompt = coords + AQI + ranked ONLY"]
    LLM --> P[save_house_rental → ChatResponse]
    LLM -. "schools/metro claims hardcoded" .-> F{{FABRICATED}}

    class N1,N4,N5,P ok
    class N2,N3,X,F bad
    classDef ok fill:#15102a,stroke:#f4a93b,color:#f5ede0
    classDef bad fill:#15102a,stroke:#c54a2c,color:#d6cdba
```

**v2 — one linear hook. Real schools/metro reach the LLM and persistence. Fabrication impossible:**

```mermaid
flowchart TD
    A[landmark] --> B["POST /api/advisory<br/>(PocketBase auth)"]
    B --> G[geocode · Nominatim/Ola]
    G --> O1[Overpass metro · cached]
    G --> O2[Overpass schools · cached]
    G --> AQ[Open-Meteo AQI per locality]
    O1 --> ASM[assemble REAL data]
    O2 --> ASM
    AQ --> RK[rank ascending] --> ASM
    ASM --> LLM["LLM via $http.send<br/>prompt = ONLY verified fields"]
    LLM --> S[(searches collection<br/>ranked + schools + metro + advisory)]
    LLM --> R[JSON → PWA]

    class G,O1,O2,AQ,RK,ASM,LLM,S,R ok
    classDef ok fill:#15102a,stroke:#f4a93b,color:#f5ede0
```

## og-coach pattern (the target, verified by reading the repo)

- ONE binary `pocketbase` serves API + static PWA from the same origin. No web framework.
- `pb_public/` — vanilla JS PWA: `app.js` + `index.html` (inline CSS design system) +
  `sw.js` + `manifest.webmanifest` + icons. NO build step. Edit + reload.
- `pb_hooks/main.pb.js` — ONE custom endpoint. LLM key from server env ($os.getenv),
  never client-side. Calls LLM via `$http.send`. ~237 lines does the whole AI feature.
- `pb_migrations/TIMESTAMP_name.js` — schema as timestamped files; never edit old, add new.
- PocketBase gives auth, REST CRUD, embedded SQLite, admin UI, file storage — free.
- `tools/deploy.sh` — rsync to Lightsail, migrate, restart. Live URL.

## Mapping: Ooru v1 → v2

| Concern | v1 (now) | v2 (PocketBase) | Net |
|---|---|---|---|
| Server | FastAPI + uvicorn | `pocketbase serve` | delete framework |
| DB / ORM | SQLAlchemy + external Postgres | embedded SQLite + collections | delete ORM + DATABASE_URL |
| Auth | JWT + bcrypt + python-jose (services.py) | PocketBase built-in auth | delete, bug 0.2 gone |
| Orchestration | LangGraph (workflow.py) | sequential calls in one hook | delete LangGraph, bug 0.1 gone |
| LLM | OpenAI SDK (Python) | `$http.send` in hook, server key | key never client-side |
| Frontend | none (CLI + JSON) | vanilla PWA, same origin | gain a real iOS-friendly surface |
| Cache | APICache table | `cache` collection (or SQLite) | carries over |
| Persistence | HouseRental | `searches` collection | carries over |
| Deploy | undefined | `tools/deploy.sh` | gain repeatable deploy |

Deleted from v1: `backend/app.py`, `backend/services.py`, `backend/database.py`,
`backend/models.py`, `backend/schemas.py`, `workflow.py`, `chatbot.py` (CLI optional),
plus deps: fastapi, uvicorn, sqlalchemy, python-jose, passlib, langgraph. `main.py`'s
API-integration functions get PORTED to JS in the hook (logic preserved, language changed).

## Target layout

    ooru/
      pocketbase                    # single binary (download per-OS)
      pb_public/                    # vanilla PWA — Cosmic Engineering design system
        index.html  app.js  sw.js  manifest.webmanifest  icon-*.png
      pb_hooks/
        main.pb.js                  # POST /api/advisory  (the whole pipeline)
      pb_migrations/
        1718100000_ooru_schema.js   # collections below
      tools/deploy.sh               # rsync → Lightsail → migrate → restart
      docs/                         # already built this session

## Collections (per-user via `user = @request.auth.id`)

- `searches` — {user, landmark, lat, lon, aqi(JSON), ranked(JSON), schools(JSON),
  metro(JSON), advisory(text), created} — replaces HouseRental, now stores REAL
  schools/metro (the honesty fix, persisted).
- `cache` — {api_name, cache_key(unique), data(JSON), created} — replaces APICache.
- `profile` — {user, saved(JSON list of localities), notes(JSON)} — one row/user, powers
  the Horizon-2 comparison + personal-memory features.
- (auth `users` collection is built in.)

## The one hook — POST /api/advisory (pseudocode)

```js
/// <reference path="../pb_data/types.d.ts" />
// LLM + API keys live ONLY here (server env). Linear pipeline — no graph.
routerAdd("POST", "/api/advisory", (e) => {
 try {
  const auth = e.auth; if (!auth || !auth.id) return e.json(401, {error:"Login required."})
  const uid = auth.id
  const landmark = (e.requestInfo().body || {}).landmark
  if (!landmark) return e.json(400, {error:"Landmark required."})

  // per-user daily cap (same pattern as og-coach build-plan)
  const cap = parseInt($os.getenv("OORU_DAILY_CAP") || "10", 10)
  // ... countRecords on searches today >= cap -> 429 ...

  // cache helper backed by the `cache` collection
  const cached = (name, key, fn) => { /* find or fn()+save */ }

  // 1. geocode (Nominatim or Ola) — server-side
  const geo = cached("geocode", landmark, () =>
    $http.send({url:"https://nominatim.openstreetmap.org/search?q="+encodeURIComponent(landmark)+
      "&format=json&limit=1&countrycodes=in",
      headers:{"User-Agent":"ooru/1.0 (contact@maahaa.dev)"}, timeout:20}).json)
  const {lat, lon} = pickFirst(geo)

  // 2. POIs via Overpass (BBOX, not area-name) — metro + schools, cached
  const metro   = cached("overpass:subway", bbox, () => overpass(`node["station"="subway"](${BBOX});`))
  const schools = cached("overpass:school", bbox, () => overpass(`nwr["amenity"="school"](${BBOX});`))
  // nearest-N via haversine in JS over cached lists — zero extra calls

  // 3. AQI (Open-Meteo) per locality, ranked ascending — cached
  const ranked = rankByAqi(localities)   // Open-Meteo $http.send per locality

  // 4. LLM advisory — pass ONLY verified fields. No fabrication possible.
  const sys = "You are Ooru, a Bangalore locality advisor. State ONLY what the data shows. "
    + "If schools/metro lists are empty, say 'none found within X km'. Never invent counts."
  const userMsg = JSON.stringify({landmark, lat, lon, ranked, metro, schools})
  const res = $http.send({url:$os.getenv("OORU_LLM_URL"), method:"POST",
    headers:{"Content-Type":"application/json","Authorization":"Bearer "+$os.getenv("OORU_LLM_KEY")},
    body: JSON.stringify({model:$os.getenv("OORU_LLM_MODEL"),
      messages:[{role:"system",content:sys},{role:"user",content:userMsg}],
      temperature:0.4}), timeout:240})
  const advisory = res.json.choices[0].message.content

  // 5. persist REAL data + advisory
  const col = $app.findCollectionByNameOrId("searches")
  const rec = new Record(col)
  rec.set("user", uid); rec.set("landmark", landmark)
  rec.set("lat", lat); rec.set("lon", lon)
  rec.set("ranked", ranked); rec.set("metro", metro); rec.set("schools", schools)
  rec.set("advisory", advisory)
  $app.save(rec)
  return e.json(200, {id: rec.id, landmark, lat, lon, ranked, metro, schools, advisory})
 } catch (er) { return e.json(500, {error:"Could not build advisory."}) }
})
```
Key point: schools/metro are assembled into real variables and BOTH persisted AND passed
to the LLM. The v1 failure (data fetched then discarded by a node returning `{}`) cannot
recur — there is no node, just straight-line code.

## PocketBase JSVM gotchas (from og-coach CLAUDE.md — already learned)

- Module-level vars are NOT visible inside `routerAdd` callbacks. Read files / env INSIDE
  the handler (og-coach reads og_principles.txt per-request; negligible cost).
- Schema: add a NEW migration file, never edit an applied one. Applied by timestamp order.
- `$os.getenv`, `$os.readFile`, `$http.send`, `$app.findCollectionByNameOrId`, `new Record`,
  `$app.save`, `$app.countRecords`, `$dbx.exp` are the core APIs (all used in og-coach hook).
- LLM call: `response_format:{type:"json_object"}` if you want strict JSON back; strip
  ```json fences defensively before JSON.parse (og-coach does this).

## Migration steps (one focused session)

1. Drop the `pocketbase` binary into `ooru/`. `./pocketbase serve` — confirm admin UI at /_/.
2. Write `pb_migrations/1718100000_ooru_schema.js` — searches, cache, profile collections
   with `user = @request.auth.id` rules. `./pocketbase migrate up`.
3. Port `main.py` API functions → `pb_hooks/main.pb.js` as one `POST /api/advisory` hook
   (geocode, overpass, aqi, rank, llm). Use the data-sources playbook (DATA_SOURCES.md §8)
   for exact endpoints/queries. Server env: OORU_LLM_URL/KEY/MODEL, OORU_DAILY_CAP.
4. Build `pb_public/` PWA in Cosmic Engineering: index.html (inline CSS tokens), app.js
   (auth via PocketBase JS, call /api/advisory, render advisory + ranked localities + a
   Mapbox/Ola map of pins), sw.js (network-first JS/HTML, bump CACHE per release),
   manifest + icons. iOS Add-to-Home-Screen ready.
5. `tools/deploy.sh` — clone og-coach's: rsync to Lightsail (3.108.167.11 or a new box),
   migrate up, restart. CF A record ooru.maahaa.dev → box, DNS-only.
6. Retire v1: archive `backend/`, `workflow.py`, `chatbot.py`, `main.py`; trim
   requirements.txt to just the push/util scripts (if any). Keep them in git history.

## Trade-offs (honest)

- It's a rewrite, not a refactor. Python logic → JS in the hook. ~1 session of real work.
- SQLite not Postgres — fine at Ooru's scale (single box, <100 users), same as og-coach.
- LangGraph would only earn its keep if the flow became non-linear (branching/parallel/
  retry). It's linear today, so it's unused capability. Revisit only if genuinely agentic.
- PocketBase JSVM quirks are real but already documented (see gotchas above).

## Verdict

Adopt the og-coach pattern. It removes FastAPI + SQLAlchemy + LangGraph + hand-rolled
auth + external Postgres + the CLI, replaces them with one binary + one hook + a PWA,
and makes both the security bug and the fabrication bug impossible by construction. Same
stack you already ship and maintain. This IS the simplification.
