diff --git a/Jarvis/Models/Models.swift b/Jarvis/Models/Models.swift index a5b23f0..aa6fc2f 100644 --- a/Jarvis/Models/Models.swift +++ b/Jarvis/Models/Models.swift @@ -50,6 +50,9 @@ struct StorySummary: Codable, Identifiable, Hashable { let headline: String let summary: String let topic: String + /// Canonical category slugs from the backend. Optional so decoding tolerates + /// the transition while the backend rolls out `tags[]`; falls back to `topic`. + let tags: [String]? let signalScore: Int let scoreBreakdown: ScoreBreakdown let sourceCount: Int diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift index 8681431..7f8bb70 100644 --- a/Jarvis/Store/StoryStore.swift +++ b/Jarvis/Store/StoryStore.swift @@ -129,6 +129,7 @@ final class StoryStore: ObservableObject { headline: old.headline, summary: old.summary, topic: old.topic, + tags: old.tags, signalScore: signalScore, scoreBreakdown: old.scoreBreakdown, sourceCount: sourceCount, diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index fbc223e..68a639e 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -28,7 +28,7 @@ struct SignalFeedView: View { @Query private var seenStories: [SeenStory] @State private var showFeeds = false @State private var showSettings = false - @State private var selectedInterest: Interest? = nil + @State private var selectedPill: StoryPill = .all @State private var shareStory: StorySummary? /// Snapshot of already-seen story ids, captured per load so the order stays /// stable while scrolling (and refreshes to sink newly-seen ones). @@ -40,8 +40,8 @@ struct SignalFeedView: View { private var readStoryIds: Set { Set(readStories.map(\.id)) } private var savedStoryIds: Set { Set(savedStories.map(\.id)) } - /// Live stories when online; cached fallback when offline. Interest-filtered, - /// not yet split by read state. + /// Live stories when online; cached fallback when offline. Filtered by the + /// selected pill via backend tags — no headline keyword matching. private var filteredStories: [StorySummary] { let base: [StorySummary] if !store.stories.isEmpty { @@ -51,17 +51,7 @@ struct SignalFeedView: View { } else { base = [] } - // Sports (incl. F1) live only under their own pills — the backend mis-tags - // them into finance/tech/politics, so exclude them everywhere else. - let isSport: (StorySummary) -> Bool = { Interest.isFormula1($0) || Interest.isSports($0) } - if let interest = selectedInterest { - if interest.id == "f1" || interest.id == "sport" { - return base.filter(interest.matches) - } - return base.filter { interest.matches($0) && !isSport($0) } - } - // "All" is news-only: no sports. - return base.filter { !isSport($0) } + return base.filter { $0.matches(selectedPill) } } /// The main feed: unread stories, with already-seen ones sunk below fresh ones. @@ -130,10 +120,10 @@ struct SignalFeedView: View { .onChange(of: store.stories) { _, newValue in cacheStories(newValue) } - .task(id: selectedInterest?.id) { + .task(id: selectedPill) { // A filter active but nothing matched in the loaded set → pull a few - // more pages so sparse interests still populate. - guard selectedInterest != nil else { return } + // more pages so sparse pills still populate. + guard selectedPill != .all else { return } var pages = 0 while filteredStories.isEmpty && store.hasMore && pages < 8 { await store.loadMore() @@ -191,16 +181,13 @@ struct SignalFeedView: View { } } - // MARK: - Pinned interest pills + // MARK: - Pills private var topicPills: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - pill("All", selected: selectedInterest == nil) { selectedInterest = nil } - ForEach(Interest.pinned) { interest in - pill(interest.label, selected: selectedInterest?.id == interest.id) { - selectedInterest = (selectedInterest?.id == interest.id) ? nil : interest - } + ForEach(StoryPill.allCases) { p in + pill(p.label, selected: selectedPill == p) { selectedPill = p } } } .padding(.horizontal, 16) @@ -382,9 +369,9 @@ struct SignalFeedView: View { Text("No cached stories to show") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) refreshButton("Retry") - } else if let interest = selectedInterest { + } else if selectedPill != .all { emptyIcon("line.3.horizontal.decrease.circle") - Text("No \(interest.label) stories yet") + Text("No \(selectedPill.label) stories yet") .font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) if store.isLoadingMore || store.hasMore { Text("Pulling more to find them…") @@ -496,6 +483,7 @@ extension StorySummary { headline: cached.headline, summary: cached.summary, topic: cached.topic, + tags: nil, signalScore: cached.signalScore, scoreBreakdown: ScoreBreakdown(sourceAuthority: 0, freshness: 0, localRelevance: 0, crossSourceConfirmation: 0, topicImportance: 0), diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift index 4171f6b..187b809 100644 --- a/Jarvis/Views/Home/StoryRowView.swift +++ b/Jarvis/Views/Home/StoryRowView.swift @@ -117,7 +117,7 @@ struct StoryRowView: View { StoryRowView( story: StorySummary( id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points", - summary: "", topic: "finance", signalScore: score, scoreBreakdown: breakdown, + summary: "", topic: "finance", tags: ["finance"], signalScore: score, scoreBreakdown: breakdown, sourceCount: 7, sources: sources, consensus: nil, conflict: nil, updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()), isCached: score == 64 diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift index f8096e6..977bfdd 100644 --- a/Jarvis/Views/Shared/Theme.swift +++ b/Jarvis/Views/Shared/Theme.swift @@ -172,115 +172,56 @@ extension StorySummary { } } -// MARK: - Pinned interest filters (client-side) +// MARK: - Story pills (backend-tag driven) // -// The backend only classifies topics as finance/tech/politics/africa, so these -// personal filters match keywords in the headline, summary, and source names of -// the loaded stories instead of calling the API. +// The backend classifies each story into canonical category slugs in `tags[]`. +// The client filters by pill -> slug membership only; no headline keyword +// matching. `topic` is a fallback while the backend finishes emitting `tags[]`. -struct Interest: Identifiable, Hashable { - let id: String - let label: String - var keywords: [String] = [] - /// If any of these appear, the story is NOT this interest — used to keep the - /// region pills exclusive (East Africa excludes Uganda & South Africa). - var excludeKeywords: [String] = [] - /// When set, matches the backend topic classification instead of keywords - /// (e.g. finance, tech). - var topicSlug: String? = nil - func matches(_ s: StorySummary) -> Bool { - if id == "f1" { return Interest.isFormula1(s) } - if id == "sport" { return Interest.isSports(s) } - if let topicSlug { - return s.topic.caseInsensitiveCompare(topicSlug) == .orderedSame +enum StoryPill: String, CaseIterable, Identifiable { + case all = "All" + case f1 = "F1" + case sport = "Sport" + case ai = "AI" + case cloud = "Cloud" + case homelab = "HomeLab" + case tech = "Tech" + case uganda = "Uganda" + case southAfrica = "South Africa" + case canada = "Canada" + case unitedStates = "US" + + var id: String { rawValue } + var label: String { rawValue } + + var slugs: Set { + switch self { + case .all: return [] + case .f1: return ["formula-1"] + case .sport: return ["sports", "esports"] + case .ai: return ["artificial-intelligence", "machine-learning", "robotics"] + case .cloud: return ["cloud-computing"] + case .homelab: return ["homelab"] + case .tech: return ["technology", "programming-and-software-development", + "cybersecurity", "security", "privacy-and-data-protection", + "web-design-and-ui-ux", "wordpress-and-web-development"] + case .uganda: return ["uganda"] + case .southAfrica: return ["south-africa"] + case .canada: return ["canada"] + case .unitedStates: return ["united-states"] } - // Headline only — the backend's summaries/outlet names are polluted by - // over-clustering (a Congo headline can carry a South-Africa summary or an - // SA outlet), so they're unsafe to match on. - let hay = s.headline.lowercased() - if excludeKeywords.contains(where: { hay.contains($0) }) { return false } - return keywords.contains { hay.contains($0) } } - - /// F1 = a clear F1 headline, or a majority of F1-feed sources. A single F1 - /// source isn't enough — the over-clustered backend sometimes attaches one to - /// an unrelated story. Avoids substring traps like "fia" matching "deFIAnt". - static func isFormula1(_ s: StorySummary) -> Bool { - let h = s.headline.lowercased() - if h.contains("formula 1") || h.contains("formula one") || h.contains("grand prix") - || h.hasPrefix("f1 ") || h.hasSuffix(" f1") || h.contains(" f1 ") || h.contains("f1:") { - return true - } - let feeds = ["f1", "racefans", "motorsport", "autosport", "planetf1", "the race", "formula 1"] - let srcs = s.sources - guard !srcs.isEmpty else { return false } - let n = srcs.filter { src in feeds.contains { src.name.lowercased().contains($0) } }.count - return Double(n) / Double(srcs.count) >= 0.5 - } - - /// Sport (non-F1) = a clear sports headline, or a majority of sports-outlet - /// sources. The backend mis-tags World Cup / soccer into finance & tech, so - /// this pulls them out. F1 has its own pill and is excluded here. - static func isSports(_ s: StorySummary) -> Bool { - if isFormula1(s) { return false } - let h = s.headline.lowercased() - let terms = ["world cup", "fifa", "premier league", "la liga", "champions league", - "uefa", "ballon d'or", "knockout stage", "quarterfinal", "semifinal", - "nba ", "nfl ", "nhl ", "mlb ", "super bowl", "playoff", "wimbledon", - "grand slam", "olympic", "test match", "six nations", "messi", "ronaldo", - "striker", "midfielder", "goalkeeper", "world cup match"] - if terms.contains(where: { h.contains($0) }) { return true } - let feeds = ["the athletic", "fox sports", "sportsnet", "espn", "sky sports", - "bbc sport", "goal", "bleacher report", "sports illustrated", "sporting news"] - let srcs = s.sources - guard !srcs.isEmpty else { return false } - let n = srcs.filter { src in feeds.contains { src.name.lowercased().contains($0) } }.count - return Double(n) / Double(srcs.count) >= 0.5 - } - - // Region keywords are place/person names found in the story text — never - // outlet names (which are unreliable due to backend over-clustering). - private static let ugandaKeywords = ["uganda", "ugandan", "kampala", "museveni", - "entebbe", "jinja", "gulu"] - private static let southAfricaKeywords = ["south africa", "south african", "johannesburg", - "cape town", "pretoria", "durban", "ramaphosa", - "eskom", "gauteng", "western cape", "soweto", - "drakensberg", "mbalula", "zuma"] - private static let canadaKeywords = ["canada", "canadian", "toronto", "ottawa", "vancouver", - "montreal", "quebec", "alberta", "ontario", "manitoba", - "calgary", "winnipeg", "trudeau", "carney"] - private static let usKeywords = ["united states", "u.s.", "americans", "trump", "white house", - "washington", "congress", "pentagon", "biden", "kamala", - "harris", "wall street", "new york", "california", "texas", - "florida", "supreme court", "senate", "capitol"] - // Content topics — strong, unambiguous terms (no bare "ai"/"aws" substring traps). - private static let aiKeywords = ["artificial intelligence", "machine learning", "deep learning", - "generative ai", "chatgpt", "openai", "anthropic", "claude", - "llm", "gpt", "gemini", "hugging face", "nvidia", "copilot", - "agentic", "midjourney", "a.i."] - private static let cloudKeywords = ["cloud", "azure", "google cloud", "kubernetes", "docker", - "serverless", "cloudflare", "data center", "datacenter", - "devops", "openstack", "terraform"] - private static let homelabKeywords = ["homelab", "home lab", "self-host", "self-hosted", - "selfhosted", "proxmox", "raspberry pi", "truenas", - "unraid", "synology", "jellyfin", "home server", "nas ", - "home assistant", "docker compose", "tailscale", "k3s", - "mini pc"] - - static let f1 = Interest(id: "f1", label: "F1") // matching via isFormula1 - static let sport = Interest(id: "sport", label: "Sport") // matching via isSports - - /// Pinned filter pills, in order. - static let pinned: [Interest] = [ - f1, - sport, - Interest(id: "uganda", label: "Uganda", keywords: ugandaKeywords), - Interest(id: "south-africa", label: "South Africa", keywords: southAfricaKeywords), - Interest(id: "canada", label: "Canada", keywords: canadaKeywords), - Interest(id: "us", label: "US", keywords: usKeywords), - Interest(id: "ai", label: "AI", keywords: aiKeywords), - Interest(id: "cloud", label: "Cloud", keywords: cloudKeywords), - Interest(id: "homelab", label: "HomeLab", keywords: homelabKeywords), - Interest(id: "tech", label: "Tech", topicSlug: "tech"), - ] +} + +extension StorySummary { + /// Backend `tags[]` are the source of truth; fall back to `topic` during the + /// transition while the backend finishes emitting tags. + var effectiveTags: Set { + let clean = Set((tags ?? []).filter { !$0.isEmpty }) + return clean.isEmpty ? [topic] : clean + } + + func matches(_ pill: StoryPill) -> Bool { + pill == .all || !effectiveTags.isDisjoint(with: pill.slugs) + } }