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>
7.7 KiB
7.7 KiB
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:
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, literalZ, NO fractional seconds.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. scoreBreakdownints sum exactly tosignalScore.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
signalScoredescending inGET /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=20honorslimit(≤100), returns{data,nextCursor,hasMore,total}.- Pagination round-trips: take
nextCursor, pass it asafter, get the next page with no overlap and no dupes.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=financefilters;&min_signal=80filters.GET /api/v1/stories/{id}→StoryDetailwithtimeline[](chronological).- Bad id → 404 with
{"error":{"code":"story_not_found","message":...,"status":404}}. GET /api/v1/articles/{id}→ fullbodypresent (not truncated to the RSS blurb if avoidable);storyIdmatches.GET /api/v1/feeds→{data:[Feed]}withhealth,failureCount,articleCountToday,lastFetchedAt.POST /api/v1/feedswith{"url","name","pollIntervalSeconds"}→ 201 + feed body.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 → 404feed_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 >= 2whosetimelineshows ≥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/isBreakingper the seed logic.consensustext appears whensourceCount >= 2;conflictisnullunless a real dispute marker is present.freshnessdecays over time (an old Story scores lower than a fresh one with the same sources).
D. WebSocket /ws
ws://<host>/wsaccepts a connection (test below).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.updatedwithsignalScoreANDsourceCount(the client requires both on that event). - A brand-new cluster broadcasts
story.created; a stale one broadcastsstory.stale. - A feed health transition broadcasts
feed.healthwithfeedId,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 → deadand incrementsfailureCountwithout crashing the poller. - Duplicate articles (same guid/link re-published) are not inserted twice.
- HTML is stripped from
body;imageUrlis populated when the feed provides one. - Missing
published_atfalls 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/healthworks from your Mac, not only inside the container. - Survives reboot:
rebootthe LXC, thencurl .../healthsucceeds 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 50shows 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 /storiesresponse and oneGET /stories/{id}(so I can diff against the client decoder). - The seed feed list used and the
localRelevanceregion/lexicon chosen. - Any deviations from
BACKEND_HANDOFF.md(endpoints, fields, thresholds) called out. requirements.txtfrozen (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.