Files
jarvis/docs/backend/CODEX_PREHANDOVER_CHECKLIST.md
Robin Kutesa 27d0e22767 Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed,
story detail, article reader, feed manager, and LAN⇄Tailscale connectivity.
Project generated from project.yml via XcodeGen.

Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates,
backlog, and API/backend handoff docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:59 +03:00

151 lines
7.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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://<container-ip>: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://<host>/ws` accepts a connection (test below).
```bash
python3 - <<'PY'
import asyncio, websockets, json
async def main():
async with websockets.connect("ws://<container-ip>: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://<container-ip>: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 `<container-ip>: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.