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

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