from __future__ import annotations import json import re from pathlib import Path from typing import Any try: from . import character_config as character_policy except ImportError: # Allows local smoke tests from the repository root. import character_config as character_policy ROOT_DIR = Path(__file__).resolve().parent PROFILE_DIR = ROOT_DIR / "profiles" CHARACTER_MANUAL_FIELDS = ( "manual_age", "manual_body", "body_phrase", "skin", "hair", "eyes", "softcore_outfit", "hardcore_clothing", ) def body_phrase(body: Any, figure_note: Any = "") -> str: body = str(body or "").strip() figure_note = str(figure_note or "").strip() if not body: return figure_note if not figure_note: return f"{body} figure" if "figure" in figure_note.lower(): return f"{body} build and {figure_note}" return f"{body} figure with {figure_note}" def safe_profile_name(profile_name: str) -> str: profile_name = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(profile_name or "").strip()).strip("_") return profile_name[:64] or "profile" def profile_path(profile_name: str) -> Path: return PROFILE_DIR / f"{safe_profile_name(profile_name)}.json" def character_profile_choices() -> list[str]: if not PROFILE_DIR.exists(): return ["manual"] names = sorted(path.stem for path in PROFILE_DIR.glob("*.json") if path.is_file()) return ["manual"] + names def load_json_object(value: str | dict[str, Any] | None, label: str) -> dict[str, Any]: if not value: return {} if isinstance(value, dict): return value try: raw = json.loads(str(value)) except json.JSONDecodeError as exc: raise ValueError(f"Invalid {label} JSON: {exc}") from exc if not isinstance(raw, dict): raise ValueError(f"{label} must be a JSON object.") return raw def parse_character_manual_config(value: str | dict[str, Any] | None) -> dict[str, str]: if not value: return {} if isinstance(value, dict): raw = value else: try: raw = json.loads(str(value)) except json.JSONDecodeError: return {} if not isinstance(raw, dict): return {} return { key: str(raw.get(key) or "").strip() for key in CHARACTER_MANUAL_FIELDS if str(raw.get(key) or "").strip() } def character_manual_summary(config: dict[str, str]) -> str: parts = [f"{key}={value}" for key, value in config.items() if value] return "; ".join(parts) if parts else "manual unrestricted" def build_character_manual_config_json( manual: str | dict[str, Any] | None = "", combine_mode: str = "merge_nonempty", manual_age: str = "", manual_body: str = "", body_phrase: str = "", skin: str = "", hair: str = "", eyes: str = "", softcore_outfit: str = "", hardcore_clothing: str = "", ) -> str: base = {} if combine_mode == "replace_all" else parse_character_manual_config(manual) updates = { "manual_age": manual_age, "manual_body": manual_body, "body_phrase": body_phrase, "skin": skin, "hair": hair, "eyes": eyes, "softcore_outfit": softcore_outfit, "hardcore_clothing": hardcore_clothing, } for key, value in updates.items(): value = str(value or "").strip() if value: base[key] = value result = {"config_type": "character_manual", **base} result["summary"] = character_manual_summary(base) return json.dumps(result, ensure_ascii=True, sort_keys=True) def descriptor_detail_for_subject(subject: Any, descriptor_detail: Any) -> str: detail = character_policy.normalize_descriptor_detail(descriptor_detail) if detail != "auto": return detail return "compact" if str(subject or "").strip().lower() == "man" else "full" def descriptor_from_parts( subject: Any, age: Any, body_phrase_value: Any, skin: Any, hair: Any, eyes: Any, descriptor_detail: Any = "auto", ) -> str: subject = str(subject or "person").strip() or "person" age_text = " ".join(str(age or "").strip().split()) age_text = age_text.removesuffix(" adults").removesuffix(" adult").strip() if age_text in ("adult", "adults"): age_text = "" subject_phrase = f"{age_text} adult {subject}".strip() if age_text else f"adult {subject}" detail = descriptor_detail_for_subject(subject, descriptor_detail) detail_map = { "minimal": (body_phrase_value,), "compact": (body_phrase_value, skin), "medium": (body_phrase_value, skin, hair), "full": (body_phrase_value, skin, hair, eyes), } pieces = [subject_phrase, *detail_map.get(detail, detail_map["full"])] return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) def row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> dict[str, Any]: row = load_json_object(metadata_json, "metadata_json") if isinstance(row.get("softcore_row"), dict): return row["softcore_row"] return row def character_profile_descriptor(profile: dict[str, Any]) -> str: subject = str(profile.get("subject_type") or profile.get("subject") or "person").strip() return descriptor_from_parts( subject, profile.get("age"), profile.get("body_phrase") or body_phrase(profile.get("body"), profile.get("figure")), profile.get("skin"), profile.get("hair"), profile.get("eyes"), profile.get("descriptor_detail"), ) def normalize_character_profile(profile: dict[str, Any], profile_name: str = "") -> dict[str, Any]: subject_type = str(profile.get("subject_type") or profile.get("primary_subject") or profile.get("subject") or "").strip() if subject_type not in ("woman", "man"): subject_type = "woman" body = str(profile.get("body") or profile.get("body_type") or "").strip() figure = str(profile.get("figure") or "").strip() normalized_body_phrase = str(profile.get("body_phrase") or "").strip() or body_phrase(body, figure) normalized = { "profile_type": "character", "profile_name": safe_profile_name(profile_name or str(profile.get("profile_name") or "")), "subject_type": subject_type, "subject": subject_type, "subject_phrase": subject_type, "age": str(profile.get("age") or profile.get("age_band") or "").strip(), "body": body, "body_phrase": normalized_body_phrase, "skin": str(profile.get("skin") or "").strip(), "hair": str(profile.get("hair") or "").strip(), "eyes": str(profile.get("eyes") or "").strip(), "figure": figure, "descriptor_detail": character_policy.normalize_descriptor_detail(profile.get("descriptor_detail")), } normalized["descriptor"] = character_profile_descriptor(normalized) return normalized def build_character_profile_json( profile_name: str = "", source: str = "metadata_json", metadata_json: str | dict[str, Any] | None = "", character_slot_row: dict[str, Any] | None = None, subject_type: str = "woman", age: str = "", body: str = "", body_phrase_value: str = "", skin: str = "", hair: str = "", eyes: str = "", figure: str = "", save_now: bool = False, ) -> dict[str, str]: if source == "character_slot": row = character_slot_row or {} raw_profile = { "profile_name": profile_name, "subject_type": row.get("subject_type") or subject_type, "age": row.get("age") or age, "body": row.get("body") or body, "body_phrase": row.get("body_phrase") or body_phrase_value, "skin": row.get("skin") or skin, "hair": row.get("hair") or hair, "eyes": row.get("eyes") or eyes, "figure": row.get("figure") or figure, "descriptor_detail": row.get("descriptor_detail") or "auto", } elif source == "metadata_json": row = row_from_profile_metadata(metadata_json) raw_profile = { "profile_name": profile_name, "subject_type": row.get("subject_type") or row.get("primary_subject") or subject_type, "age": row.get("age") or row.get("age_band") or age, "body": row.get("body") or row.get("body_type") or body, "body_phrase": row.get("body_phrase") or body_phrase_value, "skin": row.get("skin") or skin, "hair": row.get("hair") or hair, "eyes": row.get("eyes") or eyes, "figure": row.get("figure") or figure, "descriptor_detail": row.get("descriptor_detail") or "auto", } else: raw_profile = { "profile_name": profile_name, "subject_type": subject_type, "age": age, "body": body, "body_phrase": body_phrase_value, "skin": skin, "hair": hair, "eyes": eyes, "figure": figure, "descriptor_detail": "auto", } profile = normalize_character_profile(raw_profile, profile_name) saved_path = "" status = "not_saved" if save_now: PROFILE_DIR.mkdir(parents=True, exist_ok=True) path = profile_path(profile["profile_name"]) path.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") saved_path = str(path) status = "saved" return { "profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True), "profile_name": profile["profile_name"], "descriptor": profile["descriptor"], "saved_path": saved_path, "status": status, } def save_character_profile_payload(profile_name: str = "", profile_json: str | dict[str, Any] | None = "") -> dict[str, str]: raw_profile = load_json_object(profile_json, "profile_json") if not raw_profile: raise ValueError("No cached character profile is available to save.") profile = normalize_character_profile(raw_profile, profile_name or str(raw_profile.get("profile_name") or "")) PROFILE_DIR.mkdir(parents=True, exist_ok=True) path = profile_path(profile["profile_name"]) path.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") return { "profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True), "profile_name": profile["profile_name"], "descriptor": profile["descriptor"], "saved_path": str(path), "status": "saved", } def empty_profile_result(status: str = "empty") -> dict[str, str]: return { "profile_json": "", "profile_name": "", "descriptor": "", "saved_path": "", "status": status, } def apply_character_profile_overrides( profile: dict[str, Any], override_subject_type: str = "", override_age: str = "", override_body: str = "", override_body_phrase: str = "", override_skin: str = "", override_hair: str = "", override_eyes: str = "", override_figure: str = "", override_descriptor_detail: str = "", ) -> dict[str, Any]: updated = dict(profile) subject_type = str(override_subject_type or "").strip() if subject_type in ("woman", "man"): updated["subject_type"] = subject_type updated["subject"] = subject_type updated["subject_phrase"] = subject_type for key, value in ( ("age", override_age), ("body", override_body), ("body_phrase", override_body_phrase), ("skin", override_skin), ("hair", override_hair), ("eyes", override_eyes), ("figure", override_figure), ): text = str(value or "").strip() if text: updated[key] = text descriptor_detail = str(override_descriptor_detail or "").strip() if descriptor_detail and descriptor_detail != "keep_profile": updated["descriptor_detail"] = character_policy.normalize_descriptor_detail(descriptor_detail) if not str(updated.get("body_phrase") or "").strip(): updated["body_phrase"] = body_phrase(updated.get("body"), updated.get("figure")) updated["descriptor"] = character_profile_descriptor(updated) return updated def load_character_profile_json( profile_name: str = "", fallback_profile_json: str | dict[str, Any] | None = "", enabled: bool = True, delete_now: bool = False, rename_now: bool = False, rename_to: str = "", override_subject_type: str = "", override_age: str = "", override_body: str = "", override_body_phrase: str = "", override_skin: str = "", override_hair: str = "", override_eyes: str = "", override_figure: str = "", override_descriptor_detail: str = "", ) -> dict[str, str]: if not enabled: return empty_profile_result("disabled") if delete_now and rename_now: return empty_profile_result("choose_delete_or_rename") raw_profile = load_json_object(fallback_profile_json, "fallback_profile_json") saved_path = "" if profile_name and profile_name != "manual": path = profile_path(profile_name) if delete_now: if path.exists(): path.unlink() return empty_profile_result(f"deleted:{path.stem}") return empty_profile_result(f"delete_missing:{safe_profile_name(profile_name)}") if rename_now: new_name = safe_profile_name(rename_to) if not rename_to.strip(): return empty_profile_result("rename_missing_name") if not path.exists(): return empty_profile_result(f"rename_missing:{safe_profile_name(profile_name)}") target = profile_path(new_name) if target.exists() and target != path: return empty_profile_result(f"rename_target_exists:{target.stem}") raw_profile = load_json_object(path.read_text(encoding="utf-8"), "character_profile") profile = normalize_character_profile(raw_profile, new_name) target.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") if target != path: path.unlink() return { "profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True), "profile_name": profile["profile_name"], "descriptor": profile["descriptor"], "saved_path": str(target), "status": f"renamed:{path.stem}->{target.stem}", } if path.exists(): raw_profile = load_json_object(path.read_text(encoding="utf-8"), "character_profile") saved_path = str(path) if not raw_profile: return empty_profile_result("empty") profile = normalize_character_profile(raw_profile, profile_name or raw_profile.get("profile_name", "")) profile = apply_character_profile_overrides( profile, override_subject_type=override_subject_type, override_age=override_age, override_body=override_body, override_body_phrase=override_body_phrase, override_skin=override_skin, override_hair=override_hair, override_eyes=override_eyes, override_figure=override_figure, override_descriptor_detail=override_descriptor_detail, ) return { "profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True), "profile_name": profile["profile_name"], "descriptor": profile["descriptor"], "saved_path": saved_path, "status": "loaded" if saved_path else "fallback", } def parse_character_profile(character_profile: str | dict[str, Any] | None) -> dict[str, Any]: raw = load_json_object(character_profile, "character_profile") if not raw: return {} if raw.get("profile_type") == "character" or any(key in raw for key in ("age", "age_band", "skin", "hair", "eyes")): return normalize_character_profile(raw, str(raw.get("profile_name") or "")) return {} def apply_character_profile_to_context( context: dict[str, Any], character_profile: str | dict[str, Any] | None, ) -> tuple[dict[str, Any], dict[str, Any], str]: profile = parse_character_profile(character_profile) if not profile: return context, {}, "none" if context.get("subject_type") not in ("woman", "man"): return context, profile, "skipped_non_single_subject" if profile["subject_type"] != context.get("subject_type"): return context, profile, "skipped_subject_mismatch" updated = dict(context) for key in ( "subject_type", "subject", "subject_phrase", "age", "body", "body_phrase", "skin", "hair", "eyes", "figure", "descriptor_detail", ): value = profile.get(key) if value: updated[key] = value updated["subject"] = profile["subject_type"] updated["subject_phrase"] = profile["subject_type"] return updated, profile, "applied" _body_phrase = body_phrase _safe_profile_name = safe_profile_name _profile_path = profile_path _load_json_object = load_json_object _parse_character_manual_config = parse_character_manual_config _character_manual_summary = character_manual_summary _descriptor_detail_for_subject = descriptor_detail_for_subject _descriptor_from_parts = descriptor_from_parts _row_from_profile_metadata = row_from_profile_metadata _character_profile_descriptor = character_profile_descriptor _normalize_character_profile = normalize_character_profile _empty_profile_result = empty_profile_result _apply_character_profile_overrides = apply_character_profile_overrides _parse_character_profile = parse_character_profile _apply_character_profile_to_context = apply_character_profile_to_context