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:
Robin Kutesa
2026-06-18 18:04:59 +03:00
commit 27d0e22767
45 changed files with 4913 additions and 0 deletions

View 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.