Filter pills by backend tags (slug membership), drop keyword matching
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

Replace the keyword/source-majority Interest system with a StoryPill enum that
matches stories by canonical category slugs. StorySummary gains a tolerant
tags[] (optional, decode-safe); effectiveTags uses tags, falling back to topic
during the backend transition. No more headline keyword-searching on the client —
the backend decides classification; the client asks slug membership.

Pills: All, F1, Sport, AI, Cloud, HomeLab, Tech, Uganda, South Africa, Canada, US.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-19 21:55:57 +03:00
parent 5c0f4afd80
commit 7283fd63ed
5 changed files with 66 additions and 133 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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<String> { Set(readStories.map(\.id)) }
private var savedStoryIds: Set<String> { 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),

View File

@@ -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

View File

@@ -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<String> {
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
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<String> {
let clean = Set((tags ?? []).filter { !$0.isEmpty })
return clean.isEmpty ? [topic] : clean
}
/// 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
func matches(_ pill: StoryPill) -> Bool {
pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
}
// 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"),
]
}