Compare commits
No commits in common. "8e40993c4f6eb2ad0dd960a70f2521ed646aafe4" and "f97369a7642635f3e3f87bb09eb5c612ad3ecba4" have entirely different histories.
8e40993c4f
...
f97369a764
3 changed files with 1 additions and 201 deletions
|
|
@ -1,32 +0,0 @@
|
|||
"""tags: functional index on alnum-normalized name (variant collapse)
|
||||
|
||||
Revision ID: 0028_tags_name_altkey_index
|
||||
Revises: 0027_playback_event_error_detail
|
||||
Create Date: 2026-07-21
|
||||
|
||||
`resolve_tag` zwija warianty pisowni tego samego tagu (case/spacja/myślnik/sklejenie
|
||||
+ mojibake) do istniejącego kanonicznego po alnum-kluczu nazwy. Lookup potrzebuje
|
||||
indeksu funkcyjnego, inaczej seq-scan po ~180k tagach przy każdym miss-slug.
|
||||
|
||||
Idempotentne (IF NOT EXISTS): prod dostaje indeks ręcznym CREATE INDEX CONCURRENTLY
|
||||
(bez locka) zanim/gdy ta migracja powstaje; guard chroni przed re-runem.
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0028_tags_name_altkey_index"
|
||||
down_revision: str | None = "0027_playback_event_error_detail"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_tags_name_altkey "
|
||||
"ON tags (regexp_replace(lower(btrim(name)), '[^a-z0-9]', '', 'g'))"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_tags_name_altkey")
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
"""Resolver tagów. Tag identyfikuje slug (case-insensitive)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -12,35 +11,6 @@ from app.models.tag import Tag
|
|||
from app.normalize.scenes import NormalizedTag
|
||||
from app.normalize.text import slugify
|
||||
|
||||
_ALTKEY_RE = re.compile(r"[^a-z0-9]")
|
||||
|
||||
|
||||
def _altkey(value: str) -> str:
|
||||
"""Alnum-klucz nazwy tagu: lower + usunięcie wszystkiego poza [a-z0-9]. Zwija
|
||||
warianty pisowni tego samego pojęcia (Blowjob/Blow Job/Bigtits, mojibake CJK →
|
||||
czysty rdzeń). Musi być zgodny z regexp w scripts/merge_altkey_tags.py + indeksem
|
||||
ix_tags_name_altkey."""
|
||||
return _ALTKEY_RE.sub("", value.strip().lower())
|
||||
|
||||
|
||||
def _resolve_by_altkey(session: Session, name: str) -> Tag | None:
|
||||
"""Znajdź istniejący kanoniczny tag o tym samym alnum-kluczu nazwy (największy
|
||||
scene_count wygrywa). Zapobiega regeneracji duplikatów: nowy wariant "Deep Throat"
|
||||
trafia do istniejącego "deepthroat" zamiast tworzyć osobny tag. Używa indeksu
|
||||
funkcyjnego ix_tags_name_altkey."""
|
||||
key = _altkey(name)
|
||||
if not key:
|
||||
return None
|
||||
return (
|
||||
session.execute(
|
||||
select(Tag)
|
||||
.where(func.regexp_replace(func.lower(func.btrim(Tag.name)), "[^a-z0-9]", "", "g") == key)
|
||||
.order_by(Tag.scene_count.desc())
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _canonical_dup2_slug(session: Session, slug: str) -> str:
|
||||
"""Kanonizuje numbered-duplicate slug `<base>2` → `<base>`.
|
||||
|
|
@ -72,12 +42,6 @@ def resolve_tag(session: Session, *, norm: NormalizedTag) -> Tag | None:
|
|||
return tag
|
||||
|
||||
name = (norm.name or "").strip() or slug.replace("-", " ").title()
|
||||
# Prewencja regeneracji duplikatów: zanim utworzymy nowy tag, sprawdź czy istnieje
|
||||
# kanoniczny o tym samym alnum-kluczu nazwy (wariant pisowni). Jeśli tak, reużyj go.
|
||||
# (One-time backfill istniejących dup: scripts/merge_altkey_tags.py.)
|
||||
existing = _resolve_by_altkey(session, name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
if len(name) > 120:
|
||||
name = name[:120]
|
||||
# Concurrent insert race: worker scraper + API enrich_tags_from_tube oba
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
"""Bulk-merge wariantów tego samego tagu różniących się tylko wielkością liter,
|
||||
spacją, myślnikiem albo sklejeniem słów.
|
||||
|
||||
Kontekst: `resolve_tag` bierze slug ze źródła albo `slugify(name)` verbatim, więc
|
||||
te same kategorie z różnych tubów tworzą OSOBNE tagi:
|
||||
"Blowjob"/"Blow Job", "Big Tits"/"Bigtits", "Deepthroat"/"Deep Throat",
|
||||
"Doggystyle"/"Doggy Style", "POV"x3. Przy scene_count>=30 to ~376 nadmiarowych tagów.
|
||||
|
||||
Klucz scalania = alnum-normalizacja nazwy: lower + usunięcie WSZYSTKICH nie-alfanum
|
||||
znaków. Łączy TYLKO warianty pisowni (case/spacja/myślnik/sklejenie), NIE łączy
|
||||
różnych pojęć (inny klucz) ani liczby poj./mn. (nie ścinamy 's'). Kanoniczny w
|
||||
klastrze = tag o największym scene_count. Reszta scalona: scene_tags + movie_tags +
|
||||
blacklisted_tags przepisane (dedup na PK), dropy skasowane, scene_count odświeżony.
|
||||
|
||||
Prewencja regeneracji: `app/resolve/tag_resolver.py` (_canonical_altkey_slug) +
|
||||
indeks funkcyjny `ix_tags_altkey`.
|
||||
|
||||
Użycie:
|
||||
python scripts/merge_altkey_tags.py [--dry-run] [--min-count N]
|
||||
--min-count: scalaj tylko klastry, gdzie kanoniczny ma >= N scen (domyślnie 0 = wszystkie).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import session_scope
|
||||
|
||||
log = logging.getLogger("merge_altkey_tags")
|
||||
|
||||
# drop→keep: w każdym klastrze o wspólnym alnum-kluczu kanoniczny = max scene_count
|
||||
# (tie-break: id), reszta to dropy. Pomijamy pusty klucz (nazwy bez alfanum).
|
||||
_DUP_MAP_SQL = """
|
||||
WITH norm AS (
|
||||
SELECT id, slug, name, scene_count,
|
||||
regexp_replace(lower(btrim(name)), '[^a-z0-9]', '', 'g') AS k
|
||||
FROM tags
|
||||
),
|
||||
ranked AS (
|
||||
SELECT id, slug, scene_count, k,
|
||||
first_value(id) OVER (PARTITION BY k ORDER BY scene_count DESC, id) AS keep_id
|
||||
FROM norm
|
||||
WHERE k <> ''
|
||||
)
|
||||
SELECT r.id AS drop_id, r.slug AS drop_slug, r.scene_count AS drop_cnt,
|
||||
b.id AS keep_id, b.slug AS keep_slug, b.scene_count AS keep_cnt
|
||||
FROM ranked r
|
||||
JOIN tags b ON b.id = r.keep_id
|
||||
WHERE r.id <> r.keep_id
|
||||
AND b.scene_count >= :min_count
|
||||
ORDER BY b.scene_count DESC
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--min-count", type=int, default=0)
|
||||
args = ap.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
with session_scope() as s:
|
||||
pairs = list(s.execute(text(_DUP_MAP_SQL), {"min_count": args.min_count}))
|
||||
log.info("found %d drop tags across clusters (min_count=%d)", len(pairs), args.min_count)
|
||||
for p in pairs[:80]:
|
||||
log.info(" %-34s (%6d) -> %-30s (%6d)", p.drop_slug, p.drop_cnt, p.keep_slug, p.keep_cnt)
|
||||
if len(pairs) > 80:
|
||||
log.info(" ... (%d more)", len(pairs) - 80)
|
||||
if not pairs:
|
||||
return
|
||||
|
||||
s.execute(
|
||||
text(
|
||||
"CREATE TEMP TABLE _dup_map ON COMMIT DROP AS " + _DUP_MAP_SQL
|
||||
),
|
||||
{"min_count": args.min_count},
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
n = s.execute(text("SELECT count(*) FROM scene_tags st JOIN _dup_map m ON st.tag_id=m.drop_id")).scalar()
|
||||
nm = s.execute(text("SELECT count(*) FROM movie_tags mt JOIN _dup_map m ON mt.tag_id=m.drop_id")).scalar()
|
||||
log.info("DRY-RUN: would touch %d scene_tags + %d movie_tags, drop %d tags", n, nm, len(pairs))
|
||||
s.rollback()
|
||||
return
|
||||
|
||||
# UWAGA: NIE robimy UPDATE tag_id (kolizja PK gdy scena ma DWA warianty tego
|
||||
# samego klastra → oba UPDATE na keep = duplikat (scene_id, keep)). Zamiast tego
|
||||
# DODAJEMY membership kanonicznego (DISTINCT + ON CONFLICT), a wiersze dropów
|
||||
# sprzątnie CASCADE przy DELETE FROM tags. Kolizyjno-odporne.
|
||||
# EXISTS-guard na keep: gdyby kanoniczny zniknął między budową _dup_map a INSERT
|
||||
# (współbieżny ingest/cleanup), pomijamy zamiast walić FK violation (całość rollback).
|
||||
r1 = s.execute(text("""
|
||||
INSERT INTO scene_tags (scene_id, tag_id)
|
||||
SELECT DISTINCT st.scene_id, m.keep_id
|
||||
FROM scene_tags st JOIN _dup_map m ON st.tag_id = m.drop_id
|
||||
WHERE EXISTS (SELECT 1 FROM tags kt WHERE kt.id = m.keep_id)
|
||||
ON CONFLICT (scene_id, tag_id) DO NOTHING
|
||||
"""))
|
||||
log.info("scene_tags keep-memberships inserted: %d", r1.rowcount)
|
||||
|
||||
r2 = s.execute(text("""
|
||||
INSERT INTO movie_tags (movie_id, tag_id)
|
||||
SELECT DISTINCT mt.movie_id, m.keep_id
|
||||
FROM movie_tags mt JOIN _dup_map m ON mt.tag_id = m.drop_id
|
||||
WHERE EXISTS (SELECT 1 FROM tags kt WHERE kt.id = m.keep_id)
|
||||
ON CONFLICT (movie_id, tag_id) DO NOTHING
|
||||
"""))
|
||||
log.info("movie_tags keep-memberships inserted: %d", r2.rowcount)
|
||||
|
||||
# blacklisted_tags PK = (device_id, tag_id) → przenieś ban z dropa na kanoniczny per device.
|
||||
r3 = s.execute(text("""
|
||||
INSERT INTO blacklisted_tags (device_id, tag_id)
|
||||
SELECT bt.device_id, m.keep_id
|
||||
FROM blacklisted_tags bt JOIN _dup_map m ON bt.tag_id = m.drop_id
|
||||
ON CONFLICT DO NOTHING
|
||||
"""))
|
||||
if r3.rowcount:
|
||||
log.info("blacklist refs moved: %d", r3.rowcount)
|
||||
|
||||
rd = s.execute(text("DELETE FROM tags WHERE id IN (SELECT drop_id FROM _dup_map)"))
|
||||
log.info("dup tags deleted: %d", rd.rowcount)
|
||||
s.commit()
|
||||
|
||||
from app.scheduler.taxonomy_counts import refresh_taxonomy_counts
|
||||
changed = refresh_taxonomy_counts()
|
||||
log.info("taxonomy counts refreshed: %s", changed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue