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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the suggested-tag ghost chips (E8·S4): on an UNTAGGED channel, ChannelTagsControl renders
|
||||
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
|
||||
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
|
||||
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
|
||||
// and a ResizeObserver-driven fit measurer, so we stub the observer and wrap in a FloatingTree.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import type { Tag } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const suggestions: SuggestedTag[] = [
|
||||
{ tag: docu, confidence: 0.95 },
|
||||
{ tag: history, confidence: 0.6 },
|
||||
];
|
||||
|
||||
function renderControl(props: Partial<Parameters<typeof ChannelTagsControl>[0]> = {}) {
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[docu, history]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
suggestions={suggestions}
|
||||
onAcceptSuggestion={onAccept}
|
||||
onDismissSuggestion={onDismiss}
|
||||
{...props}
|
||||
/>
|
||||
</FloatingTree>,
|
||||
);
|
||||
return { onAccept, onDismiss };
|
||||
}
|
||||
|
||||
describe("ChannelTagsControl suggested ghost chips", () => {
|
||||
it("renders a ghost chip per suggestion and accepts on click", () => {
|
||||
const { onAccept } = renderControl();
|
||||
// Both suggested tag names show (attached row is empty on an untagged channel).
|
||||
fireEvent.click(screen.getByText("docu"));
|
||||
expect(onAccept).toHaveBeenCalledWith(docu.id);
|
||||
});
|
||||
|
||||
it("dismisses via the × without accepting", () => {
|
||||
const { onAccept, onDismiss } = renderControl();
|
||||
const dismissButtons = screen.getAllByRole("button", {
|
||||
name: "channels.tags.dismissSuggestion",
|
||||
});
|
||||
// Order matches the suggestions array — the second × is the "history" suggestion.
|
||||
fireEvent.click(dismissButtons[1]!);
|
||||
expect(onDismiss).toHaveBeenCalledWith(history.id);
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never shows a suggestion whose tag the channel already carries", () => {
|
||||
renderControl({ attachedIds: [docu.id] });
|
||||
// docu is attached → its ghost chip is suppressed (only history's × remains). Counting the
|
||||
// dismiss buttons avoids colliding with docu's now-present *attached* chip text.
|
||||
const dismissButtons = screen.getAllByRole("button", {
|
||||
name: "channels.tags.dismissSuggestion",
|
||||
});
|
||||
expect(dismissButtons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { Check, Plus, Sparkles, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
@@ -40,6 +40,51 @@ function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// A suggested user tag (E8·S4): a dashed "ghost" chip the user hasn't applied yet, learned from
|
||||
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
|
||||
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
|
||||
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
|
||||
export interface SuggestedTag {
|
||||
tag: Tag;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function SuggestedChip({
|
||||
suggestion,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestion: SuggestedTag;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.round(suggestion.confidence * 100);
|
||||
return (
|
||||
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
title={t("channels.tags.suggestedHint", { pct })}
|
||||
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
|
||||
>
|
||||
<Sparkles className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="truncate">{suggestion.tag.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title={t("channels.tags.dismissSuggestion")}
|
||||
aria-label={t("channels.tags.dismissSuggestion")}
|
||||
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
||||
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
|
||||
* detail page in Part 2; internal for now.) */
|
||||
@@ -139,6 +184,9 @@ export function ChannelTagsControl({
|
||||
onToggle,
|
||||
onCreate,
|
||||
onChipClick,
|
||||
suggestions,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
bounded = true,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
@@ -147,6 +195,11 @@ export function ChannelTagsControl({
|
||||
onCreate: (name: string) => void;
|
||||
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
|
||||
onChipClick?: (tagId: number, name: string) => void;
|
||||
/** Manager only (E8·S4): suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
|
||||
* chips after the (empty) attached row. Omit on the detail page. */
|
||||
suggestions?: SuggestedTag[];
|
||||
onAcceptSuggestion?: (tagId: number) => void;
|
||||
onDismissSuggestion?: (tagId: number) => void;
|
||||
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
|
||||
* table (the manager). Set false where there is free width (the channel detail header), so tags
|
||||
* fit inline instead of needlessly collapsing into "+N". */
|
||||
@@ -154,6 +207,9 @@ export function ChannelTagsControl({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
|
||||
// Suggestions only surface for channels with no user tag yet, but guard anyway: never suggest a tag
|
||||
// the channel already carries (an optimistic accept adds it before the query refetches).
|
||||
const shownSuggestions = (suggestions ?? []).filter((s) => !attachedIds.includes(s.tag.id));
|
||||
|
||||
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
|
||||
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
|
||||
@@ -256,6 +312,17 @@ export function ChannelTagsControl({
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
{/* Suggested ghost chips (E8·S4) ride in the same overflow-hidden row. They only appear on
|
||||
untagged channels (the attached slice above is empty then), so they get the full width and
|
||||
can't fight the fit measurer. Extras past the row clip like any overflow. */}
|
||||
{shownSuggestions.map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAcceptSuggestion?.(s.tag.id)}
|
||||
onDismiss={() => onDismissSuggestion?.(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel, type TagSuggestions } from "../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
@@ -54,6 +56,11 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
cards: LayoutGrid,
|
||||
};
|
||||
|
||||
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
|
||||
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
|
||||
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
|
||||
const HIGH_CONFIDENCE = 0.9;
|
||||
|
||||
export default function Channels() {
|
||||
const me = useMe();
|
||||
const canWrite = me.can_write;
|
||||
@@ -98,6 +105,10 @@ export default function Channels() {
|
||||
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
|
||||
const suggestionsQuery = useQuery({
|
||||
queryKey: qk.tagSuggestions(),
|
||||
queryFn: api.tagSuggestions,
|
||||
});
|
||||
const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
@@ -165,10 +176,63 @@ export default function Channels() {
|
||||
),
|
||||
),
|
||||
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
|
||||
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
|
||||
onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
|
||||
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
|
||||
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
|
||||
// the learned pattern (E8·S4).
|
||||
onSuccess: (id) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
},
|
||||
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
});
|
||||
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
|
||||
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload).
|
||||
const tagById = useMemo(
|
||||
() => new Map((tagsQuery.data ?? []).map((tg) => [tg.id, tg])),
|
||||
[tagsQuery.data],
|
||||
);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
||||
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
|
||||
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
|
||||
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
|
||||
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
|
||||
const suggestionsFor = (cid: string): SuggestedTag[] =>
|
||||
(suggestions[cid] ?? []).flatMap((s) => {
|
||||
const tag = tagById.get(s.tag_id);
|
||||
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
|
||||
});
|
||||
const dismissSuggestion = (cid: string, tagId: number) =>
|
||||
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
|
||||
const acceptSuggestion = (cid: string, tagId: number) => {
|
||||
// Optimistically drop it from the suggestions cache so the ghost chip vanishes at once; toggleTag
|
||||
// attaches the real tag (its onSuccess re-invalidates suggestions + the channel detail).
|
||||
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
|
||||
if (!old?.[cid]) return old;
|
||||
const rest = old[cid].filter((s) => s.tag_id !== tagId);
|
||||
const next = { ...old };
|
||||
if (rest.length) next[cid] = rest;
|
||||
else delete next[cid];
|
||||
return next;
|
||||
});
|
||||
toggleTag(cid, tagId, false);
|
||||
};
|
||||
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
|
||||
// the bulk action accepts.
|
||||
const visibleSuggestions = Object.keys(suggestions).flatMap((cid) =>
|
||||
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
|
||||
);
|
||||
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
|
||||
const acceptAllHighConfidence = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.tags.acceptAllTitle"),
|
||||
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
|
||||
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
|
||||
});
|
||||
if (!ok) return;
|
||||
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
|
||||
};
|
||||
|
||||
const syncSubs = useMutation({
|
||||
mutationFn: () => api.syncSubscriptions(),
|
||||
onSuccess: (r: { subscriptions?: number }) => {
|
||||
@@ -319,6 +383,9 @@ export default function Channels() {
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion: acceptSuggestion,
|
||||
onDismissSuggestion: dismissSuggestion,
|
||||
});
|
||||
|
||||
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||
@@ -474,6 +541,27 @@ export default function Channels() {
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
|
||||
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
|
||||
rest. Hidden when there's nothing to suggest. */}
|
||||
{visibleSuggestions.length > 0 && (
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
<Tooltip hint={t("channels.tags.suggestionsHint")}>
|
||||
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-accent/50 px-2 py-1 text-xs text-accent/90">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<button
|
||||
onClick={acceptAllHighConfidence}
|
||||
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0">{actionButtons}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import Tooltip from "./Tooltip";
|
||||
import { PriorityStepper } from "./PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
|
||||
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
||||
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
||||
@@ -166,6 +166,10 @@ export interface ChannelColumnDeps {
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
createAndAttachTag: (channelId: string, name: string) => void;
|
||||
onFilterByTag: (tagId: number, name: string) => void;
|
||||
// E8·S4 — suggested user tags per channel (resolved to Tag + confidence), plus accept/dismiss.
|
||||
suggestionsFor: (channelId: string) => SuggestedTag[];
|
||||
onAcceptSuggestion: (channelId: string, tagId: number) => void;
|
||||
onDismissSuggestion: (channelId: string, tagId: number) => void;
|
||||
}
|
||||
|
||||
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
|
||||
@@ -185,6 +189,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
} = deps;
|
||||
return [
|
||||
{
|
||||
@@ -306,6 +313,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
||||
onCreate={(name) => createAndAttachTag(c.id, name)}
|
||||
onChipClick={onFilterByTag}
|
||||
suggestions={suggestionsFor(c.id)}
|
||||
onAcceptSuggestion={(tagId) => onAcceptSuggestion(c.id, tagId)}
|
||||
onDismissSuggestion={(tagId) => onDismissSuggestion(c.id, tagId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -54,7 +54,19 @@
|
||||
"createFailed": "Could not create the tag.",
|
||||
"noTag": "No tag",
|
||||
"moreCount_one": "{{count}} more tag",
|
||||
"moreCount_other": "{{count}} more tags"
|
||||
"moreCount_other": "{{count}} more tags",
|
||||
"suggestedHint": "Suggested from your tagging pattern ({{pct}}% match) — click to add",
|
||||
"acceptSuggestion": "Add suggested tag {{name}}",
|
||||
"dismissSuggestion": "Dismiss suggestion",
|
||||
"suggestionsHint": "Tags suggested for your untagged channels, learned from how you've tagged similar ones. Click a ghost chip to add it, or × to dismiss.",
|
||||
"suggestionsCount_one": "{{count}} suggestion",
|
||||
"suggestionsCount_other": "{{count}} suggestions",
|
||||
"acceptAllHigh_one": "Accept {{count}} confident",
|
||||
"acceptAllHigh_other": "Accept {{count}} confident",
|
||||
"acceptAllTitle": "Accept suggested tags",
|
||||
"acceptAllConfirm_one": "Add the {{count}} high-confidence suggested tag to its channel?",
|
||||
"acceptAllConfirm_other": "Add the {{count}} high-confidence suggested tags to their channels?",
|
||||
"acceptAllConfirmCta": "Accept all"
|
||||
},
|
||||
"loading": "Loading channels…",
|
||||
"empty": "No channels.",
|
||||
|
||||
@@ -54,7 +54,19 @@
|
||||
"createFailed": "A címke létrehozása nem sikerült.",
|
||||
"noTag": "Nincs címke",
|
||||
"moreCount_one": "még {{count}} címke",
|
||||
"moreCount_other": "még {{count}} címke"
|
||||
"moreCount_other": "még {{count}} címke",
|
||||
"suggestedHint": "A címkézési mintád alapján javasolt ({{pct}}% egyezés) — kattints a hozzáadáshoz",
|
||||
"acceptSuggestion": "{{name}} javasolt címke hozzáadása",
|
||||
"dismissSuggestion": "Javaslat elvetése",
|
||||
"suggestionsHint": "A címkézetlen csatornáidhoz javasolt címkék, abból tanulva, hogyan címkézted a hasonlókat. Kattints egy szellem-chipre a hozzáadáshoz, vagy ×-re az elvetéshez.",
|
||||
"suggestionsCount_one": "{{count}} javaslat",
|
||||
"suggestionsCount_other": "{{count}} javaslat",
|
||||
"acceptAllHigh_one": "{{count}} biztos elfogadása",
|
||||
"acceptAllHigh_other": "{{count}} biztos elfogadása",
|
||||
"acceptAllTitle": "Javasolt címkék elfogadása",
|
||||
"acceptAllConfirm_one": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornájához?",
|
||||
"acceptAllConfirm_other": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornáikhoz?",
|
||||
"acceptAllConfirmCta": "Mind elfogadása"
|
||||
},
|
||||
"loading": "Csatornák betöltése…",
|
||||
"empty": "Nincsenek csatornák.",
|
||||
|
||||
@@ -10,8 +10,17 @@ export interface Tag {
|
||||
channel_count: number;
|
||||
}
|
||||
|
||||
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern (E8·S4).
|
||||
export interface TagSuggestion {
|
||||
tag_id: number;
|
||||
confidence: number;
|
||||
}
|
||||
// Keyed by channel id; channels with no suggestion are omitted by the server.
|
||||
export type TagSuggestions = Record<string, TagSuggestion[]>;
|
||||
|
||||
export const tagsApi = {
|
||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||
tagSuggestions: (): Promise<TagSuggestions> => req("/api/tags/suggestions"),
|
||||
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
|
||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
||||
|
||||
@@ -51,7 +51,13 @@ export function selectDateChip(
|
||||
}
|
||||
const days = Number(key);
|
||||
return {
|
||||
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined, year: undefined },
|
||||
filters: {
|
||||
...filters,
|
||||
maxAgeDays: days,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
year: undefined,
|
||||
},
|
||||
customOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export const qk = {
|
||||
me: () => ["me"] as const,
|
||||
myStatus: () => ["my-status"] as const,
|
||||
tags: () => ["tags"] as const,
|
||||
// Per-channel suggested user tags, learned from the manual-tag pattern (E8·S4). Separate root so
|
||||
// accepting a suggestion can invalidate it without touching the whole tag catalog.
|
||||
tagSuggestions: () => ["tag-suggestions"] as const,
|
||||
|
||||
// --- feed / video ---
|
||||
// Every parameterized key guards on `!== undefined` (not truthiness), so "no arg → bare prefix
|
||||
|
||||
Reference in New Issue
Block a user