# Jarvis Backend — Pre-Handover Checklist (Codex → integration) Run every item before declaring the backend done. Most have a command + expected result, so "done" is **verified**, not assumed. Set the host once: ```bash HOST=http://:8080 # e.g. http://192.168.30.50:8080 ``` > The single biggest cause of a "connected but empty" app is **date format** and > **key casing** (§A). If §A doesn't pass, nothing else matters — the client drops > the objects silently. --- ## A. Contract compatibility (the silent killers) - [ ] **Dates are `YYYY-MM-DDTHH:MM:SSZ`** — UTC, literal `Z`, NO fractional seconds. ```bash curl -s "$HOST/api/v1/stories" | python3 -c ' import sys,json,re d=json.load(sys.stdin)["data"] bad=[s[k] for s in d for k in ("updatedAt","createdAt") if not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", s[k])] print("DATES OK" if not bad else ("BAD DATES: "+str(bad[:3])))' ``` - [ ] **Keys are camelCase** — `signalScore`, `scoreBreakdown`, `sourceCount`, `pollIntervalSeconds`, `publishedAt`, `nextCursor`, `hasMore`, `isBreaking`, `sourceUrl`, `imageUrl`, `storyId`, `articleCountToday`, `lastFetchedAt`. No snake_case anywhere in any response body. - [ ] **`scoreBreakdown` ints sum exactly to `signalScore`.** ```bash curl -s "$HOST/api/v1/stories" | python3 -c ' import sys,json d=json.load(sys.stdin)["data"] bad=[s["id"] for s in d if sum(s["scoreBreakdown"].values())!=s["signalScore"]] print("SCORES OK" if not bad else "SUM MISMATCH: "+str(bad))' ``` - [ ] **Stories sorted by `signalScore` descending** in `GET /stories`. - [ ] **Topic is always one of** `finance | tech | politics | africa` (the app's pills). - [ ] Unknown/extra fields are fine, but **no required field is missing or null** where the client expects a value (e.g. `headline`, `topic`, `signalScore`, `sourceCount`, `scoreBreakdown`, `updatedAt`, `createdAt`, `sources`). ## B. Endpoints behave exactly as the client calls them - [ ] `GET /api/v1/health` → `{status,version,storiesCount,feedsCount,uptime}` (200). - [ ] `GET /api/v1/stories?limit=20` honors `limit` (≤100), returns `{data,nextCursor,hasMore,total}`. - [ ] **Pagination round-trips:** take `nextCursor`, pass it as `after`, get the next page with no overlap and no dupes. ```bash C=$(curl -s "$HOST/api/v1/stories?limit=2" | python3 -c 'import sys,json;print(json.load(sys.stdin)["nextCursor"])') curl -s "$HOST/api/v1/stories?limit=2&after=$C" | python3 -c 'import sys,json;print("page2 ids:",[s["id"] for s in json.load(sys.stdin)["data"]])' ``` - [ ] `GET /api/v1/stories?topic=finance` filters; `&min_signal=80` filters. - [ ] `GET /api/v1/stories/{id}` → `StoryDetail` **with `timeline[]`** (chronological). - [ ] Bad id → **404** with `{"error":{"code":"story_not_found","message":...,"status":404}}`. - [ ] `GET /api/v1/articles/{id}` → full `body` present (not truncated to the RSS blurb if avoidable); `storyId` matches. - [ ] `GET /api/v1/feeds` → `{data:[Feed]}` with `health`, `failureCount`, `articleCountToday`, `lastFetchedAt`. - [ ] `POST /api/v1/feeds` with `{"url","name","pollIntervalSeconds"}` → **201** + feed body. ```bash curl -s -X POST "$HOST/api/v1/feeds" -H 'Content-Type: application/json' \ -d '{"url":"https://feeds.bbci.co.uk/news/world/rss.xml","name":"BBC World","pollIntervalSeconds":900}' -i | head -1 ``` - [ ] Invalid feed URL → **400** `invalid_url`. - [ ] `DELETE /api/v1/feeds/{id}` → **204** no body; deleting missing id → 404 `feed_not_found`. ## C. Correlation & scoring are actually working - [ ] Articles from **different sources** about the same event land in **one** Story (spot-check: a Story with `sourceCount >= 2` whose `timeline` shows ≥2 distinct sources). - [ ] Not the degenerate case of **one Story per article** (clustering threshold too high) or **everything in one Story** (threshold too low). - [ ] `sources[]` / `timeline[]` list the right outlets; exactly one entry flagged the earliest/`isBreaking` per the seed logic. - [ ] `consensus` text appears when `sourceCount >= 2`; `conflict` is `null` unless a real dispute marker is present. - [ ] `freshness` decays over time (an old Story scores lower than a fresh one with the same sources). ## D. WebSocket `/ws` - [ ] `ws:///ws` accepts a connection (test below). ```bash python3 - <<'PY' import asyncio, websockets, json async def main(): async with websockets.connect("ws://:8080/ws") as ws: print("connected; waiting for an event/ping (15s)...") try: print(await asyncio.wait_for(ws.recv(), 15)) except asyncio.TimeoutError: print("no message in 15s (ok if idle)") asyncio.run(main()) PY ``` - [ ] Server sends `{"type":"ping"}` ~every 30s and tolerates the client's `{"type":"pong"}` reply. - [ ] A score change broadcasts `story.updated` **with `signalScore` AND `sourceCount`** (the client requires both on that event). - [ ] A brand-new cluster broadcasts `story.created`; a stale one broadcasts `story.stale`. - [ ] A feed health transition broadcasts `feed.health` with `feedId`, `health`, `failureCount`, `updatedAt`. - [ ] Multiple simultaneous clients all receive broadcasts; a dropped client doesn't crash the loop. ## E. Ingest robustness - [ ] A dead/unreachable feed URL drives `active → failing → dead` and increments `failureCount` without crashing the poller. - [ ] Duplicate articles (same guid/link re-published) are **not** inserted twice. - [ ] HTML is stripped from `body`; `imageUrl` is populated when the feed provides one. - [ ] Missing `published_at` falls back sanely (now) rather than erroring. - [ ] Poller respects each feed's `pollIntervalSeconds` (doesn't hammer sources). ## F. Deployment on Proxmox - [ ] Runs under **systemd** (`systemctl status jarvis` = active/running), not a stray shell. - [ ] Binds **`0.0.0.0:8080`** (reachable from the LAN, not just localhost): `curl http://:8080/api/v1/health` works **from your Mac**, not only inside the container. - [ ] **Survives reboot:** `reboot` the LXC, then `curl .../health` succeeds with the service back up and data intact. - [ ] SQLite DB persists at a fixed path (e.g. `/opt/jarvis/jarvis.db`) and isn't wiped on restart. - [ ] Container has a **static IP / DHCP reservation** so the app's saved host stays valid. - [ ] Firewall (if any) allows inbound TCP **8080**. - [ ] Logs are reachable: `journalctl -u jarvis -n 50` shows clean startup + poll cycles. ## G. End-to-end with the real app (the real acceptance) - [ ] Onboarding: enter `:8080` → **Connect succeeds**. - [ ] Home feed populates, ranked by signal, fade rendering looks right. - [ ] Header shows **LIVE** (green) — WebSocket connected. - [ ] Tap a story → detail with timeline → tap an article → reader shows full body. - [ ] Pull-to-refresh works; opening an article caches it (green dot / offline badge appears). - [ ] Feed manager (radio icon, top-right) lists feeds with correct health dots; add + swipe-delete work. ## H. Handover packet (give these back for integration) - [ ] The **container IP/host** and confirmation the service is enabled on boot. - [ ] One **sample `GET /stories` response** and one **`GET /stories/{id}`** (so I can diff against the client decoder). - [ ] The **seed feed list** used and the **`localRelevance` region/lexicon** chosen. - [ ] Any deviations from `BACKEND_HANDOFF.md` (endpoints, fields, thresholds) called out. - [ ] `requirements.txt` frozen (`pip freeze`) and the repo/commit deployed. --- **Definition of done:** §A–§F all green, §G walks through on a real device, and §H is handed back. At that point ping me and I'll run the client-side integration pass.