Spaces:
Sleeping
Sleeping
| """Dataset export helpers for OTS recommender experiments. | |
| The app keeps its production rules in the backend; this module converts that state | |
| into standard implicit-feedback records that external recommender libraries can use. | |
| """ | |
| from __future__ import annotations | |
| from collections import defaultdict | |
| from dataclasses import dataclass, field | |
| from functools import cached_property | |
| from pathlib import Path | |
| from typing import Any | |
| from sqlmodel import Session, select | |
| from backend.constants import RATING_WEIGHTS | |
| from backend.discovery_lanes import discovery_lane_for_kink | |
| from backend.scenarios import scenario_title_fields | |
| from backend.scrape_artifacts import clean_scraped_catalog_text, clean_scraped_notes | |
| from models import ( | |
| FetlifeKinkMeta, | |
| FetlifeUserFetish, | |
| Kink, | |
| KinkContentType, | |
| KinkScenarioParent, | |
| PlayPreference, | |
| ScenarioPreference, | |
| ) | |
| APP_RATING_VALUES = { | |
| "love": 3.0, | |
| "like": 2.0, | |
| "curious": 1.0, | |
| "not_interested": 0.0, | |
| "hard_no": -2.0, | |
| } | |
| FETLIFE_BUCKET_VALUES = { | |
| "into": 2.0, | |
| "curious_about": 1.0, | |
| "soft_limits": -0.5, | |
| "hard_limits": -2.0, | |
| } | |
| class RecsysInteraction: | |
| user_id: str | |
| kink_id: str | |
| rating: float | |
| source: str | |
| timestamp: int | |
| class RecsysItem: | |
| kink_id: str | |
| name: str | |
| content_kind: str | |
| cluster: str | |
| lane: str | |
| popularity: float | |
| is_scenario: bool | |
| title_surface_as_scenario: bool | |
| shared_eligible: bool | |
| scenario_parent_ids: tuple[str, ...] = field(default_factory=tuple) | |
| class RecsysDataset: | |
| interactions: list[RecsysInteraction] | |
| items: dict[str, RecsysItem] | |
| def recommendable_item_ids(self) -> set[str]: | |
| return { | |
| item.kink_id | |
| for item in self.items.values() | |
| if item.content_kind == "play" | |
| and item.shared_eligible | |
| and not item.is_scenario | |
| and not item.title_surface_as_scenario | |
| } | |
| def canonical_item_ids(self, kink_id: str) -> tuple[str, ...]: | |
| item = self.items.get(kink_id) | |
| if not item: | |
| return () | |
| if item.is_scenario: | |
| parents = tuple(pid for pid in item.scenario_parent_ids if pid in self.recommendable_item_ids) | |
| return parents | |
| if kink_id in self.recommendable_item_ids: | |
| return (kink_id,) | |
| return () | |
| def positive_training_triples(self, *, min_rating: float = 0.5) -> list[tuple[str, str, float]]: | |
| """Return canonical positive interactions for OTS implicit-feedback models.""" | |
| by_pair: dict[tuple[str, str], float] = {} | |
| for row in self.interactions: | |
| if row.rating < min_rating: | |
| continue | |
| for item_id in self.canonical_item_ids(row.kink_id): | |
| key = (row.user_id, item_id) | |
| by_pair[key] = max(by_pair.get(key, 0.0), float(row.rating)) | |
| return [(user_id, item_id, rating) for (user_id, item_id), rating in sorted(by_pair.items())] | |
| def seen_item_ids_by_user(self, *, include_negative: bool = True) -> dict[str, set[str]]: | |
| seen: dict[str, set[str]] = {} | |
| for row in self.interactions: | |
| if not include_negative and row.rating <= 0: | |
| continue | |
| canonical_ids = self.canonical_item_ids(row.kink_id) | |
| if not canonical_ids: | |
| continue | |
| bucket = seen.setdefault(row.user_id, set()) | |
| bucket.update(canonical_ids) | |
| return seen | |
| def popularity_ranked_items(self) -> list[str]: | |
| return [ | |
| item.kink_id | |
| for item in sorted( | |
| self.items.values(), | |
| key=lambda item: (item.popularity, item.name.lower(), item.kink_id), | |
| reverse=True, | |
| ) | |
| if item.kink_id in self.recommendable_item_ids | |
| ] | |
| def _rating_value(state: str) -> float: | |
| if state in APP_RATING_VALUES: | |
| return APP_RATING_VALUES[state] | |
| return float(RATING_WEIGHTS.get(state, 0.0)) | |
| def _atomic_value(value: Any) -> str: | |
| return str(value if value is not None else "").replace("\t", " ").replace("\n", " ").strip() | |
| def _item_from_catalog_payload(kink: dict[str, Any]) -> RecsysItem: | |
| return RecsysItem( | |
| kink_id=str(kink["id"]), | |
| name=str(kink.get("name", "")), | |
| content_kind=str(kink.get("content_kind") or ""), | |
| cluster=str(kink.get("cluster") or ""), | |
| lane=discovery_lane_for_kink(kink), | |
| popularity=float(kink.get("popularity", 0.0) or 0.0), | |
| is_scenario=bool(kink.get("is_scenario")), | |
| title_surface_as_scenario=bool(kink.get("title_surface_as_scenario")), | |
| shared_eligible=bool(kink.get("shared_eligible")), | |
| scenario_parent_ids=tuple(str(pid) for pid in (kink.get("scenario_parent_ids") or [])), | |
| ) | |
| def _items_from_store(backend: Any) -> dict[str, RecsysItem]: | |
| with Session(backend.engine) as session: | |
| kinks = session.exec(select(Kink).order_by(Kink.id)).all() | |
| type_rows = {row.kink_id: row for row in session.exec(select(KinkContentType)).all()} | |
| meta_rows = {row.kink_id: row for row in session.exec(select(FetlifeKinkMeta)).all()} | |
| scenario_rows = session.exec(select(KinkScenarioParent)).all() | |
| parents_by_scenario: dict[str, list[str]] = defaultdict(list) | |
| for row in scenario_rows: | |
| parents_by_scenario[row.scenario_kink_id].append(row.parent_kink_id) | |
| items: dict[str, RecsysItem] = {} | |
| for kink in kinks: | |
| meta = meta_rows.get(kink.id) | |
| source_backed_popularity = float(meta.popularity) if meta else 0.0 | |
| raw_notes = kink.notes.strip() | |
| display_notes = clean_scraped_notes(kink.id, kink.name, raw_notes) | |
| definition = clean_scraped_catalog_text(kink.name, kink.short_definition) | |
| explicit_popularity = float(backend._extract_popularity_from_notes(raw_notes) or 0.0) | |
| popularity = source_backed_popularity or explicit_popularity | |
| type_row = type_rows.get(kink.id) | |
| base_payload = { | |
| "id": kink.id, | |
| "name": kink.name, | |
| "cluster": kink.cluster, | |
| "content_kind": type_row.content_kind if type_row else "", | |
| "definition": definition, | |
| "summary": definition, | |
| "notes": display_notes, | |
| "popularity": popularity, | |
| "source_backed_popularity": source_backed_popularity, | |
| "similar_count": int(meta.similar_count) if meta else 0, | |
| "filtered_asset_count": 0, | |
| "raw_asset_count": 0, | |
| } | |
| content_kind = base_payload["content_kind"] or backend._content_kind(base_payload) | |
| base_payload["content_kind"] = content_kind | |
| scenario_parent_ids = tuple(sorted(parents_by_scenario.get(kink.id, []))) | |
| is_scenario = bool(scenario_parent_ids) | |
| _, title_surface = ( | |
| scenario_title_fields(kink.name) if content_kind == "play" else (0.0, False) | |
| ) | |
| flags = backend._derived_product_flags(base_payload) | |
| shared_eligible = bool(flags.get("shared_eligible")) | |
| if is_scenario or title_surface: | |
| shared_eligible = False | |
| items[kink.id] = RecsysItem( | |
| kink_id=kink.id, | |
| name=kink.name, | |
| content_kind=str(content_kind), | |
| cluster=kink.cluster, | |
| lane=discovery_lane_for_kink(base_payload), | |
| popularity=popularity, | |
| is_scenario=is_scenario, | |
| title_surface_as_scenario=title_surface, | |
| shared_eligible=shared_eligible, | |
| scenario_parent_ids=scenario_parent_ids, | |
| ) | |
| return items | |
| def export_recsys_dataset( | |
| backend: Any, | |
| *, | |
| include_fetlife_samples: bool = True, | |
| include_scenario_preferences: bool = True, | |
| ) -> RecsysDataset: | |
| items = _items_from_store(backend) | |
| interactions: list[RecsysInteraction] = [] | |
| ts = 1 | |
| with Session(backend.engine) as session: | |
| for row in session.exec(select(PlayPreference).order_by(PlayPreference.user_id, PlayPreference.kink_id)).all(): | |
| if row.kink_id not in items: | |
| continue | |
| interactions.append( | |
| RecsysInteraction(row.user_id, row.kink_id, _rating_value(row.interest_state), "play_preference", ts) | |
| ) | |
| ts += 1 | |
| if include_scenario_preferences: | |
| for row in session.exec( | |
| select(ScenarioPreference).order_by( | |
| ScenarioPreference.user_id, | |
| ScenarioPreference.parent_kink_id, | |
| ScenarioPreference.scenario_kink_id, | |
| ) | |
| ).all(): | |
| rating = _rating_value(row.interest_state) | |
| if row.scenario_kink_id in items: | |
| interactions.append( | |
| RecsysInteraction( | |
| row.user_id, | |
| row.scenario_kink_id, | |
| rating, | |
| "scenario_preference", | |
| ts, | |
| ) | |
| ) | |
| ts += 1 | |
| if row.parent_kink_id in items: | |
| interactions.append( | |
| RecsysInteraction( | |
| row.user_id, | |
| row.parent_kink_id, | |
| rating * 0.75, | |
| "scenario_parent_signal", | |
| ts, | |
| ) | |
| ) | |
| ts += 1 | |
| if include_fetlife_samples: | |
| for row in session.exec( | |
| select(FetlifeUserFetish).order_by(FetlifeUserFetish.nickname, FetlifeUserFetish.kink_id) | |
| ).all(): | |
| if row.kink_id not in items: | |
| continue | |
| rating = FETLIFE_BUCKET_VALUES.get(row.bucket, 0.0) | |
| interactions.append( | |
| RecsysInteraction( | |
| f"fetlife:{row.nickname}", | |
| row.kink_id, | |
| rating, | |
| f"fetlife_{row.bucket}", | |
| ts, | |
| ) | |
| ) | |
| ts += 1 | |
| return RecsysDataset(interactions=interactions, items=items) | |
| def write_recbole_atomic_files( | |
| dataset: RecsysDataset, | |
| output_dir: Path, | |
| *, | |
| dataset_name: str = "kink_cli", | |
| ) -> dict[str, Path]: | |
| """Write RecBole-compatible atomic files for external training environments.""" | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| inter_path = output_dir / f"{dataset_name}.inter" | |
| item_path = output_dir / f"{dataset_name}.item" | |
| with inter_path.open("w", encoding="utf-8") as fh: | |
| fh.write("user_id:token\titem_id:token\trating:float\ttimestamp:float\tsource:token\n") | |
| for row in dataset.interactions: | |
| fh.write( | |
| "\t".join( | |
| [ | |
| _atomic_value(row.user_id), | |
| _atomic_value(row.kink_id), | |
| f"{row.rating:.6g}", | |
| str(row.timestamp), | |
| _atomic_value(row.source), | |
| ] | |
| ) | |
| + "\n" | |
| ) | |
| with item_path.open("w", encoding="utf-8") as fh: | |
| fh.write( | |
| "item_id:token\tname:token_seq\tcontent_kind:token\tcluster:token\tlane:token\t" | |
| "popularity:float\tis_scenario:float\tshared_eligible:float\tparent_ids:token_seq\n" | |
| ) | |
| for item in sorted(dataset.items.values(), key=lambda item: item.kink_id): | |
| fh.write( | |
| "\t".join( | |
| [ | |
| _atomic_value(item.kink_id), | |
| _atomic_value(item.name), | |
| _atomic_value(item.content_kind), | |
| _atomic_value(item.cluster), | |
| _atomic_value(item.lane), | |
| f"{item.popularity:.6g}", | |
| "1" if item.is_scenario else "0", | |
| "1" if item.shared_eligible else "0", | |
| _atomic_value(" ".join(item.scenario_parent_ids)), | |
| ] | |
| ) | |
| + "\n" | |
| ) | |
| return {"interactions": inter_path, "items": item_path} | |