Perplexed7675 commited on
Commit
13c75ca
·
verified ·
1 Parent(s): 56cd9ac

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",