Filter pills by backend tags (slug membership), drop keyword matching
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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
/// source isn't enough — the over-clustered backend sometimes attaches one to
|
extension StorySummary {
|
||||||
/// an unrelated story. Avoids substring traps like "fia" matching "deFIAnt".
|
/// Backend `tags[]` are the source of truth; fall back to `topic` during the
|
||||||
static func isFormula1(_ s: StorySummary) -> Bool {
|
/// transition while the backend finishes emitting tags.
|
||||||
let h = s.headline.lowercased()
|
var effectiveTags: Set<String> {
|
||||||
if h.contains("formula 1") || h.contains("formula one") || h.contains("grand prix")
|
let clean = Set((tags ?? []).filter { !$0.isEmpty })
|
||||||
|| h.hasPrefix("f1 ") || h.hasSuffix(" f1") || h.contains(" f1 ") || h.contains("f1:") {
|
return clean.isEmpty ? [topic] : clean
|
||||||
return true
|
}
|
||||||
}
|
|
||||||
let feeds = ["f1", "racefans", "motorsport", "autosport", "planetf1", "the race", "formula 1"]
|
func matches(_ pill: StoryPill) -> Bool {
|
||||||
let srcs = s.sources
|
pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
|
||||||
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"),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user