Add feed summary preview and pinned interest filters

- Show a 6-line cross-source summary preview in each feed row.
- Pinned, mutually-exclusive filter pills: All, F1, Uganda, East Africa,
  South Africa, Finance, Tech. "All" is news-only (F1 lives only under F1);
  regions don't overlap (East Africa excludes Uganda & South Africa).
- Categorization correctness: regions match the headline only (the backend's
  summaries/outlets are polluted by over-clustering), and F1 takes precedence
  over every other pill since the backend mis-tags F1 as tech/finance/politics.
- Finance/Tech match the backend topic; sparse filters auto-pull more pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-19 01:00:39 +03:00
parent 9662c2abf6
commit 87bfb234c2
3 changed files with 132 additions and 19 deletions

View File

@@ -171,3 +171,69 @@ extension StorySummary {
a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id
}
}
// MARK: - Pinned interest filters (client-side)
//
// 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.
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
/// Whether to consider source/outlet names when matching. True only for F1
/// (its sources are the signal). Regions must NOT an outlet's location is
/// not the story's location (e.g. a Congo story carried by an SA outlet).
var matchSources: Bool = false
func matches(_ s: StorySummary) -> Bool {
if let topicSlug {
return s.topic.caseInsensitiveCompare(topicSlug) == .orderedSame
}
// Headline only the backend's summaries are polluted by over-clustering
// (a Congo headline can carry a South-Africa summary), so they're unsafe
// to match on. F1 additionally trusts its (clean) source names.
var hay = s.headline
if matchSources { hay += " " + s.sources.map(\.name).joined(separator: " ") }
hay = hay.lowercased()
if excludeKeywords.contains(where: { hay.contains($0) }) { return false }
return keywords.contains { hay.contains($0) }
}
// 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"]
static let f1 = Interest(id: "f1", label: "F1",
keywords: ["formula 1", "formula one", "grand prix", "f1", "verstappen", "hamilton",
"mclaren", "ferrari", "red bull", "racefans", "motorsport", "autosport",
"pole position", "pirelli", "qualifying", "fia", "planetf1"],
matchSources: true)
/// Pinned filter pills, in order. Regions are mutually exclusive.
static let pinned: [Interest] = [
f1,
Interest(id: "uganda", label: "Uganda", keywords: ugandaKeywords),
Interest(id: "east-africa", label: "East Africa",
keywords: ["east africa", "eastafrican", "kenya", "kenyan", "nairobi",
"tanzania", "tanzanian", "dar es salaam", "rwanda", "rwandan",
"kigali", "ethiopia", "ethiopian", "addis ababa", "burundi",
"south sudan", "juba"],
excludeKeywords: ugandaKeywords + southAfricaKeywords),
Interest(id: "south-africa", label: "South Africa", keywords: southAfricaKeywords),
Interest(id: "finance", label: "Finance", topicSlug: "finance"),
Interest(id: "tech", label: "Tech", topicSlug: "tech"),
]
}