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:
npeter83 2026-06-11 01:57:19 +02:00
parent 64b18b3cc1
commit 68dad91e8a
9 changed files with 482 additions and 1 deletions

View 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")