feat: update desire sync question bank, degender scripts, app.db rebuild
This commit is contained in:
parent
473feb78a9
commit
5ad1456adb
Binary file not shown.
Binary file not shown.
|
|
@ -73,26 +73,19 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── Domain ────────────────────────────────────────────────────────────────────
|
// ── Domain ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class DesirePair(
|
/** A topic both partners said yes to. Questions are neutral, so one shared set. */
|
||||||
val femaleQ: Question,
|
|
||||||
val maleQ: Question
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DesireMatch(
|
data class DesireMatch(
|
||||||
val femaleQ: Question,
|
val question: Question
|
||||||
val maleQ: Question,
|
|
||||||
val label: String // human-friendly topic label
|
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class DesireSyncPhase { LOADING, INTRO, ANSWER, WAITING, REVEAL, ERROR }
|
enum class DesireSyncPhase { LOADING, INTRO, ANSWER, WAITING, REVEAL, ERROR }
|
||||||
|
|
||||||
data class DesireSyncUiState(
|
data class DesireSyncUiState(
|
||||||
val phase: DesireSyncPhase = DesireSyncPhase.LOADING,
|
val phase: DesireSyncPhase = DesireSyncPhase.LOADING,
|
||||||
val pairs: List<DesirePair> = emptyList(),
|
val questions: List<Question> = emptyList(),
|
||||||
val currentIndex: Int = 0,
|
val currentIndex: Int = 0,
|
||||||
val pendingSelection: String? = null,
|
val pendingSelection: String? = null,
|
||||||
val myAnswers: List<String> = emptyList(),
|
val myAnswers: List<String> = emptyList(),
|
||||||
val amStarter: Boolean = true,
|
|
||||||
val partnerName: String = "Your partner",
|
val partnerName: String = "Your partner",
|
||||||
val matches: List<DesireMatch> = emptyList(),
|
val matches: List<DesireMatch> = emptyList(),
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
|
@ -107,12 +100,6 @@ private fun isBinaryQuestion(q: Question): Boolean {
|
||||||
return ids == setOf("yes", "no") || ids == setOf("true", "false")
|
return ids == setOf("yes", "no") || ids == setOf("true", "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun topicLabel(femaleQ: Question): String =
|
|
||||||
femaleQ.text
|
|
||||||
.replace(Regex("^(Do you want him to |Do you want her to |I want him to |I want her to |I get |I like |I wish |I prefer )", RegexOption.IGNORE_CASE), "")
|
|
||||||
.replaceFirstChar { it.uppercase() }
|
|
||||||
.trimEnd('?', '.')
|
|
||||||
|
|
||||||
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -158,7 +145,7 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
val active = runCatching { gameSessionManager.getActiveSession(couple.id) }.getOrNull()
|
val active = runCatching { gameSessionManager.getActiveSession(couple.id) }.getOrNull()
|
||||||
when {
|
when {
|
||||||
active != null && active.gameType == GameType.DESIRE_SYNC ->
|
active != null && active.gameType == GameType.DESIRE_SYNC ->
|
||||||
joinSession(uid, active.id, active.startedByUserId, active.questionIds)
|
joinSession(active.id, active.questionIds)
|
||||||
active != null ->
|
active != null ->
|
||||||
// A different game is already in progress — respect the one-game lock.
|
// A different game is already in progress — respect the one-game lock.
|
||||||
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
|
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
|
||||||
|
|
@ -168,23 +155,23 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** First partner: pick the topic set, open the shared session, answer their own side. */
|
/** First partner: pick the neutral question set and open the shared session. */
|
||||||
private suspend fun createSession(uid: String) {
|
private suspend fun createSession(uid: String) {
|
||||||
val pairs = buildPairs(loadFemale(), loadMale()).shuffled().take(SESSION_SIZE)
|
val questions = loadNeutralQuestions().shuffled().take(SESSION_SIZE)
|
||||||
if (pairs.isEmpty()) return fail("No questions available.")
|
if (questions.isEmpty()) return fail("No questions available.")
|
||||||
|
|
||||||
val startResult = runCatching {
|
val startResult = runCatching {
|
||||||
gameSessionManager.startGame(
|
gameSessionManager.startGame(
|
||||||
userId = uid,
|
userId = uid,
|
||||||
gameType = GameType.DESIRE_SYNC,
|
gameType = GameType.DESIRE_SYNC,
|
||||||
questionIds = pairs.map { it.femaleQ.id }
|
questionIds = questions.map { it.id }
|
||||||
)
|
)
|
||||||
}.getOrElse { Result.failure(it) }
|
}.getOrElse { Result.failure(it) }
|
||||||
|
|
||||||
when {
|
when {
|
||||||
startResult.isSuccess -> {
|
startResult.isSuccess -> {
|
||||||
sessionId = startResult.getOrThrow()
|
sessionId = startResult.getOrThrow()
|
||||||
_uiState.update { it.copy(phase = DesireSyncPhase.INTRO, pairs = pairs, amStarter = true) }
|
_uiState.update { it.copy(phase = DesireSyncPhase.INTRO, questions = questions) }
|
||||||
observeReveal()
|
observeReveal()
|
||||||
}
|
}
|
||||||
startResult.exceptionOrNull()?.message?.startsWith("partner_active_session|") == true ->
|
startResult.exceptionOrNull()?.message?.startsWith("partner_active_session|") == true ->
|
||||||
|
|
@ -196,45 +183,25 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Second partner: join the in-flight session and rebuild the identical topic set. */
|
/** Second partner: join the in-flight session with the identical question set, same order. */
|
||||||
private suspend fun joinSession(
|
private suspend fun joinSession(existingSessionId: String, questionIds: List<String>) {
|
||||||
uid: String,
|
|
||||||
existingSessionId: String,
|
|
||||||
startedByUserId: String,
|
|
||||||
femaleIds: List<String>
|
|
||||||
) {
|
|
||||||
sessionId = existingSessionId
|
sessionId = existingSessionId
|
||||||
val amStarter = startedByUserId == uid
|
val byId = loadNeutralQuestions().associateBy { it.id }
|
||||||
val femaleById = loadFemale().associateBy { it.id }
|
val questions = questionIds.mapNotNull { byId[it] }
|
||||||
val maleByKey = loadMale().associateBy { it.id.replace("_male_", "_") }
|
if (questions.isEmpty()) return fail("Could not load this game.")
|
||||||
val pairs = femaleIds.mapNotNull { fid ->
|
_uiState.update { it.copy(phase = DesireSyncPhase.INTRO, questions = questions) }
|
||||||
val fq = femaleById[fid] ?: return@mapNotNull null
|
|
||||||
val mq = maleByKey[fq.id.replace("_female_", "_")] ?: return@mapNotNull null
|
|
||||||
DesirePair(fq, mq)
|
|
||||||
}
|
|
||||||
if (pairs.isEmpty()) return fail("Could not load this game.")
|
|
||||||
_uiState.update { it.copy(phase = DesireSyncPhase.INTRO, pairs = pairs, amStarter = amStarter) }
|
|
||||||
observeReveal()
|
observeReveal()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadFemale(): List<Question> =
|
/**
|
||||||
runCatching { repository.getDesireSyncQuestions("female") }
|
* The shared, gender-neutral binary desire pool — both partners answer the same
|
||||||
.onFailure { Log.w(TAG, "load female failed", it) }
|
* questions, so any couple can play. (See [isBinaryQuestion] for the yes/no filter.)
|
||||||
|
*/
|
||||||
|
private suspend fun loadNeutralQuestions(): List<Question> =
|
||||||
|
runCatching { repository.getDesireSyncQuestions("neutral") }
|
||||||
|
.onFailure { Log.w(TAG, "load desire questions failed", it) }
|
||||||
.getOrElse { emptyList() }
|
.getOrElse { emptyList() }
|
||||||
.filter { it.sex == "female" }
|
.filter { isBinaryQuestion(it) }
|
||||||
|
|
||||||
private suspend fun loadMale(): List<Question> =
|
|
||||||
runCatching { repository.getDesireSyncQuestions("male") }
|
|
||||||
.onFailure { Log.w(TAG, "load male failed", it) }
|
|
||||||
.getOrElse { emptyList() }
|
|
||||||
.filter { it.sex == "male" }
|
|
||||||
|
|
||||||
private fun buildPairs(female: List<Question>, male: List<Question>): List<DesirePair> {
|
|
||||||
val maleByKey = male.associateBy { it.id.replace("_male_", "_") }
|
|
||||||
return female.filter { isBinaryQuestion(it) }.mapNotNull { fq ->
|
|
||||||
maleByKey[fq.id.replace("_female_", "_")]?.let { DesirePair(fq, it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startAnswering() {
|
fun startAnswering() {
|
||||||
_uiState.update { it.copy(phase = DesireSyncPhase.ANSWER, currentIndex = 0) }
|
_uiState.update { it.copy(phase = DesireSyncPhase.ANSWER, currentIndex = 0) }
|
||||||
|
|
@ -248,7 +215,7 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
delay(ADVANCE_DELAY_MS)
|
delay(ADVANCE_DELAY_MS)
|
||||||
val answers = _uiState.value.myAnswers + optionId
|
val answers = _uiState.value.myAnswers + optionId
|
||||||
val next = _uiState.value.currentIndex + 1
|
val next = _uiState.value.currentIndex + 1
|
||||||
if (next >= _uiState.value.pairs.size) {
|
if (next >= _uiState.value.questions.size) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(pendingSelection = null, myAnswers = answers, phase = DesireSyncPhase.WAITING)
|
it.copy(pendingSelection = null, myAnswers = answers, phase = DesireSyncPhase.WAITING)
|
||||||
}
|
}
|
||||||
|
|
@ -294,12 +261,12 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun revealResult(mine: List<String>, theirs: List<String>) {
|
private fun revealResult(mine: List<String>, theirs: List<String>) {
|
||||||
if (_uiState.value.phase == DesireSyncPhase.REVEAL) return
|
if (_uiState.value.phase == DesireSyncPhase.REVEAL) return
|
||||||
val pairs = _uiState.value.pairs
|
val questions = _uiState.value.questions
|
||||||
val matches = pairs.indices.mapNotNull { i ->
|
val matches = questions.indices.mapNotNull { i ->
|
||||||
val a = mine.getOrNull(i)?.lowercase()
|
val a = mine.getOrNull(i)?.lowercase()
|
||||||
val b = theirs.getOrNull(i)?.lowercase()
|
val b = theirs.getOrNull(i)?.lowercase()
|
||||||
if (a != null && b != null && a in POSITIVE_IDS && b in POSITIVE_IDS) {
|
if (a != null && b != null && a in POSITIVE_IDS && b in POSITIVE_IDS) {
|
||||||
DesireMatch(pairs[i].femaleQ, pairs[i].maleQ, topicLabel(pairs[i].femaleQ))
|
DesireMatch(questions[i])
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(phase = DesireSyncPhase.REVEAL, matches = matches) }
|
_uiState.update { it.copy(phase = DesireSyncPhase.REVEAL, matches = matches) }
|
||||||
|
|
@ -380,15 +347,15 @@ fun DesireSyncScreen(
|
||||||
onBack = viewModel::quit
|
onBack = viewModel::quit
|
||||||
)
|
)
|
||||||
DesireSyncPhase.INTRO -> DSIntroScreen(
|
DesireSyncPhase.INTRO -> DSIntroScreen(
|
||||||
total = state.pairs.size,
|
total = state.questions.size,
|
||||||
onReady = viewModel::startAnswering
|
onReady = viewModel::startAnswering
|
||||||
)
|
)
|
||||||
DesireSyncPhase.ANSWER -> {
|
DesireSyncPhase.ANSWER -> {
|
||||||
val pair = state.pairs.getOrNull(state.currentIndex) ?: return@Box
|
val question = state.questions.getOrNull(state.currentIndex) ?: return@Box
|
||||||
DSAnswerScreen(
|
DSAnswerScreen(
|
||||||
question = if (state.amStarter) pair.femaleQ else pair.maleQ,
|
question = question,
|
||||||
index = state.currentIndex,
|
index = state.currentIndex,
|
||||||
total = state.pairs.size,
|
total = state.questions.size,
|
||||||
pendingSelection = state.pendingSelection,
|
pendingSelection = state.pendingSelection,
|
||||||
onSelect = viewModel::select,
|
onSelect = viewModel::select,
|
||||||
onQuit = viewModel::quit
|
onQuit = viewModel::quit
|
||||||
|
|
@ -400,7 +367,7 @@ fun DesireSyncScreen(
|
||||||
)
|
)
|
||||||
DesireSyncPhase.REVEAL -> DSRevealScreen(
|
DesireSyncPhase.REVEAL -> DSRevealScreen(
|
||||||
matches = state.matches,
|
matches = state.matches,
|
||||||
total = state.pairs.size,
|
total = state.questions.size,
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
onPlayAgain = viewModel::restart,
|
onPlayAgain = viewModel::restart,
|
||||||
onHome = viewModel::quit
|
onHome = viewModel::quit
|
||||||
|
|
@ -852,7 +819,7 @@ private fun DesireMatchCard(match: DesireMatch) {
|
||||||
iconSize = 18.dp
|
iconSize = 18.dp
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = match.femaleQ.text,
|
text = match.question.text,
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
|
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
|
||||||
color = Color(0xFF3D1F2E),
|
color = Color(0xFF3D1F2E),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
G3 — de-gender the Desire Sync pool.
|
||||||
|
|
||||||
|
Desire Sync uses the binary (yes/no) questions in the `sexual_preferences`
|
||||||
|
category. They ship as paired female/male versions ("Do you want him to…" /
|
||||||
|
"Do you want her to…"), which is unusable for same-sex couples and alienating
|
||||||
|
generally. This collapses each pair into ONE neutral question:
|
||||||
|
|
||||||
|
* female binary -> text de-gendered to "your partner" phrasing, sex='neutral'
|
||||||
|
* male binary -> deleted (now redundant)
|
||||||
|
|
||||||
|
It edits BOTH the source JSON (seed/questions/sexual_preferences.json) and the
|
||||||
|
shipped asset DB (app/src/main/assets/database/app.db) so they stay in sync.
|
||||||
|
It only touches row data — never the schema — so Room's identity hash is safe.
|
||||||
|
build_db.py is NOT run.
|
||||||
|
|
||||||
|
This is a one-off migration kept in the repo for traceability.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
JSON_PATH = os.path.join(HERE, "questions", "sexual_preferences.json")
|
||||||
|
DB_PATH = os.path.join(HERE, "..", "app", "src", "main", "assets", "database", "app.db")
|
||||||
|
|
||||||
|
# The 41 female binary questions that carry gendered pronouns, rewritten neutrally.
|
||||||
|
# (The other 59 binary questions are already gender-neutral and only need sex='neutral'.)
|
||||||
|
REWRITES = {
|
||||||
|
# yes/no questions
|
||||||
|
"sexual_preferences_female_001": "Do you want your partner to initiate sex more often?",
|
||||||
|
"sexual_preferences_female_007": "Do you like being teased before your partner touches you directly?",
|
||||||
|
"sexual_preferences_female_016": "Do you like dirty talk from your partner?",
|
||||||
|
"sexual_preferences_female_019": "Do you want more oral sex from your partner?",
|
||||||
|
"sexual_preferences_female_025": "Do you have a fantasy you want to tell your partner?",
|
||||||
|
"sexual_preferences_female_031": "Do you want your partner to go down on you more often?",
|
||||||
|
"sexual_preferences_female_034": "Do you like your partner using their fingers inside you during foreplay?",
|
||||||
|
"sexual_preferences_female_043": "Do you want your partner to be more dominant in bed?",
|
||||||
|
"sexual_preferences_female_052": "Do you like your partner making you wait before giving you what you want?",
|
||||||
|
"sexual_preferences_female_055": "Do you want your partner to care more about your orgasm?",
|
||||||
|
"sexual_preferences_female_058": "Do you want to use sex toys with your partner?",
|
||||||
|
"sexual_preferences_female_067": "Do you want to try roleplay with your partner?",
|
||||||
|
"sexual_preferences_female_070": "Do you like wearing lingerie to tease your partner?",
|
||||||
|
"sexual_preferences_female_073": "Do you like receiving dirty texts from your partner?",
|
||||||
|
"sexual_preferences_female_085": "Do you want your partner to ask about your fantasies directly?",
|
||||||
|
"sexual_preferences_female_088": "Do you like your partner watching you touch yourself?",
|
||||||
|
"sexual_preferences_female_097": "Do you want to talk openly about where your partner finishes?",
|
||||||
|
"sexual_preferences_female_106": "Do you want your partner to stop immediately if sex hurts, even a little?",
|
||||||
|
"sexual_preferences_female_121": "Do you want your partner to spend more time on clitoral stimulation?",
|
||||||
|
"sexual_preferences_female_133": "Do you want your partner to compliment specific body parts more?",
|
||||||
|
"sexual_preferences_female_136": "Do you want your partner to be more direct when they want sex?",
|
||||||
|
"sexual_preferences_female_139": "Do you want your partner to handle rejection without sulking?",
|
||||||
|
"sexual_preferences_female_145": "Do you trust your partner with your sexual boundaries?",
|
||||||
|
"sexual_preferences_female_148": "Do you want your partner to ask you exactly what you want tonight?",
|
||||||
|
# true/false (agree/disagree) statements
|
||||||
|
"sexual_preferences_female_002": "I get turned on faster when my partner touches me before trying to have sex.",
|
||||||
|
"sexual_preferences_female_005": "I want my partner to ask what feels good instead of guessing.",
|
||||||
|
"sexual_preferences_female_008": "I like when my partner tells me exactly what they want to do to me.",
|
||||||
|
"sexual_preferences_female_020": "I want to guide my partner's hands or mouth without them getting offended.",
|
||||||
|
"sexual_preferences_female_023": "I like when my partner makes me feel chased and wanted.",
|
||||||
|
"sexual_preferences_female_026": "A clear yes from me should matter more than my partner's assumptions.",
|
||||||
|
"sexual_preferences_female_032": "I want my partner to focus on my clitoris more directly.",
|
||||||
|
"sexual_preferences_female_035": "I want my partner to ask before changing speed or pressure.",
|
||||||
|
"sexual_preferences_female_038": "I like when my partner starts shallow before going deeper.",
|
||||||
|
"sexual_preferences_female_050": "I get turned on when my partner says I am doing a good job.",
|
||||||
|
"sexual_preferences_female_053": "Teasing is hotter when my partner still checks that I am enjoying it.",
|
||||||
|
"sexual_preferences_female_056": "I would rather my partner ask what helps me finish than pretend they know.",
|
||||||
|
"sexual_preferences_female_071": "I want my partner to make me feel sexy before expecting me to perform.",
|
||||||
|
"sexual_preferences_female_086": "I have at least one fantasy I have not fully explained to my partner.",
|
||||||
|
"sexual_preferences_female_098": "Where my partner finishes should be discussed before the moment.",
|
||||||
|
"sexual_preferences_female_119": "I want my partner to ask how sensitive my breasts or nipples are that day.",
|
||||||
|
"sexual_preferences_female_149": "I would rather be asked bluntly than have my partner guess badly.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_binary(answer_config):
|
||||||
|
"""True if the answer config is exactly a yes/no choice."""
|
||||||
|
opts = (answer_config or {}).get("options") or []
|
||||||
|
ids = {o.get("id") for o in opts}
|
||||||
|
return ids == {"yes", "no"} or ids == {"true", "false"}
|
||||||
|
|
||||||
|
|
||||||
|
def neutral_tags(tags):
|
||||||
|
"""Drop the sex:* tag; the question is no longer gendered."""
|
||||||
|
return [t for t in (tags or []) if not t.startswith("sex:")]
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_json():
|
||||||
|
with open(JSON_PATH) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
items = data["questions"]
|
||||||
|
|
||||||
|
kept = []
|
||||||
|
female_updated = male_deleted = 0
|
||||||
|
for q in items:
|
||||||
|
binary = q.get("type") == "single_choice" and is_binary(q.get("answer_config"))
|
||||||
|
sex = q.get("sex")
|
||||||
|
if binary and sex == "male":
|
||||||
|
male_deleted += 1
|
||||||
|
continue # drop the redundant gendered duplicate
|
||||||
|
if binary and sex == "female":
|
||||||
|
q["text"] = REWRITES.get(q["id"], q["text"])
|
||||||
|
q["sex"] = "neutral"
|
||||||
|
q["tags"] = neutral_tags(q.get("tags"))
|
||||||
|
female_updated += 1
|
||||||
|
kept.append(q)
|
||||||
|
|
||||||
|
data["questions"] = kept
|
||||||
|
with open(JSON_PATH, "w") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
print(f"JSON: {female_updated} female->neutral, {male_deleted} male deleted, {len(kept)} total")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_db():
|
||||||
|
con = sqlite3.connect(DB_PATH)
|
||||||
|
cur = con.cursor()
|
||||||
|
rows = cur.execute(
|
||||||
|
"SELECT id, sex, answer_config FROM question WHERE category_id='sexual_preferences'"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
female_updated = male_deleted = 0
|
||||||
|
for qid, sex, cfg_text in rows:
|
||||||
|
try:
|
||||||
|
cfg = json.loads(cfg_text)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
# DB answer_config is wrapped: {"type":..., "config": {"options": [...]}}
|
||||||
|
inner = cfg.get("config", cfg)
|
||||||
|
if not is_binary(inner):
|
||||||
|
continue
|
||||||
|
if sex == "male":
|
||||||
|
cur.execute("DELETE FROM question WHERE id=?", (qid,))
|
||||||
|
male_deleted += 1
|
||||||
|
elif sex == "female":
|
||||||
|
new_text = REWRITES.get(qid)
|
||||||
|
if new_text is not None:
|
||||||
|
cur.execute("UPDATE question SET text=?, sex='neutral' WHERE id=?", (new_text, qid))
|
||||||
|
else:
|
||||||
|
cur.execute("UPDATE question SET sex='neutral' WHERE id=?", (qid,))
|
||||||
|
female_updated += 1
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
print(f"DB: {female_updated} female->neutral, {male_deleted} male deleted")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_json()
|
||||||
|
migrate_db()
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Q4 (remainder) — de-gender the Spin Wheel "Sexual Preferences" pool.
|
||||||
|
|
||||||
|
The binary (yes/no + true/false) Desire Sync questions were neutralized in G3.
|
||||||
|
This script handles the remaining 100 non-binary `single_choice` questions:
|
||||||
|
50 sex='female' and 50 sex='male', used by the Spin Wheel category.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
* female non-binary -> keep as the canonical neutral question, rewrite any
|
||||||
|
gendered pronouns in the question text or option text, set sex='neutral'
|
||||||
|
* male non-binary -> deleted (duplicate or anatomy-specific variant; the
|
||||||
|
female receiver perspective is more universal for a gender-neutral pool)
|
||||||
|
|
||||||
|
It edits BOTH the source JSON (seed/questions/sexual_preferences.json) and the
|
||||||
|
shipped asset DB (app/src/main/assets/database/app.db) so they stay in sync.
|
||||||
|
Only row data is touched — never the schema — so Room's identity hash is safe.
|
||||||
|
build_db.py is NOT run.
|
||||||
|
|
||||||
|
One-off migration kept in the repo for traceability.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
JSON_PATH = os.path.join(HERE, "questions", "sexual_preferences.json")
|
||||||
|
DB_PATH = os.path.join(HERE, "..", "app", "src", "main", "assets", "database", "app.db")
|
||||||
|
|
||||||
|
# Question text rewrites for the 10 female questions that contain gendered pronouns.
|
||||||
|
# (The other 40 female questions are already neutral — only sex= needs to change.)
|
||||||
|
Q_REWRITES = {
|
||||||
|
"sexual_preferences_female_015": "How should your partner initiate more often?",
|
||||||
|
"sexual_preferences_female_024": "What is sexiest for your partner to wear?",
|
||||||
|
"sexual_preferences_female_027": "Where would you most like your partner to start teasing you?",
|
||||||
|
"sexual_preferences_female_030": "What do you want your partner to improve first?",
|
||||||
|
"sexual_preferences_female_093": "How do you prefer to teach your partner about your body?",
|
||||||
|
"sexual_preferences_female_108": "What should your partner do if you seem uncomfortable?",
|
||||||
|
"sexual_preferences_female_150": "What question should your partner ask first?",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Option text rewrites: {question_id: {old_text: new_text, ...}}
|
||||||
|
OPT_REWRITES = {
|
||||||
|
"sexual_preferences_female_015": {
|
||||||
|
"Say what he wants": "Say what they want",
|
||||||
|
},
|
||||||
|
"sexual_preferences_female_024": {
|
||||||
|
"Boxers": "Underwear",
|
||||||
|
},
|
||||||
|
"sexual_preferences_female_045": {
|
||||||
|
"He leads": "They lead",
|
||||||
|
},
|
||||||
|
"sexual_preferences_female_072": {
|
||||||
|
"His shirt": "Partner's shirt",
|
||||||
|
},
|
||||||
|
"sexual_preferences_female_090": {
|
||||||
|
"He watches only": "Partner watches only",
|
||||||
|
"He tells me what to do": "Partner directs me",
|
||||||
|
},
|
||||||
|
"sexual_preferences_female_093": {
|
||||||
|
"Show him": "Show them",
|
||||||
|
"Guide his hand": "Guide their hand",
|
||||||
|
"Tell him after": "Tell them after",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_opt_rewrites(answer_config_obj, qid):
|
||||||
|
"""Return updated answer_config dict if options need rewriting, else same obj."""
|
||||||
|
rewrites = OPT_REWRITES.get(qid)
|
||||||
|
if not rewrites:
|
||||||
|
return answer_config_obj, False
|
||||||
|
# DB format: {"type": ..., "config": {"options": [...]}}
|
||||||
|
# JSON format may be nested the same way or flat
|
||||||
|
inner = answer_config_obj.get("config", answer_config_obj)
|
||||||
|
opts = inner.get("options") or []
|
||||||
|
changed = False
|
||||||
|
for opt in opts:
|
||||||
|
old = opt.get("text", "")
|
||||||
|
if old in rewrites:
|
||||||
|
opt["text"] = rewrites[old]
|
||||||
|
changed = True
|
||||||
|
return answer_config_obj, changed
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_json():
|
||||||
|
with open(JSON_PATH) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
items = data["questions"]
|
||||||
|
|
||||||
|
kept = []
|
||||||
|
female_updated = male_deleted = 0
|
||||||
|
for q in items:
|
||||||
|
if q.get("type") != "single_choice":
|
||||||
|
kept.append(q)
|
||||||
|
continue
|
||||||
|
sex = q.get("sex")
|
||||||
|
if sex == "male":
|
||||||
|
male_deleted += 1
|
||||||
|
continue # delete the male variant
|
||||||
|
if sex == "female":
|
||||||
|
# Rewrite question text if needed
|
||||||
|
if q["id"] in Q_REWRITES:
|
||||||
|
q["text"] = Q_REWRITES[q["id"]]
|
||||||
|
# Rewrite option text if needed
|
||||||
|
cfg = q.get("answer_config") or {}
|
||||||
|
cfg, _ = apply_opt_rewrites(cfg, q["id"])
|
||||||
|
q["answer_config"] = cfg
|
||||||
|
q["sex"] = "neutral"
|
||||||
|
female_updated += 1
|
||||||
|
kept.append(q)
|
||||||
|
|
||||||
|
data["questions"] = kept
|
||||||
|
with open(JSON_PATH, "w") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
print(f"JSON: {female_updated} female->neutral, {male_deleted} male deleted, {len(kept)} total")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_db():
|
||||||
|
con = sqlite3.connect(DB_PATH)
|
||||||
|
cur = con.cursor()
|
||||||
|
rows = cur.execute(
|
||||||
|
"SELECT id, sex, answer_config FROM question WHERE category_id='sexual_preferences'"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
female_updated = male_deleted = 0
|
||||||
|
for qid, sex, cfg_text in rows:
|
||||||
|
# Only touch single_choice non-binary rows (binary pool was handled by G3)
|
||||||
|
# We identify them by sex being 'female' or 'male' (neutral = already done)
|
||||||
|
if sex not in ("female", "male"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if sex == "male":
|
||||||
|
cur.execute("DELETE FROM question WHERE id=?", (qid,))
|
||||||
|
male_deleted += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# sex == 'female': rewrite text + options, set sex='neutral'
|
||||||
|
new_q_text = Q_REWRITES.get(qid)
|
||||||
|
|
||||||
|
# Parse and possibly rewrite options
|
||||||
|
try:
|
||||||
|
cfg = json.loads(cfg_text)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
cfg = {}
|
||||||
|
cfg, opts_changed = apply_opt_rewrites(cfg, qid)
|
||||||
|
new_cfg_text = json.dumps(cfg, ensure_ascii=False) if opts_changed else cfg_text
|
||||||
|
|
||||||
|
if new_q_text and opts_changed:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE question SET text=?, answer_config=?, sex='neutral' WHERE id=?",
|
||||||
|
(new_q_text, new_cfg_text, qid),
|
||||||
|
)
|
||||||
|
elif new_q_text:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE question SET text=?, sex='neutral' WHERE id=?",
|
||||||
|
(new_q_text, qid),
|
||||||
|
)
|
||||||
|
elif opts_changed:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE question SET answer_config=?, sex='neutral' WHERE id=?",
|
||||||
|
(new_cfg_text, qid),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute("UPDATE question SET sex='neutral' WHERE id=?", (qid,))
|
||||||
|
female_updated += 1
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
print(f"DB: {female_updated} female->neutral, {male_deleted} male deleted")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_json()
|
||||||
|
migrate_db()
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue