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>
This commit is contained in:
150
docs/backend/CODEX_PREHANDOVER_CHECKLIST.md
Normal file
150
docs/backend/CODEX_PREHANDOVER_CHECKLIST.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user