feat(channels): suggest your own tags for untagged channels from your tagging pattern (E8·S4)
Learn, per user tag, which auto TOPIC tags are characteristic of the channels you've tagged it, then suggest that tag for still-untagged subscribed channels whose topics cover enough of the profile. Language auto-tags are excluded from the signal — near-universal within a language, they flooded every channel. New GET /api/tags/suggestions (read-only, deterministic) backed by app/sync/tagsuggest.py. Surfaced in the Channel Manager as dashed "ghost" chips in the tags cell — one tap accepts (reuses the attach-tag endpoint), × dismisses for the session — plus a header badge and a bulk "Accept N confident" (the full-profile matches) behind a confirm. No silent writes (D4 = suggest-and-confirm). Also reflow a pre-existing prettier drift in dateFilter.ts from E8·S2.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""DB-backed regression (E8·S4): the manual-tag-pattern suggester.
|
||||
|
||||
compute_tag_suggestions learns which auto TOPIC tags are *characteristic* of each user tag (from the
|
||||
channels the user tagged), then proposes that tag for still-untagged subscribed channels whose own
|
||||
topics cover ≥60% of the profile. The cases below pin the behaviours that would silently ship a
|
||||
useless suggester if they broke: the min-examples floor, the intersection-over-profile fraction that
|
||||
keeps a weak partial match out, the "language is not a signal" exclusion (its inclusion once flooded
|
||||
every channel with a suggestion), and the "already tagged → excluded" scope. Deterministic — no
|
||||
network, pure DB state.
|
||||
|
||||
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||
"""
|
||||
|
||||
from app.models import Channel, ChannelTag, Subscription, Tag, User
|
||||
from app.sync.tagsuggest import (
|
||||
AUTOAPPLY_THRESHOLD,
|
||||
SUGGEST_THRESHOLD,
|
||||
compute_tag_suggestions,
|
||||
)
|
||||
|
||||
|
||||
def _auto(db, name, category="topic"):
|
||||
tag = Tag(user_id=None, name=name, category=category)
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
return tag
|
||||
|
||||
|
||||
def _subscribe(db, user, ch_id, *, auto_tag_ids=(), title=None):
|
||||
"""A subscribed channel carrying the given auto-tags (source='auto', user_id NULL)."""
|
||||
db.add(Channel(id=ch_id, title=title or ch_id))
|
||||
db.add(Subscription(user_id=user.id, channel_id=ch_id))
|
||||
for tid in auto_tag_ids:
|
||||
db.add(ChannelTag(channel_id=ch_id, tag_id=tid, user_id=None, source="auto"))
|
||||
|
||||
|
||||
def test_suggests_a_user_tag_for_a_channel_matching_its_topic_profile(db):
|
||||
me = User(email="me@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
edu = _auto(db, "Education")
|
||||
film = _auto(db, "Film")
|
||||
eng = _auto(db, "English", category="language") # a language auto-tag → NOT part of the signal
|
||||
docu = Tag(user_id=me.id, name="docu", category="other")
|
||||
db.add(docu)
|
||||
db.flush()
|
||||
|
||||
# Three example channels the user tagged "docu", all Education+Film (+English). The topic profile
|
||||
# is {Education, Film}; the English language tag is excluded from the profile.
|
||||
for i in range(3):
|
||||
cid = f"UC_ex{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[edu.id, film.id, eng.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=docu.id, user_id=me.id, source="user"))
|
||||
|
||||
# An untagged subscribed channel sharing the full topic profile → full-confidence suggestion.
|
||||
_subscribe(db, me, "UC_match", auto_tag_ids=[edu.id, film.id, eng.id])
|
||||
# An untagged channel sharing ONLY the language → no topic overlap at all, so no suggestion. This
|
||||
# is the anti-flood guarantee: a shared language must never carry a suggestion on its own.
|
||||
_subscribe(db, me, "UC_english_only", auto_tag_ids=[eng.id])
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
|
||||
assert "UC_match" in out
|
||||
assert out["UC_match"][0]["tag_id"] == docu.id
|
||||
# Both profile topics present → full confidence, i.e. an "accept all confident" hit.
|
||||
assert out["UC_match"][0]["confidence"] >= AUTOAPPLY_THRESHOLD
|
||||
assert "UC_english_only" not in out
|
||||
# Example channels are already tagged → excluded from suggestions.
|
||||
assert "UC_ex0" not in out
|
||||
|
||||
|
||||
def test_a_tag_below_the_example_floor_produces_no_profile(db):
|
||||
me = User(email="me2@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
edu = _auto(db, "Education")
|
||||
niche = Tag(user_id=me.id, name="niche", category="other")
|
||||
db.add(niche)
|
||||
db.flush()
|
||||
|
||||
# Only TWO example channels for "niche" (< MIN_EXAMPLES=3) → no profile, so no suggestion even
|
||||
# for a perfectly-matching untagged channel.
|
||||
for i in range(2):
|
||||
cid = f"UC_niche{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[edu.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=niche.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_would_match", auto_tag_ids=[edu.id])
|
||||
db.commit()
|
||||
|
||||
assert compute_tag_suggestions(db, me) == {}
|
||||
|
||||
|
||||
def test_partial_topic_overlap_is_a_fraction_of_the_profile(db):
|
||||
# Guards the intersection-over-profile shape at the SUGGEST_THRESHOLD boundary: a 3-topic profile
|
||||
# matched by 1 topic scores 0.33 (dropped); matched by 2 scores 0.67 (kept). Pins the threshold
|
||||
# so a change to it can't silently widen/narrow what "a suggestion" means.
|
||||
assert SUGGEST_THRESHOLD == 0.6
|
||||
me = User(email="me3@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
a, b, c = _auto(db, "A"), _auto(db, "B"), _auto(db, "C")
|
||||
tag = Tag(user_id=me.id, name="triad", category="other")
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
for i in range(3):
|
||||
cid = f"UC_triad{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[a.id, b.id, c.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_one_of_three", auto_tag_ids=[a.id]) # 1/3 = 0.33 < 0.6 → dropped
|
||||
_subscribe(db, me, "UC_two_of_three", auto_tag_ids=[a.id, b.id]) # 2/3 = 0.67 ≥ 0.6 → kept
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
assert "UC_one_of_three" not in out
|
||||
assert "UC_two_of_three" in out
|
||||
assert out["UC_two_of_three"][0]["tag_id"] == tag.id
|
||||
assert out["UC_two_of_three"][0]["confidence"] == round(2 / 3, 3)
|
||||
Reference in New Issue
Block a user