RFC-102: Knowledge Clusters, Entity Cards & Personalized Discovery¶
- Status: Draft (§1 Implemented — Epic 3.1, #1092)
- Authors: Marko
- PRD:
docs/prd/PRD-043-knowledge-layer.md - UX spec:
docs/uxs/UXS-013-knowledge-clusters.md - Depends on: RFC-098/099 (consumer client),
search/topic_clusters.py(clustering), the viewer relational/subject layer (#1089:PersonLandingView,useRelationalCache). - Epic: GitHub #1093 (children #1092, #1095–#1100)
Abstract¶
Technical design for Epic 3 — making the consumer app's knowledge navigable: topic clusters (§1), person/topic entity cards (§2/§3), entities surfaced in search (§4), and personalized discovery (§5). All read-only over existing corpus artifacts; no request-time LLM, bridge audio / never rehost, no new persistence layer. §1 is implemented; §2–§5 are slice designs.
Problem¶
The corpus computes topic clusters (topic_clusters.json) and the viewer has a rich person
profile surface (#1089), but the consumer app shows a flat topic list and tapping an entity only
runs a library search. The knowledge is present but not navigable.
Design¶
§1 Topic clusters + cluster-first panel (3.1 — implemented, #1092)¶
Data. search/topic_clusters.py :: consumer_topic_cluster_map(corpus_root) → {topic_id:
{cluster_id, cluster_label, cluster_size}}, built from the same topic_clusters.json the search
layer/operator viewer use. cluster_id = the cluster's graph_compound_parent_id; cluster_size
= cross-corpus member count. Singletons are absent (→ no cluster fields). Path-safe load shared
with load_topic_cluster_enrichment_map via _load_topic_clusters_payload.
API. GET /api/app/episodes/{slug}/entities enriches each AppTopic with cluster_id,
cluster_label, cluster_size (defaults null/null/0). No-op (flat list) when the artifact
is absent — today's behaviour preserved.
UI (Insights panel). Topics are ordered cluster-first: the dominant cluster (the one
with the most of this episode's topics, ≥2; tie → larger cluster_size) leads and its chips get
a ring-topic standout; other clustered topics follow (larger intra-episode groups first);
singletons trail; People remain an adjacent affordance in the same row. A "Theme · {cluster}"
lead-in names the dominant cluster. Stable sort preserves original order within a rank.
Why intra-episode dominance (not cross-corpus size): it reflects what this episode is about;
cluster_size is only a tiebreak / prevalence cue (PRD-043 Design Considerations).
§2 Person profile card (3.2, #1095)¶
Tap a person → a card (sheet mobile / panel desktop): name, episodes they appear in, related
people/topics. Data from a dedicated GET /api/app/persons/{id} — KG co-occurrence over the
corpus (app_relational_view.build_person_card reuses entities_from_kg), NOT the operator
relational API / subject store (the /api/app boundary stays clean; effort over coupling). No
biography. Replaces the Epic-2 person-chip→search default; the EntityCard keeps an explicit
"search the library" action, is re-entrant (related chips walk with a back stack), and is a proper
modal (focus trap, ESC/backdrop close, restore focus). UXS-011 tokens.
§3 Topic card (3.3, #1096)¶
Tap a topic → the same EntityCard: its cluster (cluster_label), sibling topics in the cluster
(from topic_clusters.json members via consumer_cluster_siblings), episodes about it, and related
people. Data from a dedicated GET /api/app/topics/{id} (KG-grounded; not a reuse of search).
Builds on §1's exposed cluster identity.
§4 Entities as search results (3.4, #1097)¶
A dedicated GET /api/app/entities/search resolves a query to a person/topic by exact/near-exact
name match (normalized: case/punctuation/spacing-insensitive; persons win on a tie) — kept off the
shared search response so the operator surface stays clean. SearchView calls it in parallel with
/search and renders the match as a card above the passages, opening the §2/§3 EntityCard on tap.
Consumer-only for now (viewer parity deferred).
§5 Personalized discovery (3.5, #1098)¶
Interests = a mixed token set, saved as per-user files (GET/PUT /api/app/interests — the
same overlay as playback/queue; no new persistence). A token is a topic cluster (tc:), a
topic (topic:) or a person (person:). Two entry-points feed the same list:
- a first-Home dismissible card opens a picker over
GET /api/app/clusters(top bymember_count), which writestc:cluster ids viaPUT /api/app/interests; and - a one-tap Follow / Following toggle on a person/topic entity card (§2/§3) follows /
unfollows a single
topic:/person:token viaPOST/DELETE /api/app/interests/{token}(idempotent;DELETEis a no-op when absent).
The Home feed GET /api/app/discover ranks via rank_discover, which scores significance ×
interest affinity over the episode's KG — a followed token matches whichever set its prefix
belongs to (tc: → topic-cluster, topic: → topic, person: → person) — when
app.state.personalized_ranking (env APP_PERSONALIZED_RANKING, default off) is set and the
signed-in user has interests; otherwise recency. get_optional_user keeps the feed open to
anonymous requests. Flag-gated until the score is tuned.
Testing¶
- §1: unit (
consumer_topic_cluster_mapshape), integration (/entitiesattaches cluster fields; flat fallback without artifact), frontend unit (cluster-first ordering + dominant standout + theme). Shipped with 3.1. - §2–§5: each slice lands its own test pyramid + critical pass per the loop.
Phasing¶
§1 (clusters) is the shared spine and ships first; §2/§3 (cards) and §5 (personalization) consume it; §4 builds on §2/§3. Char-level highlighting (PRD-043 FR5 / 3.6) is independent.