Spaces:
Running
Running
Sync from kink_cli (Docker Space)
Browse files
backend/recommendations.py
CHANGED
|
@@ -530,6 +530,8 @@ def _personalized_recommendations(
|
|
| 530 |
continue
|
| 531 |
if not kink.get("shared_eligible"):
|
| 532 |
continue
|
|
|
|
|
|
|
| 533 |
score = raw_score
|
| 534 |
score += min(kink["popularity"] / 60000.0, 0.2)
|
| 535 |
score -= self._discoverability_penalty(kink["cluster"])
|
|
|
|
| 530 |
continue
|
| 531 |
if not kink.get("shared_eligible"):
|
| 532 |
continue
|
| 533 |
+
if play_excluded_from_surfacing(kink):
|
| 534 |
+
continue
|
| 535 |
score = raw_score
|
| 536 |
score += min(kink["popularity"] / 60000.0, 0.2)
|
| 537 |
score -= self._discoverability_penalty(kink["cluster"])
|
scripts/discover_walkthrough.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Drive Discover the way a real user would, flagging weird kinks card-by-card.
|
| 3 |
+
|
| 4 |
+
Loops:
|
| 5 |
+
1. Fetch the next recommendation card from ``GET /users/{id}/recommendations``.
|
| 6 |
+
2. Inspect the card (name, content_kind, definition, summary, popularity, etc.).
|
| 7 |
+
3. Pick a reaction with a deterministic strategy AND record any anomalies that a real user
|
| 8 |
+
would notice (long prose names, all-lowercase short names, empty descriptions, suspected
|
| 9 |
+
duplicates of an earlier card, geo/city noise, scenario-shaped titles, etc.).
|
| 10 |
+
4. Save the play via ``POST /users/{id}/plays``.
|
| 11 |
+
5. Repeat ``--turns`` times.
|
| 12 |
+
|
| 13 |
+
End-of-run, write a JSON report and a terse summary so we can fix any patterns we see.
|
| 14 |
+
|
| 15 |
+
Examples:
|
| 16 |
+
python scripts/discover_walkthrough.py --base-url https://perplexed7675-kink-discovery.hf.space --turns 120
|
| 17 |
+
python scripts/discover_walkthrough.py --base-url http://127.0.0.1:8012 --turns 60 --strategy automated
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import json
|
| 23 |
+
import re
|
| 24 |
+
import sys
|
| 25 |
+
import time
|
| 26 |
+
import urllib.error
|
| 27 |
+
import urllib.parse
|
| 28 |
+
import urllib.request
|
| 29 |
+
from collections import Counter, defaultdict
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
DEFAULT_BASE_URL = "http://127.0.0.1:8012"
|
| 34 |
+
HTTP_TIMEOUT_S = 60
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class Card:
|
| 39 |
+
kink_id: str
|
| 40 |
+
name: str
|
| 41 |
+
content_kind: str
|
| 42 |
+
is_scenario: bool
|
| 43 |
+
summary: str
|
| 44 |
+
detail_summary: str
|
| 45 |
+
definition: str
|
| 46 |
+
notes: str
|
| 47 |
+
popularity: float
|
| 48 |
+
cluster: str
|
| 49 |
+
discovery_source: str
|
| 50 |
+
reasons: list[str]
|
| 51 |
+
asset_count: int
|
| 52 |
+
raw: dict[str, object]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class Anomaly:
|
| 57 |
+
code: str
|
| 58 |
+
detail: str
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class Turn:
|
| 63 |
+
n: int
|
| 64 |
+
card: Card
|
| 65 |
+
rating: str
|
| 66 |
+
directions: list[str]
|
| 67 |
+
anomalies: list[Anomaly] = field(default_factory=list)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _http(method: str, url: str, *, headers: dict[str, str] | None = None, body: bytes | None = None) -> tuple[int, dict[str, object]]:
|
| 71 |
+
request = urllib.request.Request(url, data=body, method=method, headers=headers or {})
|
| 72 |
+
try:
|
| 73 |
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
| 74 |
+
raw = response.read().decode("utf-8")
|
| 75 |
+
payload = json.loads(raw) if raw else {}
|
| 76 |
+
return response.status, payload
|
| 77 |
+
except urllib.error.HTTPError as exc:
|
| 78 |
+
text = exc.read().decode("utf-8", errors="ignore") if exc.fp else ""
|
| 79 |
+
try:
|
| 80 |
+
payload = json.loads(text) if text else {"error": str(exc)}
|
| 81 |
+
except json.JSONDecodeError:
|
| 82 |
+
payload = {"error": text}
|
| 83 |
+
return exc.code, payload
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _post_json(base: str, path: str, body: dict, headers: dict[str, str]) -> tuple[int, dict[str, object]]:
|
| 87 |
+
return _http("POST", base + path, headers={**headers, "Content-Type": "application/json"}, body=json.dumps(body).encode("utf-8"))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _get_json(base: str, path: str, headers: dict[str, str] | None = None) -> tuple[int, dict[str, object]]:
|
| 91 |
+
return _http("GET", base + path, headers=headers)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def create_user(base: str) -> tuple[str, str]:
|
| 95 |
+
code, payload = _post_json(base, "/users", {}, headers={})
|
| 96 |
+
if code != 200:
|
| 97 |
+
raise RuntimeError(f"create_user failed: {code} {payload}")
|
| 98 |
+
return str(payload["id"]), str(payload["private_token"])
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def get_user(base: str, uid: str, token: str) -> dict[str, object]:
|
| 102 |
+
code, payload = _get_json(base, f"/users/{urllib.parse.quote(uid)}", headers={"x-private-token": token})
|
| 103 |
+
if code != 200:
|
| 104 |
+
raise RuntimeError(f"get_user failed: {code} {payload}")
|
| 105 |
+
return payload
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def get_recommendations(base: str, uid: str, token: str, limit: int = 24) -> list[dict[str, object]]:
|
| 109 |
+
code, payload = _get_json(base, f"/users/{urllib.parse.quote(uid)}/recommendations?limit={limit}", headers={"x-private-token": token})
|
| 110 |
+
if code != 200:
|
| 111 |
+
raise RuntimeError(f"recommendations failed: {code} {payload}")
|
| 112 |
+
return list(payload.get("items") or [])
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def save_play(base: str, uid: str, token: str, kink_id: str, rating: str, directions: list[str]) -> int:
|
| 116 |
+
code, _ = _post_json(
|
| 117 |
+
base,
|
| 118 |
+
f"/users/{urllib.parse.quote(uid)}/plays",
|
| 119 |
+
{"kink_id": kink_id, "interest_state": rating, "directions": directions},
|
| 120 |
+
headers={"x-private-token": token},
|
| 121 |
+
)
|
| 122 |
+
return code
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
_GEO_TOKENS = {
|
| 126 |
+
"abu", "amsterdam", "atlanta", "austin", "bangkok", "barcelona", "berlin", "boston", "brooklyn",
|
| 127 |
+
"buenos", "cairo", "calgary", "charlotte", "chicago", "cincinnati", "cleveland", "columbus",
|
| 128 |
+
"copenhagen", "dallas", "denver", "detroit", "dubai", "dublin", "edinburgh", "edmonton",
|
| 129 |
+
"frankfurt", "geneva", "glasgow", "helsinki", "hong", "houston", "indianapolis", "istanbul",
|
| 130 |
+
"jacksonville", "johannesburg", "kansas", "kolkata", "kuala", "vegas", "lisbon", "london",
|
| 131 |
+
"louisville", "madrid", "manchester", "manhattan", "melbourne", "memphis", "miami", "milan",
|
| 132 |
+
"milwaukee", "minneapolis", "montreal", "moscow", "mumbai", "nashville", "delhi", "orleans",
|
| 133 |
+
"york", "oakland", "omaha", "orlando", "oslo", "ottawa", "paris", "philadelphia", "pittsburgh",
|
| 134 |
+
"portland", "prague", "raleigh", "sacramento", "antonio", "diego", "francisco", "jose", "sao",
|
| 135 |
+
"seattle", "shanghai", "singapore", "stockholm", "sydney", "tampa", "tokyo", "toronto", "tulsa",
|
| 136 |
+
"vancouver", "vienna", "warsaw", "winnipeg", "zurich",
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
_PROFILE_SENTENCE_STARTS = ("i love ", "i like ", "i want ", "i would ", "i enjoy ")
|
| 140 |
+
_BAD_TYPOGRAPHY_RE = re.compile(r"_(?=[A-Za-z])|(?<=[A-Za-z])_")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _norm_compact(s: str) -> str:
|
| 144 |
+
return re.sub(r"[^a-z0-9]", "", s.lower())
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def detect_anomalies(card: Card, prior_compacts: dict[str, str]) -> list[Anomaly]:
|
| 148 |
+
out: list[Anomaly] = []
|
| 149 |
+
name = card.name or ""
|
| 150 |
+
lower = name.lower().strip()
|
| 151 |
+
|
| 152 |
+
if not name.strip():
|
| 153 |
+
out.append(Anomaly("empty_name", "card has no name"))
|
| 154 |
+
return out
|
| 155 |
+
|
| 156 |
+
if card.is_scenario:
|
| 157 |
+
out.append(Anomaly("scenario_in_deck", "is_scenario=True surfaced in Discover"))
|
| 158 |
+
|
| 159 |
+
if card.content_kind and card.content_kind != "play":
|
| 160 |
+
out.append(Anomaly("non_play_content_kind", f"content_kind={card.content_kind!r}"))
|
| 161 |
+
|
| 162 |
+
if len(name) > 40:
|
| 163 |
+
out.append(Anomaly("long_name", f"name length {len(name)} chars — likely scenario or prose"))
|
| 164 |
+
|
| 165 |
+
word_count = len(re.findall(r"[A-Za-z0-9]+", name))
|
| 166 |
+
if len(name) >= 35 and word_count >= 5:
|
| 167 |
+
out.append(Anomaly("prose_shape", f"{word_count} words / {len(name)} chars — reads like a sentence"))
|
| 168 |
+
|
| 169 |
+
if any(lower.startswith(p) for p in _PROFILE_SENTENCE_STARTS):
|
| 170 |
+
out.append(Anomaly("profile_sentence", f"name starts with {lower.split()[0]!r} — profile prose"))
|
| 171 |
+
|
| 172 |
+
# Casing oddities (excluding acronyms which are intentionally upper).
|
| 173 |
+
alpha_only = re.sub(r"[^A-Za-z]", "", name)
|
| 174 |
+
if alpha_only and len(alpha_only) >= 6:
|
| 175 |
+
if alpha_only == alpha_only.lower():
|
| 176 |
+
out.append(Anomaly("all_lowercase", "no caps anywhere — looks like raw user-typed entry"))
|
| 177 |
+
elif alpha_only == alpha_only.upper() and word_count >= 2:
|
| 178 |
+
out.append(Anomaly("all_uppercase", "all-caps multi-word name"))
|
| 179 |
+
|
| 180 |
+
if _BAD_TYPOGRAPHY_RE.search(name):
|
| 181 |
+
out.append(Anomaly("underscored_name", "underscores between letters (profile-export shape)"))
|
| 182 |
+
|
| 183 |
+
# Geo/city noise
|
| 184 |
+
name_tokens = set(re.findall(r"[a-z]+", lower))
|
| 185 |
+
if name_tokens & _GEO_TOKENS:
|
| 186 |
+
out.append(Anomaly("geo_token", "name contains a city/region token"))
|
| 187 |
+
|
| 188 |
+
# Empty body — both summary and detail_summary blank → no help text shown to user.
|
| 189 |
+
if not card.summary.strip() and not card.detail_summary.strip() and not card.definition.strip():
|
| 190 |
+
out.append(Anomaly("no_body_text", "no summary / detail_summary / definition for this card"))
|
| 191 |
+
|
| 192 |
+
# Definition shows up in expanded mode; short summary in collapsed. If they're identical,
|
| 193 |
+
# tap-for-full-info gains nothing.
|
| 194 |
+
if card.definition.strip() and card.summary.strip() and card.definition.strip() == card.summary.strip():
|
| 195 |
+
out.append(Anomaly("expand_no_op", "definition equals summary — Tap for full info reveals nothing more"))
|
| 196 |
+
|
| 197 |
+
# Suspected duplicate of an already-seen card.
|
| 198 |
+
cf = _norm_compact(name)
|
| 199 |
+
if len(cf) >= 8:
|
| 200 |
+
if cf in prior_compacts and prior_compacts[cf] != card.kink_id:
|
| 201 |
+
out.append(Anomaly("duplicate_of_prior", f"compact_form matches prior card {prior_compacts[cf]!r}"))
|
| 202 |
+
else:
|
| 203 |
+
prior_compacts[cf] = card.kink_id
|
| 204 |
+
|
| 205 |
+
return out
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def render_card_summary(card: Card) -> str:
|
| 209 |
+
bits = [
|
| 210 |
+
f"#{card.kink_id:<22} {card.name}",
|
| 211 |
+
f" popularity={card.popularity:.0f} asset_count={card.asset_count} source={card.discovery_source}",
|
| 212 |
+
]
|
| 213 |
+
if card.definition.strip():
|
| 214 |
+
bits.append(f" def: {card.definition.strip()[:200]}")
|
| 215 |
+
elif card.summary.strip():
|
| 216 |
+
bits.append(f" sum: {card.summary.strip()[:200]}")
|
| 217 |
+
else:
|
| 218 |
+
bits.append(" (no description)")
|
| 219 |
+
if card.reasons:
|
| 220 |
+
bits.append(f" why: {' / '.join(card.reasons[:2])}")
|
| 221 |
+
return "\n".join(bits)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def card_from_item(item: dict[str, object]) -> Card:
|
| 225 |
+
k = item.get("kink") or {}
|
| 226 |
+
return Card(
|
| 227 |
+
kink_id=str(k.get("id", "")),
|
| 228 |
+
name=str(k.get("name", "") or ""),
|
| 229 |
+
content_kind=str(k.get("content_kind", "") or ""),
|
| 230 |
+
is_scenario=bool(k.get("is_scenario")),
|
| 231 |
+
summary=str(k.get("summary", "") or ""),
|
| 232 |
+
detail_summary=str(k.get("detail_summary", "") or ""),
|
| 233 |
+
definition=str(k.get("definition", "") or ""),
|
| 234 |
+
notes=str(k.get("notes", "") or ""),
|
| 235 |
+
popularity=float(k.get("popularity", 0.0) or 0.0),
|
| 236 |
+
cluster=str(k.get("cluster", "") or ""),
|
| 237 |
+
discovery_source=str(item.get("discovery_source", "") or ""),
|
| 238 |
+
reasons=list(item.get("reasons") or []),
|
| 239 |
+
asset_count=len(k.get("assets") or []),
|
| 240 |
+
raw=dict(item),
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def automated_choice(card: Card, anomalies: list[Anomaly]) -> tuple[str, list[str]]:
|
| 245 |
+
"""Deterministic 'reasonable user' policy that exercises the full reaction set."""
|
| 246 |
+
codes = {a.code for a in anomalies}
|
| 247 |
+
if codes & {"prose_shape", "profile_sentence", "long_name", "scenario_in_deck", "non_play_content_kind", "geo_token", "underscored_name", "all_uppercase", "duplicate_of_prior"}:
|
| 248 |
+
return "hard_no", []
|
| 249 |
+
if codes & {"all_lowercase", "no_body_text", "expand_no_op"}:
|
| 250 |
+
return "not_interested", []
|
| 251 |
+
if card.popularity < 50 and not card.definition.strip():
|
| 252 |
+
return "curious", ["together"]
|
| 253 |
+
if card.popularity >= 5000 and card.definition.strip():
|
| 254 |
+
return "love", ["together"]
|
| 255 |
+
return "like", ["together"]
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def manual_choice(card: Card, anomalies: list[Anomaly]) -> tuple[str, list[str]]:
|
| 259 |
+
print(render_card_summary(card))
|
| 260 |
+
if anomalies:
|
| 261 |
+
print(" anomalies:", ", ".join(f"{a.code}({a.detail})" for a in anomalies))
|
| 262 |
+
choice = ""
|
| 263 |
+
while choice not in {"love", "like", "curious", "skip", "ni", "hard_no"}:
|
| 264 |
+
choice = input(" rate [love/like/curious/skip/ni/hard_no]: ").strip().lower()
|
| 265 |
+
return ("not_interested" if choice == "ni" else "skip" if choice == "skip" else choice), ["together"]
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def run_walkthrough(base_url: str, turns: int, strategy: str, out_path: Path | None) -> dict[str, object]:
|
| 269 |
+
uid, token = create_user(base_url)
|
| 270 |
+
print(f"[walkthrough] new user: {uid}")
|
| 271 |
+
record_turns: list[Turn] = []
|
| 272 |
+
prior_compacts: dict[str, str] = {}
|
| 273 |
+
seen_kink_ids: set[str] = set()
|
| 274 |
+
anomaly_counter: Counter[str] = Counter()
|
| 275 |
+
consecutive_empty = 0
|
| 276 |
+
turn_idx = 0
|
| 277 |
+
while turn_idx < turns:
|
| 278 |
+
recs = get_recommendations(base_url, uid, token, limit=24)
|
| 279 |
+
items = [item for item in recs if str(((item.get("kink") or {}).get("id") or "")) not in seen_kink_ids]
|
| 280 |
+
if not items:
|
| 281 |
+
consecutive_empty += 1
|
| 282 |
+
if consecutive_empty > 3:
|
| 283 |
+
print(f"[walkthrough] recommendations exhausted at turn {turn_idx}")
|
| 284 |
+
break
|
| 285 |
+
time.sleep(1.0)
|
| 286 |
+
continue
|
| 287 |
+
consecutive_empty = 0
|
| 288 |
+
item = items[0]
|
| 289 |
+
card = card_from_item(item)
|
| 290 |
+
if not card.kink_id:
|
| 291 |
+
time.sleep(0.5)
|
| 292 |
+
continue
|
| 293 |
+
anomalies = detect_anomalies(card, prior_compacts)
|
| 294 |
+
for a in anomalies:
|
| 295 |
+
anomaly_counter[a.code] += 1
|
| 296 |
+
if strategy == "manual":
|
| 297 |
+
rating, dirs = manual_choice(card, anomalies)
|
| 298 |
+
if rating == "skip":
|
| 299 |
+
seen_kink_ids.add(card.kink_id)
|
| 300 |
+
turn_idx += 1
|
| 301 |
+
continue
|
| 302 |
+
else:
|
| 303 |
+
rating, dirs = automated_choice(card, anomalies)
|
| 304 |
+
status = save_play(base_url, uid, token, card.kink_id, rating, dirs)
|
| 305 |
+
if status != 200:
|
| 306 |
+
print(f" save failed: {status}")
|
| 307 |
+
seen_kink_ids.add(card.kink_id)
|
| 308 |
+
turn_idx += 1
|
| 309 |
+
continue
|
| 310 |
+
seen_kink_ids.add(card.kink_id)
|
| 311 |
+
record_turns.append(Turn(n=turn_idx + 1, card=card, rating=rating, directions=dirs, anomalies=anomalies))
|
| 312 |
+
turn_idx += 1
|
| 313 |
+
if turn_idx % 10 == 0:
|
| 314 |
+
print(f"[walkthrough] turn {turn_idx} / {turns}: anomalies so far -> {dict(anomaly_counter)}")
|
| 315 |
+
|
| 316 |
+
summary = {
|
| 317 |
+
"user_id": uid,
|
| 318 |
+
"base_url": base_url,
|
| 319 |
+
"turns_attempted": turns,
|
| 320 |
+
"turns_completed": len(record_turns),
|
| 321 |
+
"anomaly_counts": dict(anomaly_counter),
|
| 322 |
+
"rating_distribution": dict(Counter(t.rating for t in record_turns)),
|
| 323 |
+
"discovery_source_distribution": dict(Counter(t.card.discovery_source for t in record_turns)),
|
| 324 |
+
"content_kind_distribution": dict(Counter(t.card.content_kind for t in record_turns)),
|
| 325 |
+
}
|
| 326 |
+
flagged = []
|
| 327 |
+
for t in record_turns:
|
| 328 |
+
if t.anomalies:
|
| 329 |
+
flagged.append({
|
| 330 |
+
"n": t.n,
|
| 331 |
+
"kink_id": t.card.kink_id,
|
| 332 |
+
"name": t.card.name,
|
| 333 |
+
"rating": t.rating,
|
| 334 |
+
"anomalies": [{"code": a.code, "detail": a.detail} for a in t.anomalies],
|
| 335 |
+
"popularity": t.card.popularity,
|
| 336 |
+
"discovery_source": t.card.discovery_source,
|
| 337 |
+
"reasons": t.card.reasons,
|
| 338 |
+
})
|
| 339 |
+
summary["flagged_turns"] = flagged
|
| 340 |
+
if out_path is not None:
|
| 341 |
+
out_path.write_text(json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8")
|
| 342 |
+
return summary
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def main() -> int:
|
| 346 |
+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
| 347 |
+
ap.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
| 348 |
+
ap.add_argument("--turns", type=int, default=120)
|
| 349 |
+
ap.add_argument("--strategy", choices=["automated", "manual"], default="automated")
|
| 350 |
+
ap.add_argument("--out", type=Path, default=Path("/tmp/discover_walkthrough.json"))
|
| 351 |
+
args = ap.parse_args()
|
| 352 |
+
summary = run_walkthrough(args.base_url.rstrip("/"), args.turns, args.strategy, args.out)
|
| 353 |
+
print()
|
| 354 |
+
print("=== walkthrough summary ===")
|
| 355 |
+
print(f"user_id={summary['user_id']} turns={summary['turns_completed']}/{summary['turns_attempted']}")
|
| 356 |
+
print(f"ratings: {summary['rating_distribution']}")
|
| 357 |
+
print(f"discovery_source: {summary['discovery_source_distribution']}")
|
| 358 |
+
print(f"content_kind: {summary['content_kind_distribution']}")
|
| 359 |
+
print(f"anomalies: {summary['anomaly_counts']}")
|
| 360 |
+
print(f"flagged turns: {len(summary['flagged_turns'])}")
|
| 361 |
+
print(f"detail written to {args.out}")
|
| 362 |
+
return 0
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
if __name__ == "__main__":
|
| 366 |
+
raise SystemExit(main())
|
scripts/playwright_product_flow.py
CHANGED
|
@@ -1050,7 +1050,7 @@ def scenario_couple_adversarial(browser: Browser) -> dict[str, object]:
|
|
| 1050 |
expect(page.get_by_test_id("together-explore-panel")).to_be_visible(timeout=15000)
|
| 1051 |
page.get_by_test_id("together-tab-theirs").click()
|
| 1052 |
expect(page.get_by_test_id("together-their-list-panel")).to_be_visible(timeout=15000)
|
| 1053 |
-
expect(page.get_by_text(right_only_pair[1])).to_be_visible(timeout=15000)
|
| 1054 |
checkpoints.append(
|
| 1055 |
{
|
| 1056 |
"stage": "explore_and_theirs_render",
|
|
|
|
| 1050 |
expect(page.get_by_test_id("together-explore-panel")).to_be_visible(timeout=15000)
|
| 1051 |
page.get_by_test_id("together-tab-theirs").click()
|
| 1052 |
expect(page.get_by_test_id("together-their-list-panel")).to_be_visible(timeout=15000)
|
| 1053 |
+
expect(page.get_by_text(right_only_pair[1], exact=True).first).to_be_visible(timeout=15000)
|
| 1054 |
checkpoints.append(
|
| 1055 |
{
|
| 1056 |
"stage": "explore_and_theirs_render",
|