Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save aldrinleal/40ac1538ca5e3226111269b26deacb87 to your computer and use it in GitHub Desktop.

Select an option

Save aldrinleal/40ac1538ca5e3226111269b26deacb87 to your computer and use it in GitHub Desktop.
MVP-782–789: Algo Signal Upgrades — sherpahealthy backend plan

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

Decisions Locked

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

Summary

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).


Current Signal Architecture (Baseline)

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.pyEngagementEvent enum, score deltas, upsert logic
  • app/sherpahealthy/posts/feed_adapters.pyPersonalizedFeedAdapter, recency decay
  • app/sherpahealthy/posts/feeds_http.py — all feed + signal endpoints
  • app/admin/dashboard.py — DS2 algo-debug data computation

MVP-782 — Investigate Feed Position Mismatch (TestFlight vs DS2)

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):

  1. Session ID missing in DS2: The real app passes session_id to feed requests, which activates FeedSession boosted/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 optional session_id param and pass it through.

  2. Profile ID lookup mismatch: Confirm DS2 is resolving profile_id from the same source as the app (JWT sub → profile row). A mismatch here would mean DS2 is debugging a different profile.

  3. Feed variant differences: TestFlight may be hitting /v1/next/ (PersonalizedFeed) while DS2 debug renders /v1/posts/next/feed/debug with slightly different adapter selection thresholds.

Backend changes:

  • Add session_id param to GET /v1/posts/next/feed/debug
  • Pass it to _execute_feed_query so FeedSession boosts are applied
  • Add session_id display to DS2 output so QA can verify

MVP-783 — DS2 Signals Page: Show All Signal Types

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_activity view (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_delta sign (+/−) with colour in admin UI

MVP-784 — Swipe Left/Right as Strong Algo Signal

Changes:

1. Rebalance swipe score deltas

# 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-swipe

Per Confluence: "swiping left and right should be more or less symmetrical".

2. Expand to ALL tags of the post + MeSH relatives

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.


MVP-785 — Post Detail Click as Strong 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.pyget_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.


MVP-786 — Original Source Click as Very Strong Signal

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 app

Rationale: 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).


MVP-787 — Tag Follow/Unfollow as Very Strong Signal with MeSH Inheritance

Current: tag_follow = +2.0, tag_unfollow = −2.0 on the followed tag only.

Changes:

  1. Keep direct tag score: +2.0 / −2.0 (already correct weight)
  2. Add MeSH hierarchy propagation (see MVP-788 for the propagation logic)
  3. 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.

MVP-788 — MeSH Tag Hierarchy Inheritance

This is the foundation that MVP-784, 787 depend on.

MeSH Relative Weights

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.

Implementation

# 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 migration required

-- 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 schema

MVP-789 — Triple Bonus Points for MeSH Post Interactions

All 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 base

Pass post.post_source into record_post_engagement() call sites.


Signal Score Table — After All Changes

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.


Implementation Order

These have dependencies; implement in this sequence:

Phase 1 — Foundation (no FE changes needed)

  1. user_engagement_events table (Supabase migration + Alembic)
  2. get_mesh_relatives() cache (app/sherpahealthy/mesh_signal.py)
  3. New EngagementEvent values + updated SCORE_DELTAS in engagement.py
  4. get_effective_delta() MeSH multiplier in engagement.py
  5. Update record_post_engagement() to write event log + propagate MeSH relatives

Phase 2 — Signal rewiring (BE-only, FE transparent)

  1. MVP-784: Rebalance swipe deltas (+1.5 / −1.5)
  2. MVP-785: Switch get_post_detail() from view to detail_view
  3. MVP-787: MeSH propagation on tag follow/unfollow (via follows.py)
  4. MVP-789: Apply get_effective_delta() at all record_* call sites

Phase 3 — New endpoint + DS2 fixes

  1. MVP-786: POST /v1/next/post/{post_id}/source-click endpoint
  2. MVP-783: Update DS2 signal page to query user_engagement_events
  3. MVP-782: Add session_id param to debug endpoint, investigate mismatch

Phase 4 — Testing & validation

  1. Smoke test all signal paths
  2. Verify DS2 shows all new event types
  3. QA: re-run TestFlight vs DS2 comparison after session_id fix

Files to Change

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 fileget_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

Open Questions

  1. detail_view vs view disambiguation: Does the FE currently fire anything else that hits GET /v1/next/post/{post_id} besides a deliberate detail tap? If prefetching is in play, we may be over-crediting signals.

  2. tag_unfollow weight: Symmetrical (−2.0) or softer (−1.5)? Recommend −1.5 but needs product sign-off.

  3. 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.

  4. post_source field for MeSH detection: Confirm post_source = 'pubmed' is the correct discriminator, or if there's a better flag (e.g. presence of MeSH descriptor tags).

  5. Nightly vs inline propagation threshold: Profile get_mesh_relatives() on a cold cache hit before committing to inline. If > 50ms, move to nightly.


References

  • 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 implementation
  • app/sherpahealthy/posts/feed_adapters.py — PersonalizedFeedAdapter scoring
  • specs/20260314-post-generation-pipeline.md — post_source values reference
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment