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
+91 -3
View File
@@ -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>