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 headline: String
let summary: String let summary: String
let topic: 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 signalScore: Int
let scoreBreakdown: ScoreBreakdown let scoreBreakdown: ScoreBreakdown
let sourceCount: Int let sourceCount: Int

View File

@@ -129,6 +129,7 @@ final class StoryStore: ObservableObject {
headline: old.headline, headline: old.headline,
summary: old.summary, summary: old.summary,
topic: old.topic, topic: old.topic,
tags: old.tags,
signalScore: signalScore, signalScore: signalScore,
scoreBreakdown: old.scoreBreakdown, scoreBreakdown: old.scoreBreakdown,
sourceCount: sourceCount, sourceCount: sourceCount,

View File

@@ -28,7 +28,7 @@ struct SignalFeedView: View {
@Query private var seenStories: [SeenStory] @Query private var seenStories: [SeenStory]
@State private var showFeeds = false @State private var showFeeds = false
@State private var showSettings = false @State private var showSettings = false
@State private var selectedInterest: Interest? = nil @State private var selectedPill: StoryPill = .all
@State private var shareStory: StorySummary? @State private var shareStory: StorySummary?
/// Snapshot of already-seen story ids, captured per load so the order stays /// Snapshot of already-seen story ids, captured per load so the order stays
/// stable while scrolling (and refreshes to sink newly-seen ones). /// 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 readStoryIds: Set<String> { Set(readStories.map(\.id)) }
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) } private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
/// Live stories when online; cached fallback when offline. Interest-filtered, /// Live stories when online; cached fallback when offline. Filtered by the
/// not yet split by read state. /// selected pill via backend tags no headline keyword matching.
private var filteredStories: [StorySummary] { private var filteredStories: [StorySummary] {
let base: [StorySummary] let base: [StorySummary]
if !store.stories.isEmpty { if !store.stories.isEmpty {
@@ -51,17 +51,7 @@ struct SignalFeedView: View {
} else { } else {
base = [] base = []
} }
// Sports (incl. F1) live only under their own pills the backend mis-tags return base.filter { $0.matches(selectedPill) }
// 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) }
} }
/// The main feed: unread stories, with already-seen ones sunk below fresh ones. /// 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 .onChange(of: store.stories) { _, newValue in
cacheStories(newValue) cacheStories(newValue)
} }
.task(id: selectedInterest?.id) { .task(id: selectedPill) {
// A filter active but nothing matched in the loaded set pull a few // A filter active but nothing matched in the loaded set pull a few
// more pages so sparse interests still populate. // more pages so sparse pills still populate.
guard selectedInterest != nil else { return } guard selectedPill != .all else { return }
var pages = 0 var pages = 0
while filteredStories.isEmpty && store.hasMore && pages < 8 { while filteredStories.isEmpty && store.hasMore && pages < 8 {
await store.loadMore() await store.loadMore()
@@ -191,16 +181,13 @@ struct SignalFeedView: View {
} }
} }
// MARK: - Pinned interest pills // MARK: - Pills
private var topicPills: some View { private var topicPills: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
pill("All", selected: selectedInterest == nil) { selectedInterest = nil } ForEach(StoryPill.allCases) { p in
ForEach(Interest.pinned) { interest in pill(p.label, selected: selectedPill == p) { selectedPill = p }
pill(interest.label, selected: selectedInterest?.id == interest.id) {
selectedInterest = (selectedInterest?.id == interest.id) ? nil : interest
}
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -382,9 +369,9 @@ struct SignalFeedView: View {
Text("No cached stories to show") Text("No cached stories to show")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) .font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Retry") refreshButton("Retry")
} else if let interest = selectedInterest { } else if selectedPill != .all {
emptyIcon("line.3.horizontal.decrease.circle") 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) .font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
if store.isLoadingMore || store.hasMore { if store.isLoadingMore || store.hasMore {
Text("Pulling more to find them…") Text("Pulling more to find them…")
@@ -496,6 +483,7 @@ extension StorySummary {
headline: cached.headline, headline: cached.headline,
summary: cached.summary, summary: cached.summary,
topic: cached.topic, topic: cached.topic,
tags: nil,
signalScore: cached.signalScore, signalScore: cached.signalScore,
scoreBreakdown: ScoreBreakdown(sourceAuthority: 0, freshness: 0, localRelevance: 0, scoreBreakdown: ScoreBreakdown(sourceAuthority: 0, freshness: 0, localRelevance: 0,
crossSourceConfirmation: 0, topicImportance: 0), crossSourceConfirmation: 0, topicImportance: 0),

View File

@@ -117,7 +117,7 @@ struct StoryRowView: View {
StoryRowView( StoryRowView(
story: StorySummary( story: StorySummary(
id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points", 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, sourceCount: 7, sources: sources, consensus: nil, conflict: nil,
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()), updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()),
isCached: score == 64 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 // The backend classifies each story into canonical category slugs in `tags[]`.
// personal filters match keywords in the headline, summary, and source names of // The client filters by pill -> slug membership only; no headline keyword
// the loaded stories instead of calling the API. // matching. `topic` is a fallback while the backend finishes emitting `tags[]`.
struct Interest: Identifiable, Hashable { enum StoryPill: String, CaseIterable, Identifiable {
let id: String case all = "All"
let label: String case f1 = "F1"
var keywords: [String] = [] case sport = "Sport"
/// If any of these appear, the story is NOT this interest used to keep the case ai = "AI"
/// region pills exclusive (East Africa excludes Uganda & South Africa). case cloud = "Cloud"
var excludeKeywords: [String] = [] case homelab = "HomeLab"
/// When set, matches the backend topic classification instead of keywords case tech = "Tech"
/// (e.g. finance, tech). case uganda = "Uganda"
var topicSlug: String? = nil case southAfrica = "South Africa"
func matches(_ s: StorySummary) -> Bool { case canada = "Canada"
if id == "f1" { return Interest.isFormula1(s) } case unitedStates = "US"
if id == "sport" { return Interest.isSports(s) }
if let topicSlug { var id: String { rawValue }
return s.topic.caseInsensitiveCompare(topicSlug) == .orderedSame 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 extension StorySummary {
/// source isn't enough the over-clustered backend sometimes attaches one to /// Backend `tags[]` are the source of truth; fall back to `topic` during the
/// an unrelated story. Avoids substring traps like "fia" matching "deFIAnt". /// transition while the backend finishes emitting tags.
static func isFormula1(_ s: StorySummary) -> Bool { var effectiveTags: Set<String> {
let h = s.headline.lowercased() let clean = Set((tags ?? []).filter { !$0.isEmpty })
if h.contains("formula 1") || h.contains("formula one") || h.contains("grand prix") return clean.isEmpty ? [topic] : clean
|| 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 func matches(_ pill: StoryPill) -> Bool {
/// sources. The backend mis-tags World Cup / soccer into finance & tech, so pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
/// 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"),
]
} }