MVP-782 · MVP-783 · MVP-784 · MVP-785 · MVP-786 · MVP-787 · MVP-788 · MVP-789 — Algo Signal Upgrades
Source: Confluence: Algo / FE / BE upgrades needed 4/18/2026
Date: 2026-04-26
Status: Implementation in progress
| Question | Decision |
|---|---|
tag_unfollow weight |
−1.5 (softer than +2.0 follow — accidental unfollows shouldn't nuke affinity) — expressed as constant TAG_UNFOLLOW_DELTA |
| MeSH post sources | {'PubMed', 'MeSH Descriptor', 'MeSH Concept'} — confirmed from DB |
| MeSH relatives strategy | Inline with in-process singleton cache built from mesh_trees.treeid prefix matching |
| MeSH hierarchy nav | treeid dot-notation prefix: parent = rsplit('.', 1)[0]; children/siblings from prebuilt children_of dict |
Eight tickets covering signal strength rebalancing, new signal types, MeSH hierarchy propagation, and DS2 debug tooling improvements. All are backend changes with minimal frontend surface area (one new endpoint for source-click).
| Event | Current score_delta |
Stored in |
|---|---|---|
view |
+0.3 | user_tag_affinity (upsert) |
comment |
+0.5 | user_tag_affinity |
like |
+0.5 | user_tag_affinity |
more_like_this (swipe right) |
+0.5 | user_tag_affinity |
less_like_this (swipe left) |
−1.0 | user_tag_affinity |
bookmark |
+1.0 | user_tag_affinity |
remove_bookmark |
−1.0 | user_tag_affinity |
tag_follow |
+2.0 | user_tag_affinity |
tag_unfollow |
−2.0 | user_tag_affinity |
Key files:
app/sherpahealthy/engagement.py—EngagementEventenum, score deltas, upsert logicapp/sherpahealthy/posts/feed_adapters.py—PersonalizedFeedAdapter, recency decayapp/sherpahealthy/posts/feeds_http.py— all feed + signal endpointsapp/admin/dashboard.py— DS2 algo-debug data computation
Problem: First 5 TestFlight posts map to DS2 positions 238, 96, 3, 4, 165 — not a simple offset, which suggests a logic divergence, not just a cache lag.
Likely root causes to investigate (in order):
-
Session ID missing in DS2: The real app passes
session_idto feed requests, which activatesFeedSessionboosted/suppressed tag IDs. DS2 calls the debug endpoint without a session_id, so it sees the raw affinity score without session boosts. Fix: DS2 should accept an optionalsession_idparam and pass it through. -
Profile ID lookup mismatch: Confirm DS2 is resolving
profile_idfrom the same source as the app (JWT sub → profile row). A mismatch here would mean DS2 is debugging a different profile. -
Feed variant differences: TestFlight may be hitting
/v1/next/(PersonalizedFeed) while DS2 debug renders/v1/posts/next/feed/debugwith slightly different adapter selection thresholds.
Backend changes:
- Add
session_idparam toGET /v1/posts/next/feed/debug - Pass it to
_execute_feed_queryso FeedSession boosts are applied - Add
session_iddisplay to DS2 output so QA can verify
Problem: DS2 signals page only shows views, bookmarks, tag follows (sourced from
user_signal_activity DB view). Swipe left/right and all new signals (785, 786) are
invisible because they only mutate user_tag_affinity with no individual event log.
Root cause: There is no event log table — signals are immediately folded into the running affinity score and the individual event is lost.
Fix: Add user_engagement_events log table
CREATE TABLE user_engagement_events (
id BIGSERIAL PRIMARY KEY,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
post_id INTEGER REFERENCES posts(id) ON DELETE SET NULL,
tag_id INTEGER REFERENCES tags(id) ON DELETE SET NULL,
event_type VARCHAR(50) NOT NULL, -- mirrors EngagementEvent.value
score_delta FLOAT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON user_engagement_events (profile_id, created_at DESC);- Write to this table on every
_upsert_affinity()call (background task, non-blocking) - Update
user_signal_activityview (or replace with query against this table) - DS2 signal page queries this table for the given
profile_id
DS2 display additions:
- Show all event types with
event_type,score_delta,post_title,tag_name,created_at - Sort by
created_at DESC, last 100 events - Highlight
score_deltasign (+/−) with colour in admin UI
Changes:
# app/sherpahealthy/engagement.py
EngagementEvent.more_like_this: +1.5 # was +0.5 — now matches bookmark strength
EngagementEvent.less_like_this: -1.5 # was −1.0 — symmetrical with right-swipePer Confluence: "swiping left and right should be more or less symmetrical".
Currently record_post_engagement() fires the same delta for every tag on the post.
That's correct — but with MVP-788 (MeSH hierarchy), we will also propagate to
parent/child/sibling tags at reduced weight (see MVP-788 below).
No new endpoint needed — swipe already calls POST /v1/next/feed/signal.
Current state: GET /v1/next/post/{post_id} records a view (+0.3) via
BackgroundTask. This is the same event as seeing a post in the feed scroll.
Change: Introduce a separate detail_view event with stronger weight:
# engagement.py — add new event
class EngagementEvent(str, Enum):
...
detail_view = "detail_view" # user opened post detail screen
SCORE_DELTAS = {
...
EngagementEvent.detail_view: 1.0, # strong: intentional click, not passive scroll
}In feeds_http.py → get_post_detail(): change background task from view to
detail_view. The view event (+0.3) should remain for feed-scroll tracking if that
is separately instrumented; if not, just rename the event and increase the weight.
FE note: No endpoint change — same
GET /v1/next/post/{post_id}. The signal upgrade is purely server-side.
New endpoint required (FE must call this when user taps "original source" link):
POST /v1/next/post/{post_id}/source-click
Authorization: Bearer <jwt> (or anonymous — skip if anon)
Response: 204 No Content
# engagement.py
EngagementEvent.source_click = "source_click"
SCORE_DELTAS[EngagementEvent.source_click] = 2.5 # very strong: user left the appRationale: leaving the app to read the source is the strongest possible content
engagement signal short of a share. Score set above tag_follow (+2.0).
Handler records source_click for all tags on the post, plus MeSH relatives (MVP-788).
Current: tag_follow = +2.0, tag_unfollow = −2.0 on the followed tag only.
Changes:
- Keep direct tag score: +2.0 / −2.0 (already correct weight)
- Add MeSH hierarchy propagation (see MVP-788 for the propagation logic)
tag_unfollow→ asymmetric option: −1.5 instead of −2.0 (softer negative, user may have unfollowed by accident). Decision needed — Confluence says either symmetrical or simpler negative. Recommend −1.5 for unfollow.
This is the foundation that MVP-784, 787 depend on.
When a signal fires for a tag with MeSH tree entries, propagate diminished scores to its relatives:
| Relative | Weight multiplier | Rationale |
|---|---|---|
| Direct tag | 1.0× | Base signal |
| Parents (1 level up) | 0.4× | Strong: parent is broader interest |
| Children (1 level down) | 0.3× | Moderate: narrower sub-topic |
| Siblings (same parent) | 0.2× | Weaker: adjacent interest |
| Grandparents (2 levels up) | 0.2× | Distant but relevant |
| Aunts/Uncles (parent siblings) | 0.15× | Weakest: thematic neighbourhood |
Cap propagation depth at 2 levels to avoid polluting unrelated broad categories.
# app/sherpahealthy/mesh_signal.py (new file)
from functools import lru_cache
from app.db.models import MeshTree, Tag
from sqlmodel import Session, select
@lru_cache(maxsize=512)
def get_mesh_relatives(tag_id: int) -> list[tuple[int, float]]:
"""Returns [(tag_id, weight_multiplier), ...] for MeSH relatives of tag_id.
Cached in-process — MeSH hierarchy is static after import."""
...Query strategy (avoid heavy joins at signal time):
The MeSH hierarchy is static — it never changes after the initial data import.
Cache the relative map in-process with @lru_cache. On first call per tag, compute
the full relative set via MeshTree.treeid prefix matching. Subsequent calls are O(1).
Propagation in record_post_engagement():
# For each tag on the post:
for tag_id in post_tag_ids:
relatives = get_mesh_relatives(tag_id) # cached
for rel_tag_id, weight in relatives:
delta = base_delta * weight
_upsert_affinity(profile_id, rel_tag_id, delta)Nightly job consideration (from Confluence):
If the inline propagation proves too slow (>100ms per signal), extract the MeSH
hierarchy expansion into a nightly scripts/propagate_mesh_affinities.py that
re-derives affinities from the event log. Recommended: start inline with caching,
profile, then decide.
-- supabase/migrations/<timestamp>_mesh_signal_relatives_cache.sql
-- No schema changes needed for Phase 1 (use existing user_tag_affinity)
-- Only user_engagement_events table (from MVP-783) is new schemaAll non-view signals on MeSH-sourced posts carry 3× weight.
MeSH post identification: Posts with post_source = 'pubmed' are MeSH posts
(they have MeSH descriptor tags assigned by the pipeline).
# engagement.py
MESH_POST_SOURCES = {"pubmed"}
def get_effective_delta(event: EngagementEvent, post_source: str | None) -> float:
base = SCORE_DELTAS[event]
if event == EngagementEvent.view:
return base # views never get the bonus
if post_source in MESH_POST_SOURCES:
return base * 3.0
return basePass post.post_source into record_post_engagement() call sites.
| Event | Current delta | New delta | MeSH post (non-view) |
|---|---|---|---|
view |
+0.3 | +0.3 | +0.3 (no bonus) |
detail_view (new) |
— | +1.0 | +3.0 |
comment |
+0.5 | +0.5 | +1.5 |
like |
+0.5 | +0.5 | +1.5 |
more_like_this |
+0.5 | +1.5 | +4.5 |
less_like_this |
−1.0 | −1.5 | −4.5 |
bookmark |
+1.0 | +1.0 | +3.0 |
remove_bookmark |
−1.0 | −1.0 | −3.0 |
source_click (new) |
— | +2.5 | +7.5 |
tag_follow |
+2.0 | +2.0 | +6.0 |
tag_unfollow |
−2.0 | −1.5 | −4.5 |
Scores remain clamped to [−10, +10] after all multipliers.
These have dependencies; implement in this sequence:
user_engagement_eventstable (Supabase migration + Alembic)get_mesh_relatives()cache (app/sherpahealthy/mesh_signal.py)- New
EngagementEventvalues + updatedSCORE_DELTASinengagement.py get_effective_delta()MeSH multiplier inengagement.py- Update
record_post_engagement()to write event log + propagate MeSH relatives
- MVP-784: Rebalance swipe deltas (+1.5 / −1.5)
- MVP-785: Switch
get_post_detail()fromviewtodetail_view - MVP-787: MeSH propagation on tag follow/unfollow (via follows.py)
- MVP-789: Apply
get_effective_delta()at allrecord_*call sites
- MVP-786:
POST /v1/next/post/{post_id}/source-clickendpoint - MVP-783: Update DS2 signal page to query
user_engagement_events - MVP-782: Add
session_idparam to debug endpoint, investigate mismatch
- Smoke test all signal paths
- Verify DS2 shows all new event types
- QA: re-run TestFlight vs DS2 comparison after session_id fix
| File | Changes |
|---|---|
app/sherpahealthy/engagement.py |
New events, new deltas, get_effective_delta(), event log write, MeSH propagation call |
app/sherpahealthy/mesh_signal.py |
New file — get_mesh_relatives() with LRU cache |
app/sherpahealthy/posts/feeds_http.py |
detail_view in get_post_detail(), source-click endpoint, session_id in debug endpoint |
app/sherpahealthy/follows.py |
MeSH propagation on tag follow/unfollow |
app/admin/dashboard.py |
DS2 signal page queries user_engagement_events |
app/db/models.py |
UserEngagementEvent model |
supabase/migrations/ |
user_engagement_events table migration |
alembic/versions/ |
Same for production |
-
detail_viewvsviewdisambiguation: Does the FE currently fire anything else that hitsGET /v1/next/post/{post_id}besides a deliberate detail tap? If prefetching is in play, we may be over-crediting signals. -
tag_unfollowweight: Symmetrical (−2.0) or softer (−1.5)? Recommend −1.5 but needs product sign-off. -
MeSH relatives depth cap: 2 levels up + 2 levels down proposed. Validate with a sample tag (e.g. "Retinoblastoma") that the relative set is not too large.
-
post_sourcefield for MeSH detection: Confirmpost_source = 'pubmed'is the correct discriminator, or if there's a better flag (e.g. presence of MeSH descriptor tags). -
Nightly vs inline propagation threshold: Profile
get_mesh_relatives()on a cold cache hit before committing to inline. If > 50ms, move to nightly.
- Confluence: Algo FE/BE upgrades 4/18/2026
- MVP-782, MVP-783, MVP-784, MVP-785, MVP-786, MVP-787, MVP-788, MVP-789
app/sherpahealthy/engagement.py— current signal implementationapp/sherpahealthy/posts/feed_adapters.py— PersonalizedFeedAdapter scoringspecs/20260314-post-generation-pipeline.md— post_source values reference