Extract row item policy

This commit is contained in:
2026-06-27 09:04:46 +02:00
parent 3d9dbdc95d
commit 58ddda82d7
5 changed files with 454 additions and 287 deletions
+24 -283
View File
@@ -5,7 +5,7 @@ import random
import re
from pathlib import Path
from string import Formatter
from typing import Any, Callable
from typing import Any
try:
from .category_library import (
@@ -14,10 +14,8 @@ try:
compatible_entry as _compatible_entry,
find_subcategory as _find_subcategory,
load_category_library,
merged_axes as _merged_axes,
merged_field as _merged_field,
read_category_json as _read_json,
template_list as _template_list,
)
from . import camera_config as camera_policy
from . import cast_context as cast_context_policy
@@ -42,6 +40,7 @@ try:
from . import row_normalization as row_policy
from . import row_camera as row_camera_policy
from . import row_expression as row_expression_policy
from . import row_item as row_item_policy
from . import row_location as row_location_policy
from . import row_pools as row_pool_policy
from . import seed_config as seed_policy
@@ -59,10 +58,8 @@ except ImportError: # Allows local smoke tests with `python -c`.
compatible_entry as _compatible_entry,
find_subcategory as _find_subcategory,
load_category_library,
merged_axes as _merged_axes,
merged_field as _merged_field,
read_category_json as _read_json,
template_list as _template_list,
)
import camera_config as camera_policy
import cast_context as cast_context_policy
@@ -87,6 +84,7 @@ except ImportError: # Allows local smoke tests with `python -c`.
import row_normalization as row_policy
import row_camera as row_camera_policy
import row_expression as row_expression_policy
import row_item as row_item_policy
import row_location as row_location_policy
import row_pools as row_pool_policy
import seed_config as seed_policy
@@ -206,7 +204,7 @@ class SafeFormatDict(dict):
def _slug(value: str) -> str:
return g.slugify(value) or "custom"
return row_item_policy.slug(value)
def _list_from(value: Any) -> list[Any]:
@@ -243,69 +241,23 @@ def _unique_extend(target: list[Any], additions: list[Any]) -> None:
def _pair_from(value: Any) -> tuple[str, str]:
if isinstance(value, dict):
text = str(
value.get("prompt")
or value.get("description")
or value.get("text")
or value.get("name")
or ""
).strip()
slug = str(value.get("slug") or _slug(str(value.get("name") or text))).strip()
if not text:
raise ValueError(f"Pair extension is missing prompt text: {value!r}")
return slug, text
if isinstance(value, (list, tuple)) and len(value) == 2:
return str(value[0]), str(value[1])
text = str(value).strip()
if not text:
raise ValueError("Pair extension cannot be empty")
return _slug(text), text
return row_item_policy.pair_from(value)
def _weighted_choice(rng: random.Random, items: list[Any]) -> Any:
if not items:
raise ValueError("Cannot choose from an empty list")
weights: list[float] = []
for item in items:
weight = item.get("weight", 1.0) if isinstance(item, dict) else 1.0
try:
weights.append(max(0.0, float(weight)))
except (TypeError, ValueError):
weights.append(1.0)
total = sum(weights)
if total <= 0:
return items[rng.randrange(len(items))]
pick = rng.random() * total
running = 0.0
for item, weight in zip(items, weights):
running += weight
if pick <= running:
return item
return items[-1]
return row_item_policy.weighted_choice(rng, items)
def _entry_text(item: Any) -> str:
if isinstance(item, dict):
return str(
item.get("template")
or item.get("prompt")
or item.get("text")
or item.get("description")
or item.get("name")
or ""
).strip()
return str(item).strip()
return row_item_policy.entry_text(item)
def _item_text(item: Any) -> str:
return _entry_text(item)
return row_item_policy.item_text(item)
def _item_name(item: Any) -> str:
if isinstance(item, dict):
return str(item.get("name") or _item_text(item)).strip()
return _item_text(item)
return row_item_policy.item_name(item)
def _template_metadata(item: Any) -> dict[str, Any]:
@@ -333,199 +285,19 @@ def _merge_position_keys(primary: list[str], fallback: list[str]) -> list[str]:
def _oral_acts_for_position(values: list[Any], position: str) -> list[Any]:
position_text = str(position or "").lower()
if not position_text:
return values
def act_text(value: Any) -> str:
return _entry_text(value).lower()
def filtered(predicate: Callable[[str], bool]) -> list[Any]:
matches = [value for value in values if predicate(act_text(value))]
return matches or values
penis_terms = ("fellatio", "blowjob", "deepthroat", "penis sucking", "penis in mouth")
cunnilingus_terms = ("cunnilingus", "pussy licking", "tongue on pussy", "oral sex with tongue and fingers", "mouth on genitals")
if "sixty-nine" in position_text:
return filtered(lambda text: "sixty-nine" in text)
if "face-sitting" in position_text:
return filtered(lambda text: "face-sitting" in text or any(term in text for term in cunnilingus_terms))
if "kneeling oral" in position_text:
return filtered(lambda text: any(term in text for term in penis_terms))
if "straddled oral" in position_text or "reclining cunnilingus" in position_text:
return filtered(lambda text: "sixty-nine" not in text and not any(term in text for term in penis_terms))
if "spread-leg oral" in position_text:
return filtered(lambda text: "sixty-nine" not in text and "face-sitting" not in text)
if any(term in position_text for term in ("standing oral", "kneeling oral", "edge-of-bed oral", "chair oral", "side-lying oral")):
return filtered(lambda text: "sixty-nine" not in text and "face-sitting" not in text)
return values
return row_item_policy.oral_acts_for_position(values, position)
def _oral_axis_values_for_context(values: list[Any], position: str, oral_act: str, axis_name: str) -> list[Any]:
axis_name = str(axis_name or "").lower()
if axis_name not in {"body_contact", "hand_detail", "mouth_detail", "saliva_detail", "climax_hint", "visibility"}:
return values
position_text = str(position or "").lower()
act_text = str(oral_act or "").lower()
woman_gives = any(
term in act_text
for term in ("fellatio", "blowjob", "deepthroat", "penis sucking", "penis in mouth")
)
man_gives = any(
term in act_text
for term in ("cunnilingus", "pussy licking", "tongue on pussy")
)
if not (woman_gives or man_gives):
return values
def value_text(value: Any) -> str:
return _entry_text(value).lower()
def filtered(terms: tuple[str, ...], excluded_terms: tuple[str, ...] = ()) -> list[Any]:
matches = [
value
for value in values
if any(term in value_text(value) for term in terms)
and not any(term in value_text(value) for term in excluded_terms)
]
return matches or values
if woman_gives:
by_axis = {
"body_contact": ("hips pushed", "fingers tangled", "bodies stacked", "hands on thighs"),
"hand_detail": ("hips", "penis", "head", "hair"),
"mouth_detail": ("lips", "mouth", "deep mouth", "saliva"),
"saliva_detail": ("saliva", "wet lips", "slick wet mouth", "drool", "mouth"),
"climax_hint": ("mouth", "lips", "tongue", "breasts", "belly", "sexual fluids"),
"visibility": ("mouth", "penis", "oral"),
}
excluded = {
"body_contact": ("legs held open", "spread legs", "ass lifted", "chest pressed to thighs"),
"hand_detail": ("spreading thighs", "sheets", "cupping breasts", "pressing into thighs", "holding the ass"),
}
return filtered(by_axis.get(axis_name, ("mouth", "penis")), excluded.get(axis_name, ()))
if man_gives and ("kneeling oral" in position_text or "standing oral" in position_text):
by_axis = {
"body_contact": ("legs held open", "one body kneeling", "chest pressed", "ass lifted", "hands on thighs"),
"hand_detail": ("thigh", "hips", "head", "ass"),
"mouth_detail": ("tongue", "wet lips", "deep mouth", "genitals"),
"saliva_detail": ("saliva", "wet lips", "tongue", "drool"),
"climax_hint": ("sexual fluids", "orgasmic tension"),
"visibility": ("mouth", "pussy", "oral", "genital"),
}
return filtered(by_axis.get(axis_name, ("mouth", "pussy", "tongue")), ("penis", "breasts"))
return values
return row_item_policy.oral_axis_values_for_context(values, position, oral_act, axis_name)
def _outercourse_acts_for_position(values: list[Any], position: str) -> list[Any]:
position_text = str(position or "").lower()
if not position_text:
return values
def act_text(value: Any) -> str:
return _entry_text(value).lower()
def filtered(predicate: Callable[[str], bool]) -> list[Any]:
matches = [value for value in values if predicate(act_text(value))]
return matches or values
if any(term in position_text for term in ("boobjob", "titjob", "breast-sex", "breast sex")):
return filtered(lambda text: any(term in text for term in ("boobjob", "titjob", "breast sex", "breasts")))
if any(term in position_text for term in ("testicle", "balls")):
return filtered(lambda text: any(term in text for term in ("testicle", "balls")))
if "penis-licking" in position_text or "penis licking" in position_text:
return filtered(lambda text: "licking" in text or "tongue" in text)
if "handjob" in position_text or "hand job" in position_text:
return filtered(lambda text: any(term in text for term in ("handjob", "hand job", "hand wrapped", "two-handed")))
if "footjob" in position_text:
return filtered(lambda text: any(term in text for term in ("footjob", "feet", "soles", "toes")))
return values
return row_item_policy.outercourse_acts_for_position(values, position)
def _outercourse_axis_values_for_position(values: list[Any], position: str, axis_name: str) -> list[Any]:
position_text = str(position or "").lower()
if not position_text:
return values
axis_name = str(axis_name or "").lower()
if axis_name not in {"contact_detail", "hand_detail", "texture_detail", "visibility", "body_contact"}:
return values
def value_text(value: Any) -> str:
return _entry_text(value).lower()
def filtered(terms: tuple[str, ...], excluded_terms: tuple[str, ...] = ()) -> list[Any]:
matches = [
value
for value in values
if any(term in value_text(value) for term in terms)
and not any(term in value_text(value) for term in excluded_terms)
]
return matches or values
if any(term in position_text for term in ("boobjob", "titjob", "breast-sex", "breast sex")):
by_axis = {
"contact_detail": ("compressed", "glans", "breast", "breasts", "soft tissue", "skin visibly"),
"hand_detail": ("breast", "breasts", "fingers"),
"texture_detail": ("compression", "soft flesh", "skin", "flesh", "asymmetry"),
"visibility": ("breast", "breasts", "glans", "shaft"),
"body_contact": ("torso", "body angled", "shoulders", "hips"),
}
excluded_by_axis = {
"contact_detail": ("hand wrapped", "fingers and palm", "soles", "toes", "balls", "tongue"),
"hand_detail": ("base of the penis", "penis shaft", "balls", "thigh", "ankles", "stroking"),
"texture_detail": ("toes", "soles", "tongue"),
"visibility": ("balls", "soles", "toes", "hand"),
"body_contact": ("head tucked", "face directly", "base of the penis"),
}
return filtered(
by_axis.get(axis_name, ("breast", "breasts", "shaft")),
excluded_by_axis.get(axis_name, ()),
)
if any(term in position_text for term in ("testicle", "balls")):
by_axis = {
"contact_detail": ("balls", "lips", "tongue", "wet"),
"hand_detail": ("balls", "base", "thigh"),
"texture_detail": ("wet", "saliva", "skin"),
"visibility": ("balls", "mouth"),
"body_contact": ("torso", "shoulders", "head tucked", "base of the penis", "knees", "thigh"),
}
return filtered(by_axis.get(axis_name, ("balls", "mouth", "tongue")))
if "penis-licking" in position_text or "penis licking" in position_text:
by_axis = {
"contact_detail": ("tongue", "lips", "glans", "shaft", "wet"),
"hand_detail": ("base", "penis", "thigh"),
"texture_detail": ("wet", "saliva", "skin"),
"visibility": ("tongue", "penis"),
"body_contact": ("head low", "face directly", "torso", "pelvis", "base of the penis", "hips", "body angled"),
}
return filtered(by_axis.get(axis_name, ("tongue", "glans", "shaft")))
if "handjob" in position_text or "hand job" in position_text:
by_axis = {
"contact_detail": ("hand", "fingers", "palm", "shaft", "glans"),
"hand_detail": ("hand", "hands", "shaft", "penis"),
"texture_detail": ("fingers", "pressure", "skin", "shaft"),
"visibility": ("hand", "penis", "shaft", "glans"),
"body_contact": ("hips", "knees", "body angle"),
}
return filtered(by_axis.get(axis_name, ("hand", "penis", "shaft")))
if "footjob" in position_text:
by_axis = {
"contact_detail": ("soles", "toes"),
"hand_detail": ("ankles", "thighs"),
"texture_detail": ("toes", "soles", "pressure"),
"visibility": ("feet", "soles"),
"body_contact": ("legs", "knees", "body angled"),
}
excluded_by_axis = {
"contact_detail": ("hand", "finger", "palm", "balls", "tongue", "breast"),
"texture_detail": ("fingers", "tongue", "breast"),
"visibility": ("hand", "balls", "breast"),
}
return filtered(
by_axis.get(axis_name, ("feet", "soles", "toes")),
excluded_by_axis.get(axis_name, ()),
)
return values
return row_item_policy.outercourse_axis_values_for_position(values, position, axis_name)
def _compose_item(
@@ -536,57 +308,26 @@ def _compose_item(
women_count: int = 1,
men_count: int = 1,
) -> tuple[str, str, dict[str, str], dict[str, Any]]:
templates = _template_list(category, subcategory, item, "item_templates")
axes = _merged_axes(category, subcategory, item)
if templates and axes:
template_entry = _weighted_choice(rng, _compatible_entries(templates, women_count, men_count))
template = _entry_text(template_entry)
fields = [key for _, key, _, _ in Formatter().parse(template) if key]
unique_fields = list(dict.fromkeys(fields))
axis_values: dict[str, str] = {}
subcategory_slug = str(subcategory.get("slug") or "").lower()
if subcategory_slug in ("oral_sex", "outercourse_sex") and "position" in unique_fields and axes.get("position"):
position_values = _compatible_entries(axes["position"], women_count, men_count)
axis_values["position"] = _entry_text(_weighted_choice(rng, position_values))
for name in unique_fields:
if name in axis_values or name not in axes or not axes[name]:
continue
values = _compatible_entries(axes[name], women_count, men_count)
if subcategory_slug == "oral_sex" and name == "oral_act":
values = _oral_acts_for_position(values, axis_values.get("position", ""))
elif subcategory_slug == "oral_sex":
values = _oral_axis_values_for_context(
values,
axis_values.get("position", ""),
axis_values.get("oral_act", ""),
name,
)
if subcategory_slug == "outercourse_sex" and name == "outer_act":
values = _outercourse_acts_for_position(values, axis_values.get("position", ""))
if subcategory_slug == "outercourse_sex":
values = _outercourse_axis_values_for_position(values, axis_values.get("position", ""), name)
axis_values[name] = _entry_text(_weighted_choice(rng, values))
item_text = _format(template, axis_values).strip()
item_name = _item_name(item) or subcategory["name"]
return item_text, item_name, axis_values, _template_metadata(template_entry)
return _item_text(item), _item_name(item), {}, _template_metadata(item)
return row_item_policy.compose_item(
rng,
category,
subcategory,
item,
women_count,
men_count,
)
def _choose_text(rng: random.Random, items: list[Any]) -> str:
item = _weighted_choice(rng, items)
return _item_text(item)
return row_item_policy.choose_text(rng, items)
def _choose_distinct_text(rng: random.Random, items: list[Any], first_text: str) -> str:
first_text = _item_text(first_text).lower()
distinct = [item for item in items if _item_text(item).lower() != first_text]
if not distinct:
return ""
return _choose_text(rng, distinct)
return row_item_policy.choose_distinct_text(rng, items, first_text)
def _choose_pair(rng: random.Random, items: list[Any]) -> tuple[str, str]:
return _pair_from(_weighted_choice(rng, items))
return row_item_policy.choose_pair(rng, items)
def _extension_targets() -> dict[str, tuple[list[Any], bool]]: