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:
2026-08-26 02:18:34 +02:00
parent fbb961b5b7
commit 549aae67cc
12 changed files with 566 additions and 8 deletions
+12
View File
@@ -8,10 +8,22 @@ from app.auth import current_user
from app.db import get_db
from app.models import ChannelTag, Tag, User
from app.routes._common import owned_or_404
from app.sync.tagsuggest import compute_tag_suggestions
router = APIRouter(prefix="/api/tags", tags=["tags"])
@router.get("/suggestions")
def tag_suggestions(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict[str, list[dict]]:
"""Suggested user tags for this user's still-untagged subscribed channels, learned from the
auto-tag pattern of the channels they HAVE tagged (see app.sync.tagsuggest). Read-only — the
Channel Manager shows these as one-tap-accept chips; accepting posts to the normal attach-tag
endpoint. Keyed by channel id; channels with no suggestion are omitted."""
return compute_tag_suggestions(db, user)
@router.get("")
def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(
+121
View File
@@ -0,0 +1,121 @@
"""Suggest a user's own manual tags for channels they haven't tagged yet.
The signal is the auto-tagger's *topic* output: each channel carries ``source='auto'`` topic
ChannelTags (see ``autotag.py``). For every user tag T the user has applied to enough channels,
we learn which topics are *characteristic* of T (present in a high fraction of the channels the
user tagged T), then suggest T for any still-untagged channel whose own topics cover enough of
that profile.
Language auto-tags are deliberately EXCLUDED from the signal: they are near-universal within a
language ("English" is on most channels), so they don't discriminate — including them floods the
result (measured on real data: every untagged channel got a suggestion, most on a shared language
alone). Topic overlap is what makes "you tagged similar channels this way" meaningful.
Purely read-only and deterministic — the Channel Manager surfaces the result as one-tap-accept
"ghost" chips; nothing is written here (D4 = suggest-and-confirm, no silent auto-apply). The
thresholds are tuning constants rather than admin knobs on purpose: they mirror how ``autotag.py``
keeps its mappings inline, and sysconfig only stores int/str/bool (no float), so a knob would mean
storing percentages — not worth the surface for values an operator has no reason to tune per-install.
"""
from collections import Counter
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import ChannelTag, Subscription, Tag, User
# A user tag needs at least this many manually-tagged example channels before we trust its
# profile — fewer is noise (one gaming-cooking channel shouldn't make "cooking" mean "gaming").
MIN_EXAMPLES = 3
# A topic counts as *characteristic* of a user tag when it appears in ≥60% of that tag's example
# channels. High enough that an incidental shared topic (one channel's stray category) doesn't
# enter the profile, low enough to survive a couple of outliers in a small example set.
CHAR_SUPPORT = 0.6
# Suggest tag T for channel C when C's topics cover ≥60% of T's characteristic topics. The
# intersection-over-profile shape means a partial match scores proportionally: 1 topic of a
# 2-topic profile = 0.5 (dropped), 2 of 3 = 0.67 (kept). Tuned against real data to surface
# strong topical matches without flooding — 0.5 let a single shared topic carry a suggestion.
SUGGEST_THRESHOLD = 0.6
# A suggestion at/above this score is "high confidence" — a full-profile topic match — and is the
# only kind the UI's "Accept all confident" action touches. Never applied silently (see docstring).
AUTOAPPLY_THRESHOLD = 0.9
# Cap suggestions per channel so a busy channel's chip row stays legible.
MAX_PER_CHANNEL = 3
def compute_tag_suggestions(db: Session, user: User) -> dict[str, list[dict]]:
"""Map each of the user's still-untagged subscribed channels to suggested user tags.
Returns ``{channel_id: [{"tag_id": int, "confidence": float}, ...]}`` — ordered best-first,
capped at ``MAX_PER_CHANNEL``. Channels with no suggestion are omitted (empty dict when the
user has no tag with a usable profile). Deterministic for a fixed DB state.
"""
# Candidate universe: the channels this user manages. A user can only tag a subscribed channel
# (attach_tag requires a subscription), so example channels are a subset of this too — which
# lets us bound the auto-tag scan to just these channels.
subscribed: set[str] = set(
db.scalars(select(Subscription.channel_id).where(Subscription.user_id == user.id)).all()
)
if not subscribed:
return {}
# The user's manual tag links: tag_id -> example channels, and each channel's user-tag set (so
# a channel with ANY user tag is excluded from "untagged").
examples: dict[int, set[str]] = {}
user_tagged: set[str] = set()
for tag_id, cid in db.execute(
select(ChannelTag.tag_id, ChannelTag.channel_id).where(
ChannelTag.user_id == user.id, ChannelTag.source == "user"
)
).all():
examples.setdefault(tag_id, set()).add(cid)
user_tagged.add(cid)
# Topic features per channel: the auto TOPIC tags on the candidate channels. Language auto-tags
# are joined out (Tag.category) — they don't discriminate (see the module docstring).
features: dict[str, set[int]] = {}
for cid, tid in db.execute(
select(ChannelTag.channel_id, ChannelTag.tag_id)
.join(Tag, Tag.id == ChannelTag.tag_id)
.where(
ChannelTag.user_id.is_(None),
ChannelTag.source == "auto",
ChannelTag.channel_id.in_(subscribed),
Tag.category == "topic",
)
).all():
features.setdefault(cid, set()).add(tid)
# Characteristic-feature profile per user tag with enough examples.
profiles: dict[int, set[int]] = {}
for tag_id, ex in examples.items():
if len(ex) < MIN_EXAMPLES:
continue
counts: Counter[int] = Counter()
for cid in ex:
counts.update(features.get(cid, ()))
threshold = CHAR_SUPPORT * len(ex)
chars = {f for f, c in counts.items() if c >= threshold}
if chars:
profiles[tag_id] = chars
if not profiles:
return {}
suggestions: dict[str, list[dict]] = {}
for cid in subscribed:
if cid in user_tagged: # already has a user tag → not "untagged"
continue
cfeat = features.get(cid)
if not cfeat:
continue
scored = [(tag_id, len(cfeat & chars) / len(chars)) for tag_id, chars in profiles.items()]
hits = [(tid, s) for tid, s in scored if s >= SUGGEST_THRESHOLD]
if not hits:
continue
# Best score first; tag_id as a stable tie-break so the order is deterministic.
hits.sort(key=lambda x: (-x[1], x[0]))
suggestions[cid] = [
{"tag_id": tid, "confidence": round(s, 3)} for tid, s in hits[:MAX_PER_CHANNEL]
]
return suggestions
+118
View File
@@ -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)