diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index b1008acd..0000cb7c 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -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( diff --git a/backend/app/sync/tagsuggest.py b/backend/app/sync/tagsuggest.py new file mode 100644 index 00000000..dd72d8f8 --- /dev/null +++ b/backend/app/sync/tagsuggest.py @@ -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 diff --git a/backend/tests/test_tag_suggest.py b/backend/tests/test_tag_suggest.py new file mode 100644 index 00000000..899e855d --- /dev/null +++ b/backend/tests/test_tag_suggest.py @@ -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) diff --git a/frontend/src/components/ChannelTags.suggest.dom.test.tsx b/frontend/src/components/ChannelTags.suggest.dom.test.tsx new file mode 100644 index 00000000..3730f5fe --- /dev/null +++ b/frontend/src/components/ChannelTags.suggest.dom.test.tsx @@ -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(); + 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[0]> = {}) { + const onAccept = vi.fn(); + const onDismiss = vi.fn(); + render( + + {}} + onCreate={() => {}} + suggestions={suggestions} + onAcceptSuggestion={onAccept} + onDismissSuggestion={onDismiss} + {...props} + /> + , + ); + 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); + }); +}); diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 3ed2017a..d8521ad4 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -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 ( + + + + + ); +} + /** 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} )} + {/* 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) => ( + onAcceptSuggestion?.(s.tag.id)} + onDismiss={() => onDismissSuggestion?.(s.tag.id)} + /> + ))} )} + {/* 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 && ( +
+ + + + {t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })} + + + {highConfidence.length > 0 && ( + + )} +
+ )}
{actionButtons}
diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index b8a1c865..f5104fdd 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -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[] { @@ -185,6 +189,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column 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)} /> ), }, diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 9cebcbec..af5efc67 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -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.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 6b1189e0..1450c60b 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -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.", diff --git a/frontend/src/lib/api/tags.ts b/frontend/src/lib/api/tags.ts index afb17edd..e1f9ec2b 100644 --- a/frontend/src/lib/api/tags.ts +++ b/frontend/src/lib/api/tags.ts @@ -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; + export const tagsApi = { tags: (): Promise => req("/api/tags"), + tagSuggestions: (): Promise => req("/api/tags/suggestions"), createTag: (t: { name: string; color?: string; category?: string }): Promise => req("/api/tags", { method: "POST", body: JSON.stringify(t) }), updateTag: (id: number, patch: { name?: string; color?: string }) => diff --git a/frontend/src/lib/dateFilter.ts b/frontend/src/lib/dateFilter.ts index 1f0684b7..a9dd594b 100644 --- a/frontend/src/lib/dateFilter.ts +++ b/frontend/src/lib/dateFilter.ts @@ -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, }; } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 703c322c..e690c564 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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