Spaces:
Sleeping
Sleeping
Sync from kink_cli (Docker Space)
Browse files- api.py +34 -7
- backend/catalog.py +57 -1
- models.py +13 -0
api.py
CHANGED
|
@@ -138,6 +138,25 @@ def _warn_or_fail_ephemeral_store(path: Path) -> None:
|
|
| 138 |
raise RuntimeError(msg)
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
def _get_backend() -> Backend:
|
| 142 |
"""Open SQLite + warm caches on first use so uvicorn can bind before a multi‑GB Hub download finishes."""
|
| 143 |
global _backend_impl
|
|
@@ -146,25 +165,33 @@ def _get_backend() -> Backend:
|
|
| 146 |
with _backend_lock:
|
| 147 |
if _backend_impl is not None:
|
| 148 |
return _backend_impl
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
| 151 |
_warn_or_fail_ephemeral_store(path)
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
# Catalog must be ready before the first recommendations request. Build it once here:
|
| 155 |
# starting a warm thread and then blocking can double-build on slow cpu-basic Spaces.
|
| 156 |
-
|
|
|
|
| 157 |
# PPR / full similarity graph warm is RAM-heavy on multi‑GB catalogs; skip only that on
|
| 158 |
# small Spaces (see Dockerfile KINK_SKIP_HEAVY_WARM).
|
| 159 |
if os.environ.get("KINK_SKIP_HEAVY_WARM", "").strip().lower() not in ("1", "true", "yes", "on"):
|
| 160 |
-
|
|
|
|
| 161 |
|
| 162 |
-
|
| 163 |
_backend_impl = b
|
| 164 |
if os.environ.get("KINK_BACKGROUND_PPR_WARM", "").strip().lower() in ("1", "true", "yes", "on"):
|
| 165 |
from backend.recsys_graph import warm_ppr_caches_in_background
|
| 166 |
|
| 167 |
warm_ppr_caches_in_background(b)
|
|
|
|
| 168 |
return b
|
| 169 |
|
| 170 |
|
|
|
|
| 138 |
raise RuntimeError(msg)
|
| 139 |
|
| 140 |
|
| 141 |
+
def _boot_phase(name: str) -> "_BootTimer":
|
| 142 |
+
return _BootTimer(name)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class _BootTimer:
|
| 146 |
+
def __init__(self, name: str) -> None:
|
| 147 |
+
self.name = name
|
| 148 |
+
self._t0 = 0.0
|
| 149 |
+
|
| 150 |
+
def __enter__(self) -> "_BootTimer":
|
| 151 |
+
self._t0 = time.monotonic()
|
| 152 |
+
return self
|
| 153 |
+
|
| 154 |
+
def __exit__(self, exc_type, exc, tb) -> None:
|
| 155 |
+
dt = time.monotonic() - self._t0
|
| 156 |
+
status = "err" if exc_type else "ok"
|
| 157 |
+
print(f"[kink_cli boot] phase={self.name} seconds={dt:.2f} status={status}", flush=True)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
def _get_backend() -> Backend:
|
| 161 |
"""Open SQLite + warm caches on first use so uvicorn can bind before a multi‑GB Hub download finishes."""
|
| 162 |
global _backend_impl
|
|
|
|
| 165 |
with _backend_lock:
|
| 166 |
if _backend_impl is not None:
|
| 167 |
return _backend_impl
|
| 168 |
+
total_t0 = time.monotonic()
|
| 169 |
+
with _boot_phase("media_cache"):
|
| 170 |
+
ensure_media_cache(MEDIA_ROOT)
|
| 171 |
+
with _boot_phase("ensure_store_db"):
|
| 172 |
+
path = ensure_store_db(_default_store)
|
| 173 |
_warn_or_fail_ephemeral_store(path)
|
| 174 |
+
with _boot_phase("user_snapshot_restore"):
|
| 175 |
+
_restore_user_snapshot_on_boot(path)
|
| 176 |
+
with _boot_phase("backend_ctor"):
|
| 177 |
+
b = Backend(path)
|
| 178 |
# Catalog must be ready before the first recommendations request. Build it once here:
|
| 179 |
# starting a warm thread and then blocking can double-build on slow cpu-basic Spaces.
|
| 180 |
+
with _boot_phase("catalog_build"):
|
| 181 |
+
b._catalog()
|
| 182 |
# PPR / full similarity graph warm is RAM-heavy on multi‑GB catalogs; skip only that on
|
| 183 |
# small Spaces (see Dockerfile KINK_SKIP_HEAVY_WARM).
|
| 184 |
if os.environ.get("KINK_SKIP_HEAVY_WARM", "").strip().lower() not in ("1", "true", "yes", "on"):
|
| 185 |
+
with _boot_phase("ppr_warm"):
|
| 186 |
+
from backend.recsys_graph import warm_ppr_caches
|
| 187 |
|
| 188 |
+
warm_ppr_caches(b)
|
| 189 |
_backend_impl = b
|
| 190 |
if os.environ.get("KINK_BACKGROUND_PPR_WARM", "").strip().lower() in ("1", "true", "yes", "on"):
|
| 191 |
from backend.recsys_graph import warm_ppr_caches_in_background
|
| 192 |
|
| 193 |
warm_ppr_caches_in_background(b)
|
| 194 |
+
print(f"[kink_cli boot] phase=total seconds={time.monotonic() - total_t0:.2f} status=ok", flush=True)
|
| 195 |
return b
|
| 196 |
|
| 197 |
|
backend/catalog.py
CHANGED
|
@@ -29,10 +29,60 @@ from models import (
|
|
| 29 |
FetlifePictureRef,
|
| 30 |
Kink,
|
| 31 |
KinkExample,
|
|
|
|
| 32 |
KinkScenarioParent,
|
| 33 |
SimilarityEdge,
|
| 34 |
)
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
_IMAGE_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
| 37 |
_IMAGE_RELEVANCE_STOPWORDS = {"and", "the", "play", "sex", "in", "with"}
|
| 38 |
_PRODUCT_FLAG_KEYS = (
|
|
@@ -603,6 +653,7 @@ def _mark_runtime_duplicates(
|
|
| 603 |
# "Cuddling after sex") that ``merge_signature``'s sorted-token-set misses. F1 0.80 on the
|
| 604 |
# hand-labeled set vs F1 0.29 for ``merge_signature`` alone — see scripts/merge_dedup_eval.py.
|
| 605 |
lemma_groups: defaultdict[tuple[str, str], list[str]] = defaultdict(list)
|
|
|
|
| 606 |
for kid, summary in candidates.items():
|
| 607 |
cluster = str(summary.get("cluster", "") or "")
|
| 608 |
name = str(summary.get("name", "") or "")
|
|
@@ -612,9 +663,14 @@ def _mark_runtime_duplicates(
|
|
| 612 |
cf = compact_form(name)
|
| 613 |
if len(cf) >= 8:
|
| 614 |
compact_groups[(cluster, cf)].append(kid)
|
| 615 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
if lemma_sig:
|
| 617 |
lemma_groups[(cluster, lemma_sig)].append(kid)
|
|
|
|
| 618 |
|
| 619 |
def _canonical_key(kid: str) -> tuple[int, int, float, int, str]:
|
| 620 |
"""Sort key — higher is better. Strict preference: has_definition first, then well-cased
|
|
|
|
| 29 |
FetlifePictureRef,
|
| 30 |
Kink,
|
| 31 |
KinkExample,
|
| 32 |
+
KinkLemma,
|
| 33 |
KinkScenarioParent,
|
| 34 |
SimilarityEdge,
|
| 35 |
)
|
| 36 |
|
| 37 |
+
|
| 38 |
+
def _lemma_name_hash(name: str) -> str:
|
| 39 |
+
"""Fingerprint a kink name so the lemma cache invalidates when the name changes."""
|
| 40 |
+
import hashlib
|
| 41 |
+
return hashlib.sha1(name.encode("utf-8")).hexdigest()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _load_cached_lemma_signatures(
|
| 45 |
+
self,
|
| 46 |
+
candidates: dict[str, dict[str, Any]],
|
| 47 |
+
) -> tuple[dict[str, str], list[tuple[str, str, str]]]:
|
| 48 |
+
"""Return (cached_lemmas_by_kid, empty_writebacks_list).
|
| 49 |
+
|
| 50 |
+
A cached row is only returned when its ``name_hash`` matches the current candidate name —
|
| 51 |
+
stale rows are silently ignored (caller will recompute and overwrite).
|
| 52 |
+
"""
|
| 53 |
+
try:
|
| 54 |
+
with self._sqlite() as conn:
|
| 55 |
+
rows = conn.execute("SELECT kink_id, name_hash, signature FROM kinklemma").fetchall()
|
| 56 |
+
except sqlite3.OperationalError:
|
| 57 |
+
return {}, []
|
| 58 |
+
cached: dict[str, str] = {}
|
| 59 |
+
for row in rows:
|
| 60 |
+
kid = row["kink_id"]
|
| 61 |
+
if kid not in candidates:
|
| 62 |
+
continue
|
| 63 |
+
if row["name_hash"] != _lemma_name_hash(str(candidates[kid].get("name", "") or "")):
|
| 64 |
+
continue
|
| 65 |
+
cached[kid] = row["signature"] or ""
|
| 66 |
+
return cached, []
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _persist_cached_lemma_signatures(
|
| 70 |
+
self,
|
| 71 |
+
rows: list[tuple[str, str, str]],
|
| 72 |
+
) -> None:
|
| 73 |
+
if not rows:
|
| 74 |
+
return
|
| 75 |
+
try:
|
| 76 |
+
with self._sqlite() as conn:
|
| 77 |
+
conn.executemany(
|
| 78 |
+
"INSERT INTO kinklemma (kink_id, name_hash, signature) VALUES (?, ?, ?) "
|
| 79 |
+
"ON CONFLICT(kink_id) DO UPDATE SET name_hash=excluded.name_hash, signature=excluded.signature",
|
| 80 |
+
rows,
|
| 81 |
+
)
|
| 82 |
+
conn.commit()
|
| 83 |
+
except sqlite3.OperationalError:
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
_IMAGE_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
| 87 |
_IMAGE_RELEVANCE_STOPWORDS = {"and", "the", "play", "sex", "in", "with"}
|
| 88 |
_PRODUCT_FLAG_KEYS = (
|
|
|
|
| 653 |
# "Cuddling after sex") that ``merge_signature``'s sorted-token-set misses. F1 0.80 on the
|
| 654 |
# hand-labeled set vs F1 0.29 for ``merge_signature`` alone — see scripts/merge_dedup_eval.py.
|
| 655 |
lemma_groups: defaultdict[tuple[str, str], list[str]] = defaultdict(list)
|
| 656 |
+
cached_lemmas, lemma_writebacks = _load_cached_lemma_signatures(self, candidates)
|
| 657 |
for kid, summary in candidates.items():
|
| 658 |
cluster = str(summary.get("cluster", "") or "")
|
| 659 |
name = str(summary.get("name", "") or "")
|
|
|
|
| 663 |
cf = compact_form(name)
|
| 664 |
if len(cf) >= 8:
|
| 665 |
compact_groups[(cluster, cf)].append(kid)
|
| 666 |
+
if kid in cached_lemmas:
|
| 667 |
+
lemma_sig = cached_lemmas[kid]
|
| 668 |
+
else:
|
| 669 |
+
lemma_sig = lemma_signature(name)
|
| 670 |
+
lemma_writebacks.append((kid, _lemma_name_hash(name), lemma_sig))
|
| 671 |
if lemma_sig:
|
| 672 |
lemma_groups[(cluster, lemma_sig)].append(kid)
|
| 673 |
+
_persist_cached_lemma_signatures(self, lemma_writebacks)
|
| 674 |
|
| 675 |
def _canonical_key(kid: str) -> tuple[int, int, float, int, str]:
|
| 676 |
"""Sort key — higher is better. Strict preference: has_definition first, then well-cased
|
models.py
CHANGED
|
@@ -151,6 +151,19 @@ class Alias(SQLModel, table=True):
|
|
| 151 |
source_id: str = Field(foreign_key="source.id")
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
class KinkFacet(SQLModel, table=True):
|
| 155 |
id: str = Field(primary_key=True)
|
| 156 |
kink_id: str = Field(foreign_key="kink.id", index=True)
|
|
|
|
| 151 |
source_id: str = Field(foreign_key="source.id")
|
| 152 |
|
| 153 |
|
| 154 |
+
class KinkLemma(SQLModel, table=True):
|
| 155 |
+
"""Per-kink cached spaCy lemma signature used by play-dedupe at catalog build.
|
| 156 |
+
|
| 157 |
+
Populated lazily on first build and persisted so subsequent boots avoid the ~55s
|
| 158 |
+
spaCy pass over the full catalog. `name_hash` is the kink name at the time the
|
| 159 |
+
signature was computed; a name change forces a recompute via the dedupe loop.
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
kink_id: str = Field(primary_key=True, foreign_key="kink.id")
|
| 163 |
+
name_hash: str
|
| 164 |
+
signature: str
|
| 165 |
+
|
| 166 |
+
|
| 167 |
class KinkFacet(SQLModel, table=True):
|
| 168 |
id: str = Field(primary_key=True)
|
| 169 |
kink_id: str = Field(foreign_key="kink.id", index=True)
|