kink-discovery / backend /shared_play.py
Perplexed7675's picture
Sync from kink_cli (Docker Space)
8b20fbe verified
Raw
History Blame Contribute Delete
32 kB
"""Backend shared play service."""
from __future__ import annotations
from collections import defaultdict
from typing import Any
from sqlmodel import Session, select
from models import PromptDismissal, SimilarityEdge
from backend.constants import (
BLOCKED_RATINGS,
EDGE_TYPE_WEIGHTS,
POSITIVE_RATINGS,
STRONG_POSITIVE_RATINGS,
SUPPRESSED_RATINGS,
)
from backend.scenarios import play_excluded_from_surfacing
_RELATED_EDGE_LIMIT = 36
_RELATED_TWO_HOP_LIMIT = 24
_RELATED_DIRECT_MIN = 0.30
_RELATED_TWO_HOP_MIN = 0.40
_RELATED_TWO_HOP_DECAY = 0.72
def _directions_compatible(self, left: list[str], right: list[str], kink: dict[str, Any]) -> bool:
left_set = set(left)
right_set = set(right)
mode = self._direction_mode_for_kink(kink)
if "together" in left_set and "together" in right_set:
return True
if "to_me" in left_set and "by_me" in right_set:
return True
if "by_me" in left_set and "to_me" in right_set:
return True
if mode == "shared" and ("together" in left_set or "together" in right_set):
return True
return False
def _directions_compatible_group(self, directions_list: list[list[str]], kink: dict[str, Any]) -> bool:
if len(directions_list) < 2:
return False
direction_sets = [set(item) for item in directions_list]
if all("together" in direction_set for direction_set in direction_sets):
return True
directed = [direction_set & {"to_me", "by_me"} for direction_set in direction_sets]
if all(direction_set for direction_set in directed):
has_receiver = any("to_me" in direction_set for direction_set in directed)
has_giver = any("by_me" in direction_set for direction_set in directed)
if has_receiver and has_giver:
return True
mode = self._direction_mode_for_kink(kink)
if mode == "shared" and any("together" in direction_set for direction_set in direction_sets):
return all(direction_set & {"together", "to_me", "by_me"} for direction_set in direction_sets)
return False
def _safe_prompt_candidate(self, kink: dict[str, Any], support_count: int) -> bool:
if play_excluded_from_surfacing(kink):
return False
if not kink.get("prompt_eligible"):
return False
penalty = self._discoverability_penalty(kink["cluster"])
if penalty >= 0.9:
return False
if support_count >= 2 and kink["popularity"] >= 60:
return True
return kink["popularity"] >= 320
def _direct_shared_ids(self, left: dict[str, Any], right: dict[str, Any]) -> list[str]:
detail_by_id = self._catalog()["detail_by_id"]
direct_ids: list[str] = []
shared_ids = set(left["plays"]) & set(right["plays"])
for kink_id in sorted(shared_ids):
left_state = left["plays"][kink_id]
right_state = right["plays"][kink_id]
kink = detail_by_id.get(kink_id)
if not kink:
continue
if play_excluded_from_surfacing(kink):
continue
if (
left_state["interest_state"] in POSITIVE_RATINGS
and right_state["interest_state"] in POSITIVE_RATINGS
and self._directions_compatible(left_state["directions"], right_state["directions"], kink)
):
direct_ids.append(kink_id)
return direct_ids
def _related_edge_strength(edge: dict[str, Any]) -> float:
return min(float(edge["score"]) * float(EDGE_TYPE_WEIGHTS.get(edge.get("type", ""), 0.8)), 1.0)
def _similarity_edges_by_source(
self,
source_ids: set[str],
*,
per_source_limit: int = _RELATED_EDGE_LIMIT,
) -> dict[str, list[dict[str, Any]]]:
if not source_ids:
return {}
with Session(self.engine) as session:
rows = session.exec(
select(SimilarityEdge)
.where(SimilarityEdge.left_kink_id.in_(sorted(source_ids)))
.order_by(SimilarityEdge.left_kink_id, SimilarityEdge.score.desc())
).all()
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
items = grouped[row.left_kink_id]
if len(items) >= per_source_limit:
continue
items.append({"id": row.right_kink_id, "score": row.score, "type": row.similarity_type})
return dict(grouped)
def _positive_play_states(user: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {
kink_id: state
for kink_id, state in user.get("plays", {}).items()
if state.get("interest_state") in POSITIVE_RATINGS
}
def _positive_scenario_states(user: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {
scenario_id: state
for scenario_id, state in user.get("scenario_preferences", {}).items()
if state.get("interest_state") in POSITIVE_RATINGS
}
def _overlap_eligible_scenario_states(user: dict[str, Any]) -> dict[str, dict[str, Any]]:
plays = user.get("plays", {})
out: dict[str, dict[str, Any]] = {}
for scenario_id, state in user.get("scenario_preferences", {}).items():
if state.get("interest_state") not in STRONG_POSITIVE_RATINGS:
continue
parent_id = str(state.get("parent_kink_id", "") or "")
parent_state = plays.get(parent_id, {}) if parent_id else {}
if parent_state.get("interest_state") not in STRONG_POSITIVE_RATINGS:
continue
out[scenario_id] = {
**state,
"parent_kink_id": parent_id,
"directions": state.get("directions", []) or parent_state.get("directions", []),
}
return out
def _related_direction_compatible(
self,
left_state: dict[str, Any],
right_state: dict[str, Any],
left_kink: dict[str, Any],
right_kink: dict[str, Any],
) -> bool:
left_dirs = left_state.get("directions", [])
right_dirs = right_state.get("directions", [])
return self._directions_compatible(left_dirs, right_dirs, left_kink) or self._directions_compatible(
left_dirs,
right_dirs,
right_kink,
)
def _related_match_entry(
self,
left_user: dict[str, Any],
right_user: dict[str, Any],
left_kink_id: str,
right_kink_id: str,
*,
score: float,
hops: int,
path: list[str],
edge_types: list[str],
) -> dict[str, Any] | None:
detail_by_id = self._catalog()["detail_by_id"]
left_kink = detail_by_id.get(left_kink_id)
right_kink = detail_by_id.get(right_kink_id)
if not left_kink or not right_kink:
return None
left_state = left_user["plays"][left_kink_id]
right_state = right_user["plays"][right_kink_id]
if not self._related_direction_compatible(left_state, right_state, left_kink, right_kink):
return None
bridge = None
if len(path) == 3 and path[1] not in {left_kink_id, right_kink_id}:
bridge = detail_by_id.get(path[1])
reason = (
f"{left_kink['name']} is graph-near {right_kink['name']}"
if hops == 1
else f"{left_kink['name']} connects to {right_kink['name']} via {bridge['name'] if bridge else 'a nearby kink'}"
)
return {
"left_user_id": left_user["id"],
"right_user_id": right_user["id"],
"left_kink": left_kink,
"right_kink": right_kink,
"bridge_kink": bridge,
"score": round(float(score), 4),
"hops": hops,
"path": path,
"edge_types": edge_types,
"reasons": [reason],
"partner_reactions": {
left_user["id"]: {
"kink_id": left_kink_id,
"interest_state": left_state["interest_state"],
"directions": left_state.get("directions", []),
},
right_user["id"]: {
"kink_id": right_kink_id,
"interest_state": right_state["interest_state"],
"directions": right_state.get("directions", []),
},
},
}
def _related_positive_matches_for_pair(
self,
left: dict[str, Any],
right: dict[str, Any],
*,
limit: int = 12,
) -> list[dict[str, Any]]:
left_positive = _positive_play_states(left)
right_positive = _positive_play_states(right)
if not left_positive or not right_positive:
return []
exact_shared = set(left_positive) & set(right_positive)
left_only = set(left_positive) - exact_shared
right_only = set(right_positive) - exact_shared
if not left_only or not right_only:
return []
first_edges = _similarity_edges_by_source(self, left_only | right_only, per_source_limit=_RELATED_EDGE_LIMIT)
mid_ids = {
edge["id"]
for source in left_only | right_only
for edge in first_edges.get(source, [])
if edge["id"] not in exact_shared
}
second_edges = _similarity_edges_by_source(self, mid_ids, per_source_limit=_RELATED_TWO_HOP_LIMIT)
best_by_pair: dict[tuple[str, str], dict[str, Any]] = {}
def add_candidate(
left_kink_id: str,
right_kink_id: str,
*,
score: float,
hops: int,
path: list[str],
edge_types: list[str],
) -> None:
threshold = _RELATED_DIRECT_MIN if hops == 1 else _RELATED_TWO_HOP_MIN
if score < threshold:
return
entry = _related_match_entry(
self,
left,
right,
left_kink_id,
right_kink_id,
score=score,
hops=hops,
path=path,
edge_types=edge_types,
)
if not entry:
return
key = (left_kink_id, right_kink_id)
current = best_by_pair.get(key)
if current is None or entry["score"] > current["score"] or (entry["score"] == current["score"] and entry["hops"] < current["hops"]):
best_by_pair[key] = entry
for left_kink_id in left_only:
for edge in first_edges.get(left_kink_id, []):
strength = _related_edge_strength(edge)
if edge["id"] in right_only:
add_candidate(
left_kink_id,
edge["id"],
score=strength,
hops=1,
path=[left_kink_id, edge["id"]],
edge_types=[edge["type"]],
)
for edge2 in second_edges.get(edge["id"], []):
if edge2["id"] not in right_only:
continue
add_candidate(
left_kink_id,
edge2["id"],
score=strength * _related_edge_strength(edge2) * _RELATED_TWO_HOP_DECAY,
hops=2,
path=[left_kink_id, edge["id"], edge2["id"]],
edge_types=[edge["type"], edge2["type"]],
)
for right_kink_id in right_only:
for edge in first_edges.get(right_kink_id, []):
strength = _related_edge_strength(edge)
if edge["id"] in left_only:
add_candidate(
edge["id"],
right_kink_id,
score=strength,
hops=1,
path=[edge["id"], right_kink_id],
edge_types=[edge["type"]],
)
for edge2 in second_edges.get(edge["id"], []):
if edge2["id"] not in left_only:
continue
add_candidate(
edge2["id"],
right_kink_id,
score=strength * _related_edge_strength(edge2) * _RELATED_TWO_HOP_DECAY,
hops=2,
path=[edge2["id"], edge["id"], right_kink_id],
edge_types=[edge["type"], edge2["type"]],
)
related = sorted(
best_by_pair.values(),
key=lambda item: (
int(item["hops"]),
-float(item["score"]),
item["left_kink"]["name"].lower(),
item["right_kink"]["name"].lower(),
),
)
return related[:limit]
def _related_positive_matches_for_users(
self,
users: list[dict[str, Any]],
*,
limit: int = 12,
) -> list[dict[str, Any]]:
if len(users) != 2:
return []
return self._related_positive_matches_for_pair(users[0], users[1], limit=limit)
def _pair_safe_similarity_floor(self, direct_count: int, support_count: int, popularity: float) -> bool:
if support_count >= 2:
return True
if direct_count >= 3 and popularity >= 75:
return True
if direct_count >= 2 and popularity >= 180:
return True
return direct_count == 1 and popularity >= 450
def _direct_shared_ids_for_users(self, users: list[dict[str, Any]]) -> list[str]:
detail_by_id = self._catalog()["detail_by_id"]
if len(users) < 2:
return []
shared_ids = set(users[0]["plays"])
for user in users[1:]:
shared_ids &= set(user["plays"])
direct_ids: list[str] = []
for kink_id in sorted(shared_ids):
states = [user["plays"][kink_id] for user in users]
kink = detail_by_id.get(kink_id)
if not kink:
continue
if play_excluded_from_surfacing(kink):
continue
if all(state["interest_state"] in POSITIVE_RATINGS for state in states) and self._directions_compatible_group(
[state["directions"] for state in states],
kink,
):
direct_ids.append(kink_id)
return direct_ids
def _scenario_direct_matches_for_users(
self,
users: list[dict[str, Any]],
*,
limit: int = 24,
) -> list[dict[str, Any]]:
if len(users) < 2:
return []
detail_by_id = self._catalog()["detail_by_id"]
positive_by_user = [_overlap_eligible_scenario_states(user) for user in users]
if any(not prefs for prefs in positive_by_user):
return []
shared_ids = set(positive_by_user[0])
for prefs in positive_by_user[1:]:
shared_ids &= set(prefs)
out: list[dict[str, Any]] = []
for scenario_id in sorted(shared_ids):
states = [prefs[scenario_id] for prefs in positive_by_user]
scenario = detail_by_id.get(scenario_id)
if not scenario:
continue
parent_ids = [state.get("parent_kink_id", "") for state in states]
parent_id = next((pid for pid in parent_ids if pid), "")
parent = detail_by_id.get(parent_id) if parent_id else None
compatibility_kink = parent or scenario
if not self._directions_compatible_group([state.get("directions", []) for state in states], compatibility_kink):
continue
out.append(
{
"scenario_kink": scenario,
"parent_kink": parent,
"score": round(1.0 + min(float(scenario.get("popularity", 0.0) or 0.0) / 100000.0, 0.2), 4),
"partner_reactions": {
user["id"]: {
"parent_kink_id": positive_by_user[i][scenario_id].get("parent_kink_id", ""),
"interest_state": positive_by_user[i][scenario_id].get("interest_state", ""),
"directions": positive_by_user[i][scenario_id].get("directions", []),
}
for i, user in enumerate(users)
},
}
)
out.sort(
key=lambda item: (
-float(item.get("score", 0.0) or 0.0),
(item.get("parent_kink") or {}).get("name", ""),
item["scenario_kink"]["name"].lower(),
)
)
return out[:limit]
def _scenario_related_matches_for_pair(
self,
left: dict[str, Any],
right: dict[str, Any],
*,
limit: int = 24,
) -> list[dict[str, Any]]:
detail_by_id = self._catalog()["detail_by_id"]
left_positive = _overlap_eligible_scenario_states(left)
right_positive = _overlap_eligible_scenario_states(right)
if not left_positive or not right_positive:
return []
left_by_parent: dict[str, list[tuple[str, dict[str, Any]]]] = defaultdict(list)
right_by_parent: dict[str, list[tuple[str, dict[str, Any]]]] = defaultdict(list)
for sid, state in left_positive.items():
parent_id = state.get("parent_kink_id", "")
if parent_id:
left_by_parent[parent_id].append((sid, state))
for sid, state in right_positive.items():
parent_id = state.get("parent_kink_id", "")
if parent_id:
right_by_parent[parent_id].append((sid, state))
out: list[dict[str, Any]] = []
for parent_id in sorted(set(left_by_parent) & set(right_by_parent)):
parent = detail_by_id.get(parent_id)
if not parent:
continue
for left_sid, left_state in left_by_parent[parent_id]:
for right_sid, right_state in right_by_parent[parent_id]:
if left_sid == right_sid:
continue
left_scenario = detail_by_id.get(left_sid)
right_scenario = detail_by_id.get(right_sid)
if not left_scenario or not right_scenario:
continue
if not self._directions_compatible(left_state.get("directions", []), right_state.get("directions", []), parent):
continue
score = 0.7 + min(float(parent.get("popularity", 0.0) or 0.0) / 100000.0, 0.18)
out.append(
{
"left_user_id": left["id"],
"right_user_id": right["id"],
"parent_kink": parent,
"left_scenario": left_scenario,
"right_scenario": right_scenario,
"score": round(score, 4),
"reasons": [f"Both are scenarios under {parent['name']}"],
"partner_reactions": {
left["id"]: {
"scenario_kink_id": left_sid,
"interest_state": left_state.get("interest_state", ""),
"directions": left_state.get("directions", []),
},
right["id"]: {
"scenario_kink_id": right_sid,
"interest_state": right_state.get("interest_state", ""),
"directions": right_state.get("directions", []),
},
},
}
)
out.sort(
key=lambda item: (
-float(item.get("score", 0.0) or 0.0),
item["parent_kink"]["name"].lower(),
item["left_scenario"]["name"].lower(),
item["right_scenario"]["name"].lower(),
)
)
return out[:limit]
def _scenario_related_matches_for_users(
self,
users: list[dict[str, Any]],
*,
limit: int = 24,
) -> list[dict[str, Any]]:
if len(users) != 2:
return []
return self._scenario_related_matches_for_pair(users[0], users[1], limit=limit)
def _shared_play_list_for_users(self, users: list[dict[str, Any]], limit: int = 24) -> dict[str, list[dict[str, Any]]]:
detail_by_id = self._catalog()["detail_by_id"]
direct_ids = self._direct_shared_ids_for_users(users)
candidate_scores: dict[str, float] = defaultdict(float)
candidate_reasons: dict[str, set[str]] = defaultdict(set)
candidate_support: dict[str, set[str]] = defaultdict(set)
suppressed_ids = {
kink_id
for user in users
for kink_id, state in user["plays"].items()
if state["interest_state"] in SUPPRESSED_RATINGS | BLOCKED_RATINGS
}
for kink_id in direct_ids:
source = detail_by_id.get(kink_id)
if not source:
continue
for edge in self._edges_for_kink(kink_id, limit=12):
if edge["id"] in direct_ids or any(edge["id"] in user["plays"] for user in users):
continue
target = detail_by_id.get(edge["id"])
if not target or edge["id"] in suppressed_ids:
continue
if not target.get("shared_eligible"):
continue
score = edge["score"] - self._discoverability_penalty(target["cluster"])
score += min(target["popularity"] / 60000.0, 0.3)
if score <= 0:
continue
candidate_scores[target["id"]] += score
candidate_support[target["id"]].add(kink_id)
candidate_reasons[target["id"]].add(f"close to {source['name']}")
direct_count = len(direct_ids)
similar = []
for kink_id, score in sorted(candidate_scores.items(), key=lambda item: item[1], reverse=True):
if kink_id not in detail_by_id:
continue
kink = detail_by_id[kink_id]
support_count = len(candidate_support.get(kink_id, set()))
if not self._pair_safe_similarity_floor(direct_count, support_count, kink["popularity"]):
continue
similar.append(
{
"kink": kink,
"score": score + min(support_count * 0.08, 0.24),
"reasons": self._finalize_reasons(
candidate_reasons[kink_id],
fallback="close to what you both already like",
),
"support_count": support_count,
}
)
if len(similar) >= limit:
break
direct = []
for kink_id in direct_ids:
kink = detail_by_id.get(kink_id)
if not kink:
continue
entry = dict(kink)
entry["partner_reactions"] = {
user["id"]: user["plays"][kink_id]["interest_state"]
for user in users
if kink_id in user.get("plays", {})
}
direct.append(entry)
direct.sort(key=lambda item: (-item["popularity"], item["name"].lower()))
related = self._related_positive_matches_for_users(users, limit=limit)
scenario_matches = self._scenario_direct_matches_for_users(users, limit=limit)
scenario_related = self._scenario_related_matches_for_users(users, limit=limit)
return {
"direct_matches": direct,
"related_matches": related,
"scenario_matches": scenario_matches,
"scenario_related_matches": scenario_related,
"similar_matches": similar,
}
def _empty_shared_play_response() -> dict[str, Any]:
return {
"direct_matches": [],
"related_matches": [],
"scenario_matches": [],
"scenario_related_matches": [],
"similar_matches": [],
"viewer_boundary_alerts": {"count": 0},
}
def _viewer_boundary_alert_count(viewer: dict[str, Any], others: list[dict[str, Any]]) -> int:
"""Count the viewer's hard_no kinks that any other participant marks love/like.
Returns a count only — kink identities are intentionally not exposed. The viewer
is the only side that sees their own number; partners querying the same group
get a different number computed against their own hard_nos.
"""
viewer_id = viewer.get("id")
viewer_hard_no_ids = {
kink_id
for kink_id, state in viewer.get("plays", {}).items()
if state.get("interest_state") in BLOCKED_RATINGS
}
if not viewer_hard_no_ids:
return 0
conflicting: set[str] = set()
for other in others:
if other.get("id") == viewer_id:
continue
for kink_id, state in other.get("plays", {}).items():
if (
kink_id in viewer_hard_no_ids
and state.get("interest_state") in STRONG_POSITIVE_RATINGS
):
conflicting.add(kink_id)
return len(conflicting)
def shared_play_list(self, user_id: str, partner_id: str, limit: int = 24) -> dict[str, Any]:
left = self.get_user(user_id)
right = self.get_user(partner_id)
if not left or not right:
return _empty_shared_play_response()
result = self._shared_play_list_for_users([left, right], limit=limit)
result["viewer_boundary_alerts"] = {
"count": _viewer_boundary_alert_count(left, [right]),
}
return result
def shared_play_list_for_group(self, owner_user_id: str, group_id: str, limit: int = 24) -> dict[str, Any]:
users = self._group_participants(owner_user_id, group_id)
if len(users) < 2:
return _empty_shared_play_response()
result = self._shared_play_list_for_users(users, limit=limit)
viewer = next((u for u in users if u.get("id") == owner_user_id), None)
others = [u for u in users if u.get("id") != owner_user_id]
count = _viewer_boundary_alert_count(viewer, others) if viewer else 0
result["viewer_boundary_alerts"] = {"count": count}
return result
def prompt_candidates(self, user_id: str, limit: int = 8, group_id: str | None = None) -> list[dict[str, Any]]:
if group_id:
return self.group_prompt_candidates(user_id, group_id, limit=limit)
user = self.get_user(user_id)
if not user or not user["partners"]:
return []
detail_by_id = self._catalog()["detail_by_id"]
dismissed: set[str] = set()
with Session(self.engine) as session:
dismissed = {
row.kink_id
for row in session.exec(select(PromptDismissal).where(PromptDismissal.user_id == user_id)).all()
}
user_play_ids = set(user["plays"])
blocked_ids = {
kink_id
for kink_id, state in user["plays"].items()
if state["interest_state"] in SUPPRESSED_RATINGS | BLOCKED_RATINGS
}
candidate_scores: dict[str, float] = defaultdict(float)
candidate_support: dict[str, set[str]] = defaultdict(set)
candidate_reasons: dict[str, set[str]] = defaultdict(set)
for partner_id in user["partners"]:
partner = self.get_user(partner_id)
if not partner:
continue
partner_positive = {
kink_id: state
for kink_id, state in partner["plays"].items()
if state["interest_state"] in POSITIVE_RATINGS
}
direct_shared_ids = self._direct_shared_ids(user, partner)
direct_neighbor_ids = {
edge["id"]
for direct_id in direct_shared_ids
for edge in self._edges_for_kink(direct_id, limit=12)
}
for kink_id in direct_shared_ids:
source = detail_by_id.get(kink_id)
if not source:
continue
for edge in self._edges_for_kink(kink_id, limit=10):
candidate_id = edge["id"]
if candidate_id in user_play_ids or candidate_id in blocked_ids or candidate_id in dismissed:
continue
target = detail_by_id.get(candidate_id)
if not target:
continue
candidate_scores[candidate_id] += edge["score"] + min(target["popularity"] / 80000.0, 0.18)
candidate_support[candidate_id].add(kink_id)
candidate_reasons[candidate_id].add(f"aligned with {source['name']}")
for kink_id in partner_positive:
if kink_id in user_play_ids or kink_id in blocked_ids or kink_id in dismissed:
continue
target = detail_by_id.get(kink_id)
supported_by_shared_lane = kink_id in direct_neighbor_ids and target and target["popularity"] >= 80
if not target or (not self._safe_prompt_candidate(target, 1) and not supported_by_shared_lane):
continue
candidate_scores[kink_id] += 0.55 + min(target["popularity"] / 70000.0, 0.22)
candidate_support[kink_id].add(f"partner:{partner_id}")
if supported_by_shared_lane:
candidate_support[kink_id].add("shared-lane")
candidate_reasons[kink_id].add("worth getting your take on")
prompts = []
for kink_id, score in sorted(candidate_scores.items(), key=lambda item: item[1], reverse=True):
kink = detail_by_id.get(kink_id)
if not kink:
continue
support_count = len(candidate_support.get(kink_id, set()))
if not self._safe_prompt_candidate(kink, support_count):
continue
prompts.append(
{
"kink": kink,
"score": score,
"support_count": support_count,
"reasons": sorted(candidate_reasons[kink_id]),
}
)
if len(prompts) >= limit:
break
return prompts
def group_prompt_candidates(self, requester_user_id: str, group_id: str, limit: int = 8) -> list[dict[str, Any]]:
requester = self.get_user(requester_user_id)
users = self._group_participants(requester_user_id, group_id)
if not requester or len(users) < 2:
return []
detail_by_id = self._catalog()["detail_by_id"]
with Session(self.engine) as session:
dismissed = {
row.kink_id
for row in session.exec(select(PromptDismissal).where(PromptDismissal.user_id == requester_user_id)).all()
}
requester_play_ids = set(requester["plays"])
blocked_ids = {
kink_id
for kink_id, state in requester["plays"].items()
if state["interest_state"] in SUPPRESSED_RATINGS | BLOCKED_RATINGS
}
direct_shared_ids = self._direct_shared_ids_for_users(users)
shared_neighbor_ids = {
edge["id"]
for direct_id in direct_shared_ids
for edge in self._edges_for_kink(direct_id, limit=12)
}
candidate_scores: dict[str, float] = defaultdict(float)
candidate_support: dict[str, set[str]] = defaultdict(set)
candidate_reasons: dict[str, set[str]] = defaultdict(set)
for kink_id in direct_shared_ids:
source = detail_by_id.get(kink_id)
if not source:
continue
for edge in self._edges_for_kink(kink_id, limit=10):
candidate_id = edge["id"]
if candidate_id in requester_play_ids or candidate_id in blocked_ids or candidate_id in dismissed:
continue
target = detail_by_id.get(candidate_id)
if not target:
continue
candidate_scores[candidate_id] += edge["score"] + min(target["popularity"] / 80000.0, 0.18)
candidate_support[candidate_id].add(kink_id)
candidate_reasons[candidate_id].add(f"aligned with {source['name']}")
for partner in users:
if partner["id"] == requester_user_id:
continue
partner_positive = {
kink_id: state
for kink_id, state in partner["plays"].items()
if state["interest_state"] in POSITIVE_RATINGS
}
for kink_id in partner_positive:
if kink_id in requester_play_ids or kink_id in blocked_ids or kink_id in dismissed:
continue
target = detail_by_id.get(kink_id)
if not target:
continue
if kink_id in shared_neighbor_ids:
continue
candidate_scores[kink_id] += 0.55 + min(target["popularity"] / 70000.0, 0.22)
candidate_support[kink_id].add(partner["id"])
candidate_reasons[kink_id].add("worth getting your take on")
prompts = []
for kink_id, score in sorted(candidate_scores.items(), key=lambda item: item[1], reverse=True):
kink = detail_by_id.get(kink_id)
if not kink:
continue
support_count = len(candidate_support.get(kink_id, set()))
if not self._safe_prompt_candidate(kink, support_count):
continue
prompts.append(
{
"kink": kink,
"score": score,
"support_count": support_count,
"reasons": self._finalize_reasons(
candidate_reasons[kink_id],
fallback="worth getting your take on",
),
}
)
if len(prompts) >= limit:
break
return prompts