feat: M3 — automatic channel tagging (language + topic system tags)
- Tag and ChannelTag models + migration 0003 (partial unique indexes split system vs per-user tag names) - Offline language detection (py3langid) constrained to a curated language set, with the channel's declared default language as a strong prior - Topic tags mapped from YouTube topicDetails + dominant video category; the generic "Lifestyle" catch-all is intentionally dropped - System (auto) tags are regenerated idempotently and never touch user tags; orphaned system tags are cleaned up - GET /api/tags and admin POST /api/tags/recompute; scheduled autotag job
This commit is contained in:
parent
64b18b3cc1
commit
68dad91e8a
9 changed files with 482 additions and 1 deletions
89
backend/alembic/versions/0003_tags.py
Normal file
89
backend/alembic/versions/0003_tags.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""tags and channel_tags
|
||||
|
||||
Revision ID: 0003_tags
|
||||
Revises: 0002_ingest_core
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0003_tags"
|
||||
down_revision: Union[str, None] = "0002_ingest_core"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("color", sa.String(length=16), nullable=True),
|
||||
sa.Column("category", sa.String(length=16), nullable=False, server_default="other"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_tags_user_id", "tags", ["user_id"])
|
||||
# System tag names unique among system tags; user tag names unique per user.
|
||||
op.create_index(
|
||||
"uq_tags_system_name",
|
||||
"tags",
|
||||
["name"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("user_id IS NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_tags_user_name",
|
||||
"tags",
|
||||
["user_id", "name"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("user_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"channel_tags",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"channel_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("channels.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"tag_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("tags.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("source", sa.String(length=8), nullable=False, server_default="auto"),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),
|
||||
)
|
||||
op.create_index("ix_channel_tags_channel_id", "channel_tags", ["channel_id"])
|
||||
op.create_index("ix_channel_tags_tag_id", "channel_tags", ["tag_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("channel_tags")
|
||||
op.drop_index("uq_tags_user_name", table_name="tags")
|
||||
op.drop_index("uq_tags_system_name", table_name="tags")
|
||||
op.drop_index("ix_tags_user_id", table_name="tags")
|
||||
op.drop_table("tags")
|
||||
Loading…
Add table
Add a link
Reference in a new issue