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:
32
docs/BACKLOG.md
Normal file
32
docs/BACKLOG.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Backlog
|
||||
|
||||
Seed list for the issue tracker. Each becomes a GitHub issue (see
|
||||
`scripts/create-issues.sh`). `[client]` = iOS, `[backend]` = coordinate with the
|
||||
backend service.
|
||||
|
||||
1. **[client][backend] Add `Sport` (F1) and `World` topic pills.** The 47 feeds now
|
||||
include heavy F1 + global-news coverage that gets squeezed into finance/tech/
|
||||
politics/africa. Add pills in `Topic.filters`; backend classifier must emit `sport`
|
||||
/ `world` slugs to match.
|
||||
2. **[client] Wire the Tailscale remote host in Connectivity.** Populate the Remote
|
||||
Access card's host so away-from-home auto-selects the Tailscale address. Verify the
|
||||
LAN⇄Tailscale switch via `/health` probing.
|
||||
3. **[client] SSID-based trusted networks.** Pin trusted Wi-Fi by name ("Add current
|
||||
network"). Needs the *Access WiFi Information* entitlement (real device + paid
|
||||
account); no-ops in the simulator. Currently approximated by LAN reachability.
|
||||
4. **[client] Optional in-app WireGuard tunnel.** NetworkExtension packet-tunnel so
|
||||
Jarvis can bring up a WireGuard tunnel itself (WireGuard/Netmaker configs only).
|
||||
Needs entitlement + real device. Deferred.
|
||||
5. **[client] App icon + launch assets.** `AppIcon` is an empty placeholder.
|
||||
6. **[client] Flesh out Latest / Saved / Search tabs.** Currently minimal reuse of the
|
||||
feed components; add proper empty/loading states and dedicated behavior.
|
||||
7. **[client] Verify hero image loading.** `AsyncImage` against real feed `imageUrl`s
|
||||
(ATS, redirects, grey fallback) in the article reader.
|
||||
8. **[client] Infinite scroll / pagination at scale.** Validate `loadMore`/cursor
|
||||
round-trips against the live ~850-story dataset; guard against dupes.
|
||||
9. **[backend] Topic-classifier coverage for the 47 feeds.** Ensure science/tech/world/
|
||||
sport map sensibly into the supported topic slugs.
|
||||
10. **[ci] Add unit/UI tests.** The build matrix compiles but runs no tests yet; add a
|
||||
test target and a `test` matrix leg.
|
||||
11. **[client] Offline polish.** Green "cached" dot + Saved tab depend on opening an
|
||||
article; consider pre-caching top stories for true offline-first.
|
||||
188
docs/IOS_CLIENT_EXPECTATIONS.md
Normal file
188
docs/IOS_CLIENT_EXPECTATIONS.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# What the iOS Client Actually Requires (derived from client source)
|
||||
|
||||
This is reverse-engineered from the Swift `Codable` models (`Jarvis/Models/Models.swift`)
|
||||
and the two decoders (`APIClient.swift`, `WebSocketManager.swift`) — i.e. the real
|
||||
arbiter of what the backend must emit. Where this disagrees with
|
||||
`BACKEND_HANDOFF.md`, **this wins**. Read alongside the pre-handover checklist.
|
||||
|
||||
## Decoder facts (both REST and WS use the same config)
|
||||
|
||||
```swift
|
||||
decoder.dateDecodingStrategy = .iso8601 // Foundation .withInternetDateTime
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
```
|
||||
|
||||
Three consequences that drive everything below:
|
||||
|
||||
1. **Dates: `YYYY-MM-DDTHH:MM:SSZ` only** — UTC, literal `Z`, **no fractional seconds,
|
||||
no `+00:00` offset**. `.iso8601` rejects microseconds. This is the #1 break.
|
||||
2. **Key casing is forgiving** — because of `.convertFromSnakeCase`, **either**
|
||||
`signalScore` **or** `signal_score` decodes correctly (camelCase has no underscores
|
||||
so it passes through; snake_case is converted). So you are NOT forced into camelCase
|
||||
— but camelCase matches the contract examples, so prefer it and stay consistent.
|
||||
3. **A Swift non-optional property that is missing OR `null` throws** — and because
|
||||
lists are decoded as `[StorySummary]` / `[Feed]`, **one bad element fails the entire
|
||||
array**, so the whole page/response is dropped and the screen goes empty. The store
|
||||
swallows the error silently (no crash, no log). So "mostly correct" = "empty feed."
|
||||
Every required field on every object must be present, non-null, and the right type.
|
||||
|
||||
---
|
||||
|
||||
## Required vs nullable — by endpoint
|
||||
|
||||
Legend: **R** = required (must be present, non-null, correct type) · **N** = nullable
|
||||
(may be `null`, but the **key should still be present**).
|
||||
|
||||
### `GET /stories` → `PaginatedStories`
|
||||
| Field | Req | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `data` | R | array | each element a **StorySummary** (below) |
|
||||
| `nextCursor` | N | string\|null | pass back verbatim as `after` |
|
||||
| `hasMore` | R | bool | |
|
||||
| `total` | R | int | |
|
||||
|
||||
**StorySummary** (one per `data[]`):
|
||||
| Field | Req | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | R | string | |
|
||||
| `headline` | R | string | canonical story headline |
|
||||
| `summary` | R | string | **non-null** — must synthesize a cross-source summary |
|
||||
| `topic` | R | string | one of `finance/tech/politics/africa` |
|
||||
| `signalScore` | R | int | integer, not float/string |
|
||||
| `scoreBreakdown` | R | object | all 5 ints R (below); **sum == signalScore** |
|
||||
| `sourceCount` | R | int | |
|
||||
| `sources` | R | array | **REQUIRED here** (see gap #1); may be `[]` but key must exist |
|
||||
| `consensus` | N | string\|null | |
|
||||
| `conflict` | N | string\|null | |
|
||||
| `updatedAt` | R | date | `...Z` |
|
||||
| `createdAt` | R | date | `...Z` |
|
||||
|
||||
**scoreBreakdown**: `sourceAuthority`, `freshness`, `localRelevance`,
|
||||
`crossSourceConfirmation`, `topicImportance` — **all R, all int**.
|
||||
|
||||
**StorySource** (each `sources[]`): `id` R, `name` R, `url` R, `publishedAt` R (date),
|
||||
`isBreaking` R (bool).
|
||||
|
||||
### `GET /stories/{id}` → `StoryDetail`
|
||||
Same as StorySummary **except**: **no `sources` field**, and **adds `timeline`** (R, array).
|
||||
`consensus`/`conflict` N. `summary` R.
|
||||
|
||||
**TimelineEntry** (each `timeline[]`): `articleId` R, `source` R, `headline` R,
|
||||
`publishedAt` R (date), `isBreaking` R (bool). Client renders the **first** entry with
|
||||
the orange node and shows a BREAKING badge where `isBreaking == true`.
|
||||
|
||||
### `GET /articles/{id}` → `Article`
|
||||
| Field | Req | Type |
|
||||
|---|---|---|
|
||||
| `id`, `storyId`, `source`, `sourceUrl`, `headline`, `body` | R | string |
|
||||
| `publishedAt` | R | date |
|
||||
| `imageUrl` | N | string\|null |
|
||||
| `author` | N | string\|null |
|
||||
|
||||
`body` is shown as the full article — give the richest text you have (HTML-stripped).
|
||||
|
||||
### `GET /feeds` → `{ "data": [Feed] }`
|
||||
Must be wrapped in `{"data": [...]}`. **Feed**:
|
||||
| Field | Req | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `id`,`name`,`url` | R | string | |
|
||||
| `health` | R | enum | **exactly** `"active"`\|`"failing"`\|`"dead"` — any other string fails the whole array |
|
||||
| `pollIntervalSeconds` | R | int | |
|
||||
| `failureCount` | R | int | |
|
||||
| `lastFetchedAt` | N | date\|null | `...Z` or null |
|
||||
| `articleCountToday` | R | int | |
|
||||
|
||||
### `GET /health` → `ServerHealth`
|
||||
`status` R (string), `version` R (string), `storiesCount` R (int), `feedsCount` R (int),
|
||||
`uptime` R (int). All required — a missing one fails onboarding's connect check.
|
||||
|
||||
---
|
||||
|
||||
## What the client SENDS (match these exactly)
|
||||
|
||||
- `GET /stories` query params: **`limit`** (int, default 20), **`after`** (cursor),
|
||||
**`topic`** (slug), **`min_signal`** (int, **snake_case** — read this literal name).
|
||||
The app currently always sends `limit=20`, sends `topic` only when a pill ≠ All is
|
||||
selected, and does **not** currently send `min_signal` (but support it).
|
||||
- `POST /feeds` JSON body: **`{"url": ..., "name": ..., "pollIntervalSeconds": ...}`**
|
||||
(the app currently always sends `pollIntervalSeconds: 900`). Respond **201** + the Feed.
|
||||
- `DELETE /feeds/{id}` → must be **HTTP 204**, empty body. The client explicitly checks
|
||||
`statusCode == 204` and treats anything else as a failure (it rolls back the deletion).
|
||||
|
||||
---
|
||||
|
||||
## WebSocket — minimum fields per event
|
||||
|
||||
The client ignores events that lack the fields it needs (no error, just no effect).
|
||||
|
||||
| Event `type` | Client requires | Effect |
|
||||
|---|---|---|
|
||||
| `story.updated` | **`storyId` + `signalScore` + `sourceCount`** (all three) | patches the row in place, re-sorts by score. Missing any → ignored. |
|
||||
| `story.created` | `type` only | triggers a full `GET /stories` refetch |
|
||||
| `story.stale` | `storyId` | removes that story from the feed |
|
||||
| `feed.health` | `feedId` + `health` (valid enum) | updates the feed's health dot; `failureCount` used if present |
|
||||
| `ping` | — | client auto-replies `{"type":"pong"}`; **server should send ping every 30s** |
|
||||
|
||||
WS date fields (`updatedAt`/`createdAt`) are currently **not used** by the client after
|
||||
the in-place patch, so they're not load-bearing — but if you send them, still use `...Z`.
|
||||
|
||||
---
|
||||
|
||||
## RSS → iOS: what comes from the feed vs what you must MANUFACTURE
|
||||
|
||||
The app never sees an RSS field directly — it sees **Stories** (clusters) and
|
||||
**Articles**. RSS only fills the article layer; everything story-level is computed.
|
||||
|
||||
| iOS field | Source | Notes |
|
||||
|---|---|---|
|
||||
| `Article.headline` | RSS `title` | |
|
||||
| `Article.body` | RSS `content`/`description` | HTML-stripped; full text if you fetch the page |
|
||||
| `Article.publishedAt` | RSS `pubDate`/`published` | → UTC, fall back to now if absent |
|
||||
| `Article.author` | RSS `author`/`dc:creator` | nullable |
|
||||
| `Article.imageUrl` | RSS `media:content`/`enclosure`/first `<img>` | nullable |
|
||||
| `Article.sourceUrl` | RSS `link` | |
|
||||
| `Article.source` | the **feed's** name | |
|
||||
| `Article.id`, `storyId` | **you generate / assign** | storyId from clustering |
|
||||
| **`Story.headline`** | **computed** | pick canonical from the cluster |
|
||||
| **`Story.summary`** | **computed** | cross-source summary — must be non-null |
|
||||
| **`Story.topic`** | **computed** | classify into finance/tech/politics/africa |
|
||||
| **`signalScore` + breakdown** | **computed** | the 5-component rubric |
|
||||
| **`sourceCount`, `sources[]`, `timeline[]`** | **computed** | cluster aggregation |
|
||||
| **`consensus`, `conflict`** | **computed** | nullable |
|
||||
| **`isBreaking`** | **computed** | heuristic (earliest in cluster / recency / RSS flag) |
|
||||
|
||||
So plugging in real feeds = (1) parse entries → Articles, (2) **cluster** them into
|
||||
Stories, (3) **score + classify + summarize**, (4) shape into the exact objects above.
|
||||
Steps 2–3 are the whole product; RSS gives you almost none of it.
|
||||
|
||||
---
|
||||
|
||||
## GAPS / CORRECTIONS vs `BACKEND_HANDOFF.md`
|
||||
|
||||
1. **`sources[]` is REQUIRED on `GET /stories` list items** (not just the contract's
|
||||
nice-to-have). It is **absent** on `GET /stories/{id}` (which has `timeline` instead).
|
||||
Easy to forget because the two endpoints differ — omitting `sources` on the list
|
||||
endpoint makes the **entire home feed decode fail → blank screen**. Highest-risk item.
|
||||
2. **camelCase is NOT mandatory** — the handoff overstated this. `.convertFromSnakeCase`
|
||||
means snake_case decodes fine too. The real hard requirement is the **date format**,
|
||||
not the casing. (Still: pick one and be consistent; camelCase matches the contract.)
|
||||
3. **One bad object fails the whole response, not just that object.** Stricter than the
|
||||
handoff's "drops the object." Per-element validity matters — especially the `health`
|
||||
enum and any required field being null.
|
||||
4. **`summary` must be non-null** on both story endpoints. RSS has per-article
|
||||
descriptions, but the *story* summary is synthesized — don't leave it null.
|
||||
5. **`health` must be exactly `active`/`failing`/`dead`.** A stray value (e.g. `"error"`,
|
||||
`"ok"`, `"unknown"`) fails the whole `/feeds` array → empty feed manager.
|
||||
6. **`min_signal` is snake_case** in the query string even though responses are camelCase
|
||||
— read the literal param name `min_signal`.
|
||||
7. Numeric fields must be **JSON numbers**, not strings (`"signalScore": 91`, not `"91"`).
|
||||
|
||||
---
|
||||
|
||||
## Fastest way to de-risk before the app ever connects
|
||||
|
||||
Decode-test each endpoint with Swift's exact rules. A 5-line check that mirrors
|
||||
`.iso8601`: every date must match `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`. Then assert
|
||||
`sources` exists on `/stories` items, `timeline` on `/stories/{id}`, and that no
|
||||
required field is null. (The pre-handover checklist §A/§B has runnable versions.)
|
||||
If those pass, the app will populate on first connect.
|
||||
147
docs/KICKOFF_PROMPT.md
Normal file
147
docs/KICKOFF_PROMPT.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Jarvis iOS — Claude Code Kickoff Prompt
|
||||
|
||||
Paste this entire prompt into Claude Code to begin.
|
||||
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
You are building **Jarvis** — a SwiftUI iOS app that connects to a self-hosted RSS news correlation platform and displays stories ranked by signal score.
|
||||
|
||||
Read `CLAUDE.md` before writing a single line of code. It contains every architecture decision already made. Do not deviate from it.
|
||||
|
||||
---
|
||||
|
||||
## Your job
|
||||
|
||||
Build the remaining SwiftUI views. The networking layer, models, stores, and onboarding screen are already written. You are building the UI on top of them.
|
||||
|
||||
### Views to build
|
||||
|
||||
1. **SignalFeedView** — the home screen
|
||||
- Wordmark `Jarvis` with `J` in #FF5C00, rest white, weight 800, kerning -2
|
||||
- Live/Offline connection indicator (driven by `WebSocketManager.shared.connectionState`)
|
||||
- Sync bar: "Synced X min ago · N cached" when live, "Last synced X min ago" when offline
|
||||
- Topic filter pills: All · Finance · Tech · Politics · Africa (calls `store.setTopic()`)
|
||||
- Column headers: Story / Signal ↓
|
||||
- List of `StoryRowView` sorted by `signalScore` descending
|
||||
- Pull to refresh calls `store.loadStories(refresh: true)`
|
||||
- Infinite scroll: call `store.loadMore()` when last row appears
|
||||
- Tap row → navigate to `StoryDetailView`
|
||||
|
||||
2. **StoryRowView** — one row in the signal feed
|
||||
- Left edge stripe: 3pt wide, color opacity tied to signal score (97 = #FF5C00, fades to #1a1a1a at 0)
|
||||
- Story title, dimmed proportionally to score (97 = white, 20 = #444)
|
||||
- Signal score number, same fade behaviour
|
||||
- Source count, topic, time ago in monospaced font
|
||||
- Source chips (first 2 named, rest "+N")
|
||||
- Small green dot if story is fully cached offline
|
||||
|
||||
3. **StoryDetailView** — full story
|
||||
- Back button "Signal feed"
|
||||
- Category label + signal score pill (e.g. "Finance · 97 signal")
|
||||
- Large headline, weight 800
|
||||
- Source chips
|
||||
- "All N articles cached · available offline" line if cached
|
||||
- Consensus block: orange left border, dark orange background
|
||||
- Conflicting block: dark red left border, dark red background (only if `conflict != nil`)
|
||||
- Coverage timeline: chronological list of `TimelineEntry`, first item has orange node
|
||||
- Tapping a timeline entry → navigate to `ArticleReaderView`
|
||||
|
||||
4. **ArticleReaderView** — full article
|
||||
- Back button showing parent story headline (truncated to 30 chars)
|
||||
- Source pill in #FF5C00, timestamp in monospaced
|
||||
- Cached badge inline (green, only if article is in SwiftData)
|
||||
- Large headline weight 800
|
||||
- Hero image placeholder (AsyncImage with grey fallback)
|
||||
- Body text, #4a4a4a, size 16, line spacing 1.65
|
||||
- Divider then "More from this cluster" section
|
||||
- 2–3 other articles from same story as tappable rows
|
||||
|
||||
5. **FeedManagerView** — feed health and management
|
||||
- Wordmark `Jarvis` header + "+ Add feed" button
|
||||
- Platform connection card: server URL, live/offline dot (from `WebSocketManager`)
|
||||
- Search bar to filter feed list
|
||||
- Two sections: "Healthy" and "Needs attention"
|
||||
- Each row: RSS icon, feed name, poll interval + article count, health dot
|
||||
- Health dot: green (#2a5a2a) = active, amber (#AA6600) = failing with retry countdown, red (#5a1a1a) = dead
|
||||
- Swipe to delete calls `APIClient.shared.deleteFeed(id:)`
|
||||
- "+ Add feed" sheet: URL input + name field + confirm button
|
||||
|
||||
---
|
||||
|
||||
## Design rules — non-negotiable
|
||||
|
||||
- Background: `Color.black` / `#000000` everywhere
|
||||
- Accent: `Color("KisaniOrange")` = `#FF5C00`
|
||||
- Surface cards: `#111111`, `#1A1A1A`
|
||||
- All screens: `.preferredColorScheme(.dark)`
|
||||
- Font weight: 800 for headlines, 700 for labels, 400 for body — never use 600
|
||||
- Monospaced font for: timestamps, signal scores, server URLs, feed metadata
|
||||
- No gradients. No shadows. Flat surfaces only.
|
||||
- Minimum tap target: 44pt
|
||||
- Signal score stripe and text both fade: score 80–100 = full orange, 50–79 = dimmed orange, 20–49 = dark brown, 0–19 = near invisible
|
||||
|
||||
---
|
||||
|
||||
## Data flow rules — non-negotiable
|
||||
|
||||
- Views never call `APIClient` directly — always go through `StoryStore` or a dedicated `@StateObject` ViewModel
|
||||
- Signal scores are server-computed — never calculate or modify them in the UI
|
||||
- WebSocket events trigger REST refetches — the store handles this already
|
||||
- SwiftData (`CachedStory`, `CachedArticle`) is the offline source — query it when the server is unreachable
|
||||
- Use `@EnvironmentObject var store: StoryStore` in views that need stories
|
||||
- Use `@EnvironmentObject var ws: WebSocketManager` for connection state
|
||||
|
||||
---
|
||||
|
||||
## Xcode project setup
|
||||
|
||||
1. Create new Xcode project: iOS App, named **Jarvis**, Swift, SwiftUI
|
||||
2. Set minimum deployment target: **iOS 17.0** (required for SwiftData)
|
||||
3. Add all provided `.swift` files to the project
|
||||
4. In `Assets.xcassets` add a new Color Set named `KisaniOrange`:
|
||||
- Any / Dark: `#FF5C00`
|
||||
5. Add `Assets.xcassets` Color Set `AppBackground`: `#000000`
|
||||
6. The SwiftData model container is already configured in `JarvisApp.swift`
|
||||
|
||||
---
|
||||
|
||||
## File structure to produce
|
||||
|
||||
```
|
||||
Jarvis/
|
||||
Views/
|
||||
Home/
|
||||
SignalFeedView.swift
|
||||
StoryRowView.swift
|
||||
Story/
|
||||
StoryDetailView.swift
|
||||
Reader/
|
||||
ArticleReaderView.swift
|
||||
Feeds/
|
||||
FeedManagerView.swift
|
||||
AddFeedSheet.swift
|
||||
Shared/
|
||||
SignalStripe.swift ← reusable 3pt stripe component
|
||||
SourceChips.swift ← reusable chips row
|
||||
CachedBadge.swift ← reusable green cached indicator
|
||||
ConnectionBanner.swift ← offline warning banner
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Start here
|
||||
|
||||
1. Read `CLAUDE.md`
|
||||
2. Create the Xcode project and add all existing files
|
||||
3. Build `SignalStripe.swift`, `SourceChips.swift`, `CachedBadge.swift` — shared components used by multiple views
|
||||
4. Build `StoryRowView.swift`
|
||||
5. Build `SignalFeedView.swift`
|
||||
6. Build `StoryDetailView.swift`
|
||||
7. Build `ArticleReaderView.swift`
|
||||
8. Build `FeedManagerView.swift` + `AddFeedSheet.swift`
|
||||
9. Run on simulator, fix layout issues, verify dark mode renders correctly
|
||||
10. Confirm WebSocket connection state drives the live/offline indicator on the home screen
|
||||
|
||||
Do not skip steps. Do not simplify the signal stripe fade logic. Do not use placeholder colours — every hex value is specified above.
|
||||
336
docs/backend/BACKEND_HANDOFF.md
Normal file
336
docs/backend/BACKEND_HANDOFF.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# Jarvis Backend — Build & Deploy Handoff (for Codex on Proxmox)
|
||||
|
||||
You are building the **server side** of Jarvis: a self-hosted RSS news-correlation
|
||||
platform. A finished SwiftUI iOS client already exists and will connect to you
|
||||
**unmodified** — so the API shapes below are not suggestions, they are a contract.
|
||||
The canonical spec is `API/contract.md` in this repo; this document adds the
|
||||
implementation plan, the algorithms, the client-compat gotchas, and the Proxmox
|
||||
deployment.
|
||||
|
||||
**Stack (already decided):** Python 3.11+ · FastAPI · Uvicorn · SQLite (via SQLModel) ·
|
||||
scikit-learn TF-IDF for correlation · rule-based scoring. No external API keys.
|
||||
No auth (local network / self-hosted only).
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR of what to build
|
||||
|
||||
A single FastAPI app that does four jobs:
|
||||
|
||||
1. **Ingest** — poll RSS feeds on a schedule, parse articles, track feed health.
|
||||
2. **Correlate** — cluster articles from different sources into *Stories* (TF-IDF + cosine).
|
||||
3. **Score** — compute a server-side `signalScore` (5 components) + topic + consensus/conflict.
|
||||
4. **Serve** — the REST API under `/api/v1` and a WebSocket at `/ws`, broadcasting live events.
|
||||
|
||||
Then deploy it as a systemd service inside a Proxmox **LXC container**, bound to
|
||||
`0.0.0.0:8080`, reachable from the iPhone on the LAN.
|
||||
|
||||
---
|
||||
|
||||
## 1. NON-NEGOTIABLE client-compatibility rules
|
||||
|
||||
The iOS client decodes JSON with `keyDecodingStrategy = .convertFromSnakeCase`
|
||||
and `dateDecodingStrategy = .iso8601`, and it **silently drops any object that
|
||||
fails to decode**. So:
|
||||
|
||||
1. **Dates must be exactly `YYYY-MM-DDTHH:MM:SSZ`** — UTC, the literal `Z`, and
|
||||
**no fractional seconds**. Pydantic's default (`...+00:00` with microseconds)
|
||||
will FAIL to decode and stories will vanish. Force the format with a serializer
|
||||
(see §4).
|
||||
2. **JSON keys: camelCase** (`signalScore`, `sourceCount`, `pollIntervalSeconds`,
|
||||
`publishedAt`, `nextCursor`, `hasMore`, …). FastAPI: set field aliases via
|
||||
`alias_generator=to_camel`, serialize with `response_model_by_alias=True` (default).
|
||||
3. **Exact query-param names** the client sends to `GET /stories`:
|
||||
`limit`, `after` (the cursor), `topic`, `min_signal`. Read those names verbatim.
|
||||
4. **`DELETE /feeds/:id` must return HTTP 204** (no body). The client checks for 204.
|
||||
5. **`POST /feeds` returns 201** with the created feed object. The client sends
|
||||
`{"url": ..., "name": ..., "pollIntervalSeconds": ...}` — accept camelCase.
|
||||
6. **Base URL is `http://<host>/api/v1`** and **WebSocket is `ws://<host>/ws`** —
|
||||
the client builds these strings literally, so REST lives under `/api/v1` and the
|
||||
socket at the root path `/ws`. Plain `http`/`ws` (not TLS) is expected.
|
||||
7. **No CORS needed** (native app, not a browser), but harmless to enable `*`.
|
||||
8. Error responses use `{"error": {"code", "message", "status"}}` (see contract §Error).
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints (all under `/api/v1` unless noted)
|
||||
|
||||
Full example payloads are in `API/contract.md`. Summary:
|
||||
|
||||
| Method | Path | Returns | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/health` | `{status, version, storiesCount, feedsCount, uptime}` | client calls on launch to validate connection |
|
||||
| GET | `/stories` | `{data[], nextCursor, hasMore, total}` | sorted by `signalScore` desc; params `limit`(≤100, def 20), `after`, `topic`, `min_signal` |
|
||||
| GET | `/stories/{id}` | `StoryDetail` (adds `timeline[]`) | 404 `story_not_found` if missing |
|
||||
| GET | `/articles/{id}` | `Article` (full body for offline cache) | 404 if missing |
|
||||
| GET | `/feeds` | `{data: [Feed]}` | all feeds + health |
|
||||
| POST | `/feeds` | `Feed` (201) | body `{url, name, pollIntervalSeconds?}`; 400 `invalid_url` if not a real feed |
|
||||
| DELETE | `/feeds/{id}` | 204 | 404 `feed_not_found` if missing |
|
||||
| WS | `/ws` (root, not `/api/v1`) | event stream | see §6 |
|
||||
|
||||
### Object shapes (camelCase, dates as `...Z`)
|
||||
|
||||
- **StorySummary**: `id, headline, summary, topic, signalScore, scoreBreakdown,
|
||||
sourceCount, sources[], consensus, conflict, updatedAt, createdAt`
|
||||
- **scoreBreakdown**: `{sourceAuthority, freshness, localRelevance,
|
||||
crossSourceConfirmation, topicImportance}` (ints, sum == `signalScore`)
|
||||
- **StorySource**: `{id, name, url, publishedAt, isBreaking}`
|
||||
- **StoryDetail**: StorySummary fields **minus `sources`**, **plus** `timeline[]`
|
||||
- **TimelineEntry**: `{articleId, source, headline, publishedAt, isBreaking}`
|
||||
- **Article**: `{id, storyId, source, sourceUrl, headline, body, imageUrl, author, publishedAt}`
|
||||
- **Feed**: `{id, name, url, health, pollIntervalSeconds, failureCount, lastFetchedAt, articleCountToday}`
|
||||
- `health ∈ {"active","failing","dead"}`
|
||||
- **topic** slugs used by the app's filter pills: `finance`, `tech`, `politics`, `africa`
|
||||
(classify everything into one of these; fall back to the best match).
|
||||
|
||||
---
|
||||
|
||||
## 3. Suggested project layout
|
||||
|
||||
```
|
||||
server/
|
||||
app/
|
||||
main.py # FastAPI app, lifespan: init DB, seed, start poller
|
||||
config.py # tunables: thresholds, lexicons, source-authority map
|
||||
db.py # SQLModel engine/session, init_db()
|
||||
models.py # Feed, Article, Story (SQLModel tables)
|
||||
schemas.py # Pydantic response models (camelCase aliases + Z dates)
|
||||
ingest.py # RSS poll + parse + feed-health state machine
|
||||
correlate.py # TF-IDF clustering: assign articles -> stories
|
||||
scoring.py # signal-score components, topic classify, consensus/conflict
|
||||
events.py # WebSocket ConnectionManager + broadcast + 30s ping loop
|
||||
scheduler.py # asyncio loop: poll due feeds, correlate, score, sweep stale
|
||||
routers/
|
||||
stories.py articles.py feeds.py health.py ws.py
|
||||
seed_feeds.py # default feed list inserted on first run
|
||||
requirements.txt # already in this folder
|
||||
jarvis.service # systemd unit (see §8)
|
||||
README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Data model (SQLite via SQLModel)
|
||||
|
||||
- **Feed**: `id (pk, "feed_"+short uuid)`, `name`, `url`, `health`,
|
||||
`poll_interval_seconds`, `failure_count`, `last_fetched_at (nullable)`, `created_at`.
|
||||
`articleCountToday` is **computed** at query time (count of this feed's articles
|
||||
with `created_at >= today 00:00 UTC`).
|
||||
- **Article**: `id ("art_"+uuid)`, `feed_id (fk)`, `story_id (fk, nullable)`,
|
||||
`source` (feed name), `source_url`, `headline`, `body`, `image_url (nullable)`,
|
||||
`author (nullable)`, `published_at`, `guid (unique — dedup key)`, `created_at`,
|
||||
`match_text` (lowercased headline + body, for TF-IDF).
|
||||
- **Story**: `id ("story_"+uuid)`, `headline`, `summary`, `topic`, `signal_score`,
|
||||
the 5 breakdown ints, `consensus (nullable)`, `conflict (nullable)`,
|
||||
`source_count`, `is_stale (bool)`, `created_at`, `updated_at`.
|
||||
|
||||
Store **all datetimes as naive UTC**. Serialize with this exact helper so the
|
||||
client's `.iso8601` accepts them:
|
||||
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import field_serializer
|
||||
|
||||
def iso_z(dt: datetime) -> str:
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%SZ") # NO microseconds, literal Z
|
||||
```
|
||||
|
||||
Apply via `@field_serializer("publishedAt","updatedAt","createdAt","lastFetchedAt")`
|
||||
returning `iso_z(value)` (return `None` for null `lastFetchedAt`).
|
||||
|
||||
Camel aliases:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
class Schema(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. The algorithms
|
||||
|
||||
### 5a. Ingest (`ingest.py`)
|
||||
- Fetch each due feed with `httpx.AsyncClient` (timeout ~10s), parse bytes with
|
||||
`feedparser` (run in `asyncio.to_thread`, it's blocking).
|
||||
- For each entry: dedup by `guid` (entry.id or link). Extract `headline` (title),
|
||||
`body` (richest of `content[].value` / `summary`, HTML-stripped with BeautifulSoup),
|
||||
`image_url` (media:content / enclosure / first `<img>`), `author`, `published_at`
|
||||
(`entry.published_parsed` → UTC; fall back to now).
|
||||
- **Feed-health state machine** on each poll:
|
||||
- success → `health="active"`, `failure_count=0`, `last_fetched_at=now`.
|
||||
- failure → `failure_count += 1`; `failing` at ≥1, `dead` at ≥5 (tunable).
|
||||
- On any health **change**, broadcast a `feed.health` WS event.
|
||||
|
||||
### 5b. Correlate (`correlate.py`)
|
||||
Goal: same real-world event across sources → one Story; stable Story `id`s.
|
||||
- Consider only articles from a rolling window (e.g. last **72h**).
|
||||
- Single `TfidfVectorizer` fit per cycle over (existing recent stories' aggregate
|
||||
text + the new unclustered articles' `match_text`). English stopwords, `ngram_range=(1,2)`.
|
||||
- **Greedy online assignment** per new article (process oldest→newest):
|
||||
- cosine-compare against current cluster representatives;
|
||||
- if `max_sim >= 0.22` (tunable) **and** within the time window → join that story;
|
||||
- else → create a **new** Story seeded by this article (append its vector as a new rep).
|
||||
- After assignment, recompute each touched story's aggregates: `source_count`
|
||||
(distinct feeds), canonical `headline`/`summary` (take the highest-authority or
|
||||
earliest article), then re-score (§5c). Track which stories were **created** vs
|
||||
**updated** to emit the right WS event.
|
||||
|
||||
### 5c. Score (`scoring.py`) — rule-based, must sum to `signalScore`
|
||||
Component caps chosen so totals land like the contract example (91 = 28+22+15+16+10):
|
||||
|
||||
| Component | Range | Rule |
|
||||
|---|---|---|
|
||||
| `sourceAuthority` | 0–30 | sum of per-source authority weights (default 6; majors higher via config map), capped 30 |
|
||||
| `freshness` | 0–25 | `round(25 * max(0, 1 - age_hours_of_newest / 48))` |
|
||||
| `localRelevance` | 0–15 | hits against a local lexicon (place names, currency, institutions) scaled, capped 15 |
|
||||
| `crossSourceConfirmation` | 0–20 | `min(20, (distinct_sources - 1) * 5)` |
|
||||
| `topicImportance` | 0–10 | per-topic map: politics/finance=10, africa=8, tech=7, else 5 |
|
||||
|
||||
`signalScore = clamp(sum, 0, 100)`.
|
||||
|
||||
- **Topic classification**: keyword lexicons per `{finance, tech, politics, africa}`;
|
||||
pick the highest-scoring; tie-break to `africa`. Keep lexicons in `config.py`.
|
||||
- **Consensus** (set when ≥2 sources): e.g. `"All N sources confirm the report.
|
||||
{SourceA} and {SourceB} agree."` — template is fine for v1.
|
||||
- **Conflict** (nullable, default null): v1 heuristic — set a generic line only if a
|
||||
headline contains dispute markers (`deny`, `dispute`, `contradict`, `reject`),
|
||||
else `null`. (Leave a TODO to upgrade with NLP/LLM later.)
|
||||
|
||||
### 5d. Staleness sweep (`scheduler.py`)
|
||||
- Periodically (e.g. every 5 min), any non-stale Story with **no new article in 24h**
|
||||
→ set `is_stale=True`, broadcast `story.stale`. **Exclude stale stories** from
|
||||
`GET /stories` by default.
|
||||
|
||||
### 5e. Pagination
|
||||
Keyset cursor over `(signal_score DESC, id DESC)`. `nextCursor` = base64 of
|
||||
`"{signal_score}:{id}"` of the last row; on `after`, decode and filter
|
||||
`(signal_score < s) OR (signal_score == s AND id < id)`. `hasMore` = more rows exist;
|
||||
`total` = total non-stale stories (optionally filtered by topic/min_signal).
|
||||
|
||||
---
|
||||
|
||||
## 6. WebSocket `/ws`
|
||||
|
||||
On connect: `accept()`, register the socket. The server emits these (client refetches
|
||||
full objects via REST as needed):
|
||||
|
||||
```jsonc
|
||||
{"type":"story.updated","storyId":"story_x","signalScore":94,"sourceCount":9,"updatedAt":"...Z"}
|
||||
{"type":"story.created","storyId":"story_y","headline":"...","signalScore":42,"topic":"politics","createdAt":"...Z"}
|
||||
{"type":"story.stale","storyId":"story_z","updatedAt":"...Z"}
|
||||
{"type":"feed.health","feedId":"feed_a","health":"failing","failureCount":3,"updatedAt":"...Z"}
|
||||
```
|
||||
|
||||
- Send `{"type":"ping"}` every **30s**; the client replies `{"type":"pong"}` (you can
|
||||
ignore it). The client ALSO sends WebSocket-protocol pings — Uvicorn/websockets
|
||||
auto-answers those, no action needed.
|
||||
- Keep a `ConnectionManager` with `broadcast(dict)`; the ingest/correlate/scoring code
|
||||
calls it. Drop dead sockets on send failure.
|
||||
- Reconnect/backoff is handled entirely client-side — just accept new connections.
|
||||
|
||||
---
|
||||
|
||||
## 7. Proxmox deployment (recommended: unprivileged LXC)
|
||||
|
||||
A Python web service is a perfect fit for an **LXC container** (lighter than a VM).
|
||||
|
||||
1. **Create the container** (Proxmox shell or UI):
|
||||
```bash
|
||||
# Debian 12 template, on the Proxmox host:
|
||||
pveam update && pveam available | grep debian-12
|
||||
pveam download local debian-12-standard_*_amd64.tar.zst
|
||||
pct create 140 local:vztmpl/debian-12-standard_*_amd64.tar.zst \
|
||||
--hostname jarvis-api --cores 2 --memory 1024 --swap 512 \
|
||||
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
|
||||
--rootfs local-lvm:8 --unprivileged 1 --features nesting=1
|
||||
pct start 140 && pct enter 140
|
||||
```
|
||||
Note the container's DHCP IP (`ip a`) — that's the host the iPhone points at. A
|
||||
**static IP / DHCP reservation** is recommended so the app's saved host stays valid.
|
||||
|
||||
2. **Inside the container:**
|
||||
```bash
|
||||
apt update && apt install -y python3 python3-venv python3-pip git
|
||||
adduser --system --group jarvis
|
||||
mkdir -p /opt/jarvis && chown jarvis:jarvis /opt/jarvis
|
||||
# copy the server/ folder here (git clone, scp, or pct push)
|
||||
cd /opt/jarvis
|
||||
python3 -m venv .venv && . .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **systemd service** (`/etc/systemd/system/jarvis.service`):
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Jarvis news-correlation API
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=jarvis
|
||||
WorkingDirectory=/opt/jarvis
|
||||
ExecStart=/opt/jarvis/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
Environment=JARVIS_DB=/opt/jarvis/jarvis.db
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
```bash
|
||||
systemctl daemon-reload && systemctl enable --now jarvis
|
||||
systemctl status jarvis
|
||||
```
|
||||
|
||||
4. **Verify from the Proxmox host or your Mac (same LAN):**
|
||||
```bash
|
||||
curl http://<container-ip>:8080/api/v1/health
|
||||
curl http://<container-ip>:8080/api/v1/stories | head
|
||||
```
|
||||
|
||||
5. **Point the app at it:** launch Jarvis on the iPhone/sim → onboarding → enter
|
||||
`http://<container-ip>:8080` **as `<container-ip>:8080`** (the app prepends
|
||||
`http://` and `ws://` itself — enter just `host:port`, e.g. `192.168.30.50:8080`).
|
||||
|
||||
> Networking notes: bind `0.0.0.0` (not `127.0.0.1`) so the LAN can reach it. If the
|
||||
> Proxmox host or container runs a firewall, allow TCP **8080**. No TLS/auth by design —
|
||||
> keep this on a trusted VLAN or behind the homelab firewall/VPN, per the contract.
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance checklist (done = the app works end-to-end)
|
||||
|
||||
- [ ] `GET /api/v1/health` returns ok JSON; onboarding "Connect" succeeds.
|
||||
- [ ] `GET /api/v1/stories` returns camelCase, `...Z` dates, sorted by `signalScore` desc.
|
||||
- [ ] Stories actually cluster multi-source articles (not 1 story per article).
|
||||
- [ ] `scoreBreakdown` ints sum exactly to `signalScore`.
|
||||
- [ ] `GET /stories/{id}` includes a chronological `timeline[]`.
|
||||
- [ ] `GET /articles/{id}` returns full `body` (offline reader works).
|
||||
- [ ] `GET /feeds` shows health states; `POST /feeds` (201) and `DELETE` (204) work.
|
||||
- [ ] WebSocket `/ws` connects; the app's header shows **LIVE**; a score change pushes
|
||||
`story.updated` and the feed reorders.
|
||||
- [ ] Runs as a systemd service in the Proxmox LXC and survives a container reboot.
|
||||
|
||||
---
|
||||
|
||||
## 9. Hand-back
|
||||
|
||||
The iOS client is in `../Jarvis` (SwiftUI, builds with `xcodegen` + Xcode 26 / iOS 17).
|
||||
While wiring the frontend I had to fix three bugs in the provided client layer that
|
||||
also document real expectations of your server — worth knowing:
|
||||
- `StoryStore` patches stories in-place on `story.updated` using `signalScore` +
|
||||
`sourceCount` from the event (so those two fields are required on that event).
|
||||
- The client treats **HTTP `http://`** and **`ws://`** (no TLS).
|
||||
- On relaunch it reconnects REST+WS from the saved host, then immediately calls
|
||||
`GET /stories` — so the server should be ready to serve quickly after boot.
|
||||
|
||||
Questions for whoever runs this: which RSS feeds to seed by default, and the local
|
||||
lexicon (which country/region is "local" for `localRelevance`)? The contract examples
|
||||
are Uganda/East-Africa centric (Daily Monitor, NilePost, BoU) — default `seed_feeds.py`
|
||||
and the local lexicon to that unless told otherwise.
|
||||
```
|
||||
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.
|
||||
8
docs/backend/requirements.txt
Normal file
8
docs/backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlmodel==0.0.22
|
||||
feedparser==6.0.11
|
||||
httpx==0.28.1
|
||||
scikit-learn==1.6.1
|
||||
numpy==2.2.1
|
||||
beautifulsoup4==4.12.3
|
||||
Reference in New Issue
Block a user