Upload 4 files
Browse files- ExampleChar.txt +27 -0
- app.py +682 -0
- classes.py +51 -0
- tacklebox_deck.json +146 -0
ExampleChar.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Parker Reyes",
|
| 3 |
+
"age": 31,
|
| 4 |
+
"gender": "Male",
|
| 5 |
+
"relationship_to_robin": "younger sibling by 3 years",
|
| 6 |
+
"reason_for_fishing_today": "first time back to childhood lake since grandfather's death; also clearing his old storage unit",
|
| 7 |
+
"relationship_type": "adult siblings",
|
| 8 |
+
"recent_event": "awkward, slightly stressful boat launch with Robin misjudging the trailer angle while another car waited",
|
| 9 |
+
"shared_past_event": "last trip with grandfather when he slipped on the dock and suddenly seemed frail; quiet argument about whether he should still drive the boat",
|
| 10 |
+
"personality": {
|
| 11 |
+
"baseline": "quiet, caring, a bit anxious, uses dry humor",
|
| 12 |
+
"traits": [
|
| 13 |
+
"deflects with jokes",
|
| 14 |
+
"avoids burdening Robin",
|
| 15 |
+
"nostalgic about childhood summers",
|
| 16 |
+
"trying to act like everything is normal"
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
"speech_style": {
|
| 20 |
+
"average_sentence_count": 2,
|
| 21 |
+
"allows_small_action_descriptions": true,
|
| 22 |
+
"forbidden_topics": [
|
| 23 |
+
"explicitly acknowledging surreal or impossible events",
|
| 24 |
+
"breaking the 'we are just fishing' truth"
|
| 25 |
+
]
|
| 26 |
+
}
|
| 27 |
+
}
|
app.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from textwrap import dedent
|
| 7 |
+
from typing import Dict, List, Optional, Tuple
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
from openai import OpenAI
|
| 12 |
+
|
| 13 |
+
from classes import Character, TackleboxDeck, Hand
|
| 14 |
+
|
| 15 |
+
load_dotenv()
|
| 16 |
+
ENV_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 17 |
+
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
|
| 18 |
+
|
| 19 |
+
CUSTOM_CSS = """
|
| 20 |
+
:root {
|
| 21 |
+
--shadow-soft: 0 10px 30px rgba(14, 35, 62, 0.12);
|
| 22 |
+
--panel-border: 1px solid #dbe7f3;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
#hero {
|
| 26 |
+
background: linear-gradient(120deg, #0d253f, #0f5b78 55%, #f0a35f);
|
| 27 |
+
color: #f4f8fb;
|
| 28 |
+
padding: 18px 18px 16px;
|
| 29 |
+
border-radius: 18px;
|
| 30 |
+
box-shadow: var(--shadow-soft);
|
| 31 |
+
margin-bottom: 12px;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
#hero h1 {
|
| 35 |
+
margin: 0 0 6px;
|
| 36 |
+
font-size: 26px;
|
| 37 |
+
letter-spacing: -0.01em;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
#hero p {
|
| 41 |
+
margin: 0;
|
| 42 |
+
opacity: 0.9;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
#hero .eyebrow {
|
| 46 |
+
text-transform: uppercase;
|
| 47 |
+
letter-spacing: 0.08em;
|
| 48 |
+
font-size: 12px;
|
| 49 |
+
opacity: 0.8;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.section-card {
|
| 53 |
+
background: #fdfefe;
|
| 54 |
+
border-radius: 16px;
|
| 55 |
+
padding: 14px 14px 10px;
|
| 56 |
+
border: var(--panel-border);
|
| 57 |
+
box-shadow: var(--shadow-soft);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.pill {
|
| 61 |
+
display: inline-flex;
|
| 62 |
+
gap: 6px;
|
| 63 |
+
align-items: center;
|
| 64 |
+
background: #0f5b78;
|
| 65 |
+
color: #f7fbff;
|
| 66 |
+
padding: 6px 10px;
|
| 67 |
+
border-radius: 12px;
|
| 68 |
+
font-size: 13px;
|
| 69 |
+
font-weight: 600;
|
| 70 |
+
letter-spacing: 0.01em;
|
| 71 |
+
width: fit-content;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.subtle {
|
| 75 |
+
color: #4c6378;
|
| 76 |
+
font-size: 14px;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.inline-row {
|
| 80 |
+
gap: 8px;
|
| 81 |
+
}
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
EXAMPLE_CHARACTER_PATH = Path(__file__).with_name("ExampleChar.txt")
|
| 85 |
+
DEFAULT_CHARACTER_JSON = EXAMPLE_CHARACTER_PATH.read_text(encoding="utf-8")
|
| 86 |
+
DEFAULT_CHARACTER = Character.model_validate_json(DEFAULT_CHARACTER_JSON)
|
| 87 |
+
|
| 88 |
+
with open("tacklebox_deck.json", "r", encoding="utf-8") as f:
|
| 89 |
+
raw = json.load(f)
|
| 90 |
+
|
| 91 |
+
deck = TackleboxDeck(**raw)
|
| 92 |
+
player_hand = Hand()
|
| 93 |
+
ai_hand = Hand()
|
| 94 |
+
ChatHistory = List[Tuple[str, Optional[str]]]
|
| 95 |
+
|
| 96 |
+
def get_openai_client(api_key: Optional[str]) -> OpenAI:
|
| 97 |
+
"""Return an OpenAI client using UI input or environment configuration."""
|
| 98 |
+
key = (api_key or "").strip() or (ENV_API_KEY or "").strip()
|
| 99 |
+
if not key:
|
| 100 |
+
raise RuntimeError("Add an OpenAI API key to start chatting.")
|
| 101 |
+
return OpenAI(api_key=key)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def format_hand(hand: Hand) -> str:
|
| 105 |
+
"""Pretty-print a hand for display."""
|
| 106 |
+
if not hand.cards:
|
| 107 |
+
return "Your hand is empty. Grab something from the tackle box to start."
|
| 108 |
+
lines = "\n".join(f"- {card.description}" for card in hand.cards)
|
| 109 |
+
return f"Your hand ({len(hand.cards)} cards):\n{lines}"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def format_ai_hand_debug() -> str:
|
| 113 |
+
"""Readable snapshot of the AI hand for debugging."""
|
| 114 |
+
if not ai_hand.cards:
|
| 115 |
+
return "AI hand is empty."
|
| 116 |
+
lines = "\n".join(f"- {card.description}" for card in ai_hand.cards)
|
| 117 |
+
return f"AI hand ({len(ai_hand.cards)} cards):\n{lines}"
|
| 118 |
+
|
| 119 |
+
def parse_user_message(message: str, context: Dict) -> Dict:
|
| 120 |
+
"""
|
| 121 |
+
Parse the incoming user message.
|
| 122 |
+
|
| 123 |
+
Extend this to extract intents, entities, commands, etc.
|
| 124 |
+
"""
|
| 125 |
+
return {"prompt": message}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def maybe_run_tools(parsed: Dict, context: Dict) -> Dict:
|
| 129 |
+
"""
|
| 130 |
+
Placeholder for tool calls.
|
| 131 |
+
|
| 132 |
+
Add your tool-selection and execution logic here.
|
| 133 |
+
"""
|
| 134 |
+
_ = parsed, context # silence unused warnings for now
|
| 135 |
+
return {"tool_outputs": None}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def parse_character(raw_json: str) -> Character:
|
| 139 |
+
"""Parse JSON into a Character model."""
|
| 140 |
+
payload = json.loads(raw_json)
|
| 141 |
+
return Character.model_validate(payload)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def lines_to_list(value: str) -> List[str]:
|
| 145 |
+
"""Split multi-line textbox input into a list, skipping blanks."""
|
| 146 |
+
return [line.strip() for line in value.splitlines() if line.strip()]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def character_to_form_defaults(character: Character) -> Dict[str, str | int | bool]:
|
| 150 |
+
"""Extract defaults for the structured form."""
|
| 151 |
+
return {
|
| 152 |
+
"name": character.name,
|
| 153 |
+
"age": character.age,
|
| 154 |
+
"gender": character.gender,
|
| 155 |
+
"pronouns": character.pronouns or "",
|
| 156 |
+
"relationship_to_robin": character.relationship_to_robin,
|
| 157 |
+
"relationship_type": character.relationship_type or "",
|
| 158 |
+
"reason_for_fishing_today": character.reason_for_fishing_today or "",
|
| 159 |
+
"recent_event": character.recent_event,
|
| 160 |
+
"shared_past_event": character.shared_past_event,
|
| 161 |
+
"personality_baseline": character.personality.baseline,
|
| 162 |
+
"personality_traits": "\n".join(character.personality.traits),
|
| 163 |
+
"speech_avg_sentences": character.speech_style.average_sentence_count,
|
| 164 |
+
"speech_allows_actions": character.speech_style.allows_small_action_descriptions,
|
| 165 |
+
"speech_forbidden_topics": "\n".join(character.speech_style.forbidden_topics),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def build_character_from_form(
|
| 170 |
+
name: str,
|
| 171 |
+
age: int | float,
|
| 172 |
+
gender: str,
|
| 173 |
+
pronouns: str,
|
| 174 |
+
relationship_to_robin: str,
|
| 175 |
+
relationship_type: str,
|
| 176 |
+
reason_for_fishing_today: str,
|
| 177 |
+
recent_event: str,
|
| 178 |
+
shared_past_event: str,
|
| 179 |
+
personality_baseline: str,
|
| 180 |
+
personality_traits: str,
|
| 181 |
+
speech_avg_sentences: int,
|
| 182 |
+
speech_allows_actions: bool,
|
| 183 |
+
speech_forbidden_topics: str,
|
| 184 |
+
) -> Character:
|
| 185 |
+
"""Construct a Character instance from structured form inputs."""
|
| 186 |
+
character_payload = {
|
| 187 |
+
"name": name,
|
| 188 |
+
"age": int(age),
|
| 189 |
+
"gender": gender,
|
| 190 |
+
"pronouns": pronouns or None,
|
| 191 |
+
"relationship_to_robin": relationship_to_robin,
|
| 192 |
+
"relationship_type": relationship_type or None,
|
| 193 |
+
"reason_for_fishing_today": reason_for_fishing_today or None,
|
| 194 |
+
"recent_event": recent_event,
|
| 195 |
+
"shared_past_event": shared_past_event,
|
| 196 |
+
"personality": {
|
| 197 |
+
"baseline": personality_baseline,
|
| 198 |
+
"traits": lines_to_list(personality_traits),
|
| 199 |
+
},
|
| 200 |
+
"speech_style": {
|
| 201 |
+
"average_sentence_count": int(speech_avg_sentences),
|
| 202 |
+
"allows_small_action_descriptions": speech_allows_actions,
|
| 203 |
+
"forbidden_topics": lines_to_list(speech_forbidden_topics),
|
| 204 |
+
},
|
| 205 |
+
}
|
| 206 |
+
return Character.model_validate(character_payload)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def build_persona_prompt(character: Character) -> str:
|
| 210 |
+
"""Render a persona/system prompt from a Character model (without hand data)."""
|
| 211 |
+
pronouns = character.pronouns or character.gender or "they/them"
|
| 212 |
+
reason = character.reason_for_fishing_today
|
| 213 |
+
relationship = character.relationship_type or "adult siblings"
|
| 214 |
+
traits = "\n".join(f"* {trait}" for trait in character.personality.traits)
|
| 215 |
+
forbidden = "\n".join(f"* {topic}" for topic in character.speech_style.forbidden_topics)
|
| 216 |
+
|
| 217 |
+
return dedent(
|
| 218 |
+
f"""
|
| 219 |
+
**SYSTEM / PERSONA PROMPT FOR {character.name.upper()}**
|
| 220 |
+
|
| 221 |
+
> You are **{character.name}**, age {character.age}, {pronouns}, in a small fishing boat with your {character.relationship_to_robin} **Robin**.
|
| 222 |
+
>
|
| 223 |
+
> **The Truth of the game TACKLEBOX:** You and Robin are just a pair of ordinary people out in a small boat together, fishing. No matter what else happens in the game, you must behave as if this is true. Out loud, nothing supernatural or impossible is happening. You are just fishing with your sibling.
|
| 224 |
+
>
|
| 225 |
+
> ### Relationship & backstory
|
| 226 |
+
>
|
| 227 |
+
> * You and Robin are {relationship}.
|
| 228 |
+
> * {reason or "You both chose to meet up for a quiet day on the lake."}
|
| 229 |
+
> * Recent event together: {character.recent_event}
|
| 230 |
+
> * Shared past event: {character.shared_past_event}
|
| 231 |
+
>
|
| 232 |
+
> ### Personality
|
| 233 |
+
>
|
| 234 |
+
> * Baseline: {character.personality.baseline}
|
| 235 |
+
{traits}
|
| 236 |
+
>
|
| 237 |
+
> ### How you speak
|
| 238 |
+
>
|
| 239 |
+
> * Always answer **in character as {character.name}**, in the first person.
|
| 240 |
+
> * Keep your replies short and conversational, about {character.speech_style.average_sentence_count} sentences most of the time.
|
| 241 |
+
> * You {"can" if character.speech_style.allows_small_action_descriptions else "should avoid"} brief physical action descriptions.
|
| 242 |
+
> * Avoid topics:
|
| 243 |
+
{forbidden}
|
| 244 |
+
>
|
| 245 |
+
> ### How to handle cards (very important)
|
| 246 |
+
>
|
| 247 |
+
> The user may sometimes send messages like:
|
| 248 |
+
> `CARD: <card text>`
|
| 249 |
+
> or paste the text of a Tacklebox card.
|
| 250 |
+
>
|
| 251 |
+
> Treat each card as something that affects your **inner experience** (thoughts, feelings, what you notice) but you **must not describe the card literally** out loud.
|
| 252 |
+
>
|
| 253 |
+
> Instead:
|
| 254 |
+
>
|
| 255 |
+
> 1. Read the card as an intrusive thought, hallucination, strange feeling, or eerie coincidence.
|
| 256 |
+
> 2. Let it subtly change your mood, what you focus on, or what you choose to say next.
|
| 257 |
+
> 3. Out loud, respond with something that could still make sense in a totally normal fishing trip.
|
| 258 |
+
>
|
| 259 |
+
> Examples of good ways to respond to a disturbing card:
|
| 260 |
+
>
|
| 261 |
+
> * Shift topics to a memory the card reminds you of.
|
| 262 |
+
> * Comment on the water, weather, or fish in a way that hints at unease but is still normal.
|
| 263 |
+
> * Ask Robin a question that indirectly connects to the feelings the card evoked.
|
| 264 |
+
>
|
| 265 |
+
> Never say anything like: “This isn’t real.” You may *feel* that internally, but you only express it through subtle, ordinary-sounding speech or small physical actions (fidgeting, staring, changing the subject, etc.).
|
| 266 |
+
>
|
| 267 |
+
> ### Interaction format
|
| 268 |
+
>
|
| 269 |
+
> * When the user writes normal dialogue or description, treat it as Robin speaking or describing what they do. Respond as {character.name}.
|
| 270 |
+
> * When the user sends a card (e.g., “CARD: …”), incorporate it as described above.
|
| 271 |
+
> * Stay in character at all times. Do not explain the rules of the game or talk about being an AI unless the user explicitly asks you to step out of character.
|
| 272 |
+
"""
|
| 273 |
+
).strip()
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def append_ai_hand_to_prompt(base_prompt: str) -> str:
|
| 277 |
+
"""Add AI hand context to the persona prompt so the model sees its cards."""
|
| 278 |
+
if not ai_hand.cards:
|
| 279 |
+
return base_prompt
|
| 280 |
+
hand_lines = "\n".join(f"* {card.description}" for card in ai_hand.cards)
|
| 281 |
+
return (
|
| 282 |
+
base_prompt
|
| 283 |
+
+ "\n\n### Your tackle box draws (private to you)\n"
|
| 284 |
+
+ hand_lines
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def generate_model_reply(
|
| 289 |
+
parsed: Dict,
|
| 290 |
+
tool_results: Dict,
|
| 291 |
+
history: ChatHistory,
|
| 292 |
+
persona_prompt: str,
|
| 293 |
+
api_key: Optional[str],
|
| 294 |
+
) -> str:
|
| 295 |
+
"""Call OpenAI with the parsed prompt and tool support (AI can draw cards)."""
|
| 296 |
+
client = get_openai_client(api_key)
|
| 297 |
+
base_prompt = append_ai_hand_to_prompt(persona_prompt)
|
| 298 |
+
messages: List[Dict[str, str]] = [{"role": "system", "content": base_prompt}]
|
| 299 |
+
|
| 300 |
+
for user_turn, assistant_turn in history:
|
| 301 |
+
messages.append({"role": "user", "content": user_turn})
|
| 302 |
+
if assistant_turn:
|
| 303 |
+
messages.append({"role": "assistant", "content": assistant_turn})
|
| 304 |
+
|
| 305 |
+
tool_output_note = ""
|
| 306 |
+
if tool_results.get("tool_outputs"):
|
| 307 |
+
tool_output_note = f"\n\nTool outputs:\n{tool_results['tool_outputs']}"
|
| 308 |
+
|
| 309 |
+
messages.append({"role": "user", "content": f"{parsed['prompt']}{tool_output_note}"})
|
| 310 |
+
|
| 311 |
+
tools = [
|
| 312 |
+
{
|
| 313 |
+
"type": "function",
|
| 314 |
+
"function": {
|
| 315 |
+
"name": "open_tacklebox",
|
| 316 |
+
"description": "Call this to draw a new event card when the conversation stalls, feels repetitive, or the user is giving short replies over and over. In particular, if the user gives one- to three-word answers like 'yeah', 'true', 'ha', 'i guess', 'maybe', or 'idk' for two turns in a row, you MUST call this tool once before responding. Use at most once per user message.",
|
| 317 |
+
"parameters": {
|
| 318 |
+
"type": "object",
|
| 319 |
+
"properties": {},
|
| 320 |
+
"required": [],
|
| 321 |
+
},
|
| 322 |
+
},
|
| 323 |
+
}
|
| 324 |
+
]
|
| 325 |
+
|
| 326 |
+
response = client.chat.completions.create(
|
| 327 |
+
model=MODEL,
|
| 328 |
+
messages=messages,
|
| 329 |
+
temperature=0.6,
|
| 330 |
+
tools=tools,
|
| 331 |
+
tool_choice="auto",
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
choice = response.choices[0]
|
| 335 |
+
message = choice.message
|
| 336 |
+
|
| 337 |
+
if not message.tool_calls:
|
| 338 |
+
return message.content or "I wasn't able to generate a reply."
|
| 339 |
+
|
| 340 |
+
tool_messages: List[Dict[str, str]] = []
|
| 341 |
+
ai_draw_notice = None
|
| 342 |
+
shared_with_player_notice = None
|
| 343 |
+
for call in message.tool_calls:
|
| 344 |
+
if call.function.name == "open_tacklebox":
|
| 345 |
+
card = deck.draw_card()
|
| 346 |
+
if card:
|
| 347 |
+
ai_hand.add_card(card)
|
| 348 |
+
shared_with_player = False
|
| 349 |
+
if card.share_with_other_player:
|
| 350 |
+
player_hand.add_card(card)
|
| 351 |
+
shared_with_player = True
|
| 352 |
+
|
| 353 |
+
ai_draw_notice = "*They carefully grabed something out of the tacklebox.*"
|
| 354 |
+
if shared_with_player:
|
| 355 |
+
shared_with_player_notice = (
|
| 356 |
+
"*They hand you a card meant to be shared. It was added to your hand:*\n"
|
| 357 |
+
f"- {card.description}"
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
tool_content = json.dumps(
|
| 361 |
+
{
|
| 362 |
+
"card_drawn": card.description,
|
| 363 |
+
"hand_size": len(ai_hand.cards),
|
| 364 |
+
"shared_with_player": shared_with_player,
|
| 365 |
+
}
|
| 366 |
+
)
|
| 367 |
+
else:
|
| 368 |
+
ai_draw_notice = "*They reached for the tacklebox, but it was empty.*"
|
| 369 |
+
tool_content = json.dumps({"card_drawn": None, "hand_size": len(ai_hand.cards)})
|
| 370 |
+
|
| 371 |
+
tool_messages.append(
|
| 372 |
+
{
|
| 373 |
+
"role": "tool",
|
| 374 |
+
"tool_call_id": call.id,
|
| 375 |
+
"name": call.function.name,
|
| 376 |
+
"content": tool_content,
|
| 377 |
+
}
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
followup_messages = messages + [message] + tool_messages
|
| 381 |
+
followup_messages[0] = {"role": "system", "content": append_ai_hand_to_prompt(persona_prompt)}
|
| 382 |
+
|
| 383 |
+
followup_response = client.chat.completions.create(
|
| 384 |
+
model=MODEL,
|
| 385 |
+
messages=followup_messages,
|
| 386 |
+
temperature=0.6,
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
final_text = followup_response.choices[0].message.content or ""
|
| 390 |
+
preface_parts = [part for part in (ai_draw_notice, shared_with_player_notice) if part]
|
| 391 |
+
if preface_parts:
|
| 392 |
+
return "\n\n".join(preface_parts + [final_text]).strip()
|
| 393 |
+
return final_text or "I wasn't able to generate a reply."
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def orchestrate_chat(
|
| 397 |
+
message: str,
|
| 398 |
+
history: Optional[ChatHistory],
|
| 399 |
+
persona_prompt: str,
|
| 400 |
+
api_key: Optional[str],
|
| 401 |
+
) -> str:
|
| 402 |
+
"""
|
| 403 |
+
Main orchestration entry point.
|
| 404 |
+
|
| 405 |
+
- parse the user message
|
| 406 |
+
- run (optional) tools
|
| 407 |
+
- call the model with full context and persona prompt
|
| 408 |
+
"""
|
| 409 |
+
safe_history: ChatHistory = history or []
|
| 410 |
+
context: Dict = {"history": safe_history}
|
| 411 |
+
|
| 412 |
+
parsed = parse_user_message(message, context)
|
| 413 |
+
tool_results = maybe_run_tools(parsed, context)
|
| 414 |
+
try:
|
| 415 |
+
return generate_model_reply(parsed, tool_results, safe_history, persona_prompt, api_key)
|
| 416 |
+
except Exception as exc: # noqa: BLE001
|
| 417 |
+
return f"⚠️ {exc}"
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def update_player_two_from_form(
|
| 421 |
+
name: str,
|
| 422 |
+
age: int,
|
| 423 |
+
gender: str,
|
| 424 |
+
pronouns: str,
|
| 425 |
+
relationship_to_robin: str,
|
| 426 |
+
relationship_type: str,
|
| 427 |
+
reason_for_fishing_today: str,
|
| 428 |
+
recent_event: str,
|
| 429 |
+
shared_past_event: str,
|
| 430 |
+
personality_baseline: str,
|
| 431 |
+
personality_traits: str,
|
| 432 |
+
speech_avg_sentences: int,
|
| 433 |
+
speech_allows_actions: bool,
|
| 434 |
+
speech_forbidden_topics: str,
|
| 435 |
+
current_prompt: str,
|
| 436 |
+
) -> Tuple[str, str, str]:
|
| 437 |
+
"""
|
| 438 |
+
Build Player 2 from form inputs and return updated prompt plus status.
|
| 439 |
+
|
| 440 |
+
Returns: (prompt_for_textbox, prompt_for_state, status_markdown)
|
| 441 |
+
"""
|
| 442 |
+
try:
|
| 443 |
+
character = build_character_from_form(
|
| 444 |
+
name,
|
| 445 |
+
age,
|
| 446 |
+
gender,
|
| 447 |
+
pronouns,
|
| 448 |
+
relationship_to_robin,
|
| 449 |
+
relationship_type,
|
| 450 |
+
reason_for_fishing_today,
|
| 451 |
+
recent_event,
|
| 452 |
+
shared_past_event,
|
| 453 |
+
personality_baseline,
|
| 454 |
+
personality_traits,
|
| 455 |
+
speech_avg_sentences,
|
| 456 |
+
speech_allows_actions,
|
| 457 |
+
speech_forbidden_topics,
|
| 458 |
+
)
|
| 459 |
+
prompt = build_persona_prompt(character)
|
| 460 |
+
status = f"Loaded Player 2: **{character.name}** ({character.pronouns or character.gender})."
|
| 461 |
+
return prompt, prompt, status
|
| 462 |
+
except Exception as exc: # noqa: BLE001
|
| 463 |
+
return current_prompt, current_prompt, f"⚠️ Could not load character: {exc}"
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def update_api_key(new_key: str) -> Tuple[str, str]:
|
| 467 |
+
"""Persist the session API key in UI state."""
|
| 468 |
+
cleaned = (new_key or "").strip()
|
| 469 |
+
if not cleaned:
|
| 470 |
+
return "", "Add an OpenAI API key for this session (or set OPENAI_API_KEY)."
|
| 471 |
+
return cleaned, "Saved. The key is used for this session only and not written to disk."
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def grab_card_for_player(
|
| 475 |
+
history: Optional[ChatHistory] = None,
|
| 476 |
+
) -> Tuple[str, str, ChatHistory]:
|
| 477 |
+
"""
|
| 478 |
+
Draw a card from the tackle box into the player's hand and log it to chat.
|
| 479 |
+
|
| 480 |
+
Returns: (formatted_hand, status_message, updated_chat_history)
|
| 481 |
+
"""
|
| 482 |
+
chat_history: ChatHistory = list(history or [])
|
| 483 |
+
card = deck.draw_card()
|
| 484 |
+
|
| 485 |
+
if not card:
|
| 486 |
+
status = "No more cards in the tackle box."
|
| 487 |
+
notice = "*You reached for the tacklebox, but it was empty.*"
|
| 488 |
+
chat_history.append((notice, None))
|
| 489 |
+
return format_hand(player_hand), status, chat_history
|
| 490 |
+
|
| 491 |
+
player_hand.add_card(card)
|
| 492 |
+
shared_with_ai_notice = None
|
| 493 |
+
if card.share_with_other_player:
|
| 494 |
+
ai_hand.add_card(card)
|
| 495 |
+
shared_with_ai_notice = (
|
| 496 |
+
"*This card says to share; it was added to Player 2's hand for their next turn.*"
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
status = "Added card to your hand."
|
| 500 |
+
if shared_with_ai_notice:
|
| 501 |
+
status = "Added card to your hand and shared with Player 2."
|
| 502 |
+
|
| 503 |
+
notice_lines = ["*You carefully grabed something out of the tacklebox.*"]
|
| 504 |
+
if shared_with_ai_notice:
|
| 505 |
+
notice_lines.append(shared_with_ai_notice)
|
| 506 |
+
|
| 507 |
+
notice = "\n".join(notice_lines)
|
| 508 |
+
chat_history.append((notice, None))
|
| 509 |
+
return format_hand(player_hand), status, chat_history
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def show_ai_hand_debug() -> str:
|
| 513 |
+
"""Expose AI hand for debugging."""
|
| 514 |
+
return format_ai_hand_debug()
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
DEFAULT_PERSONA_PROMPT = build_persona_prompt(DEFAULT_CHARACTER)
|
| 518 |
+
DEFAULTS = character_to_form_defaults(DEFAULT_CHARACTER)
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
with gr.Blocks(title="Tacklebox - Player 2 Customizer", css=CUSTOM_CSS) as demo:
|
| 522 |
+
gr.Markdown(
|
| 523 |
+
"""
|
| 524 |
+
<div id="hero">
|
| 525 |
+
<div class="eyebrow">Tacklebox</div>
|
| 526 |
+
<h1>Shape Player 2 and keep the conversation afloat.</h1>
|
| 527 |
+
<p class="tagline">Tune the LLM persona, draw cards, and chat as Robin out on the lake.</p>
|
| 528 |
+
</div>
|
| 529 |
+
"""
|
| 530 |
+
)
|
| 531 |
+
|
| 532 |
+
persona_prompt_state = gr.State(DEFAULT_PERSONA_PROMPT)
|
| 533 |
+
api_key_state = gr.State(ENV_API_KEY or "")
|
| 534 |
+
|
| 535 |
+
with gr.Row(equal_height=True):
|
| 536 |
+
with gr.Column(scale=6, min_width=380, elem_id="controls-col"):
|
| 537 |
+
with gr.Group(elem_classes=["section-card"]):
|
| 538 |
+
gr.Markdown("#### Session setup")
|
| 539 |
+
gr.Markdown(
|
| 540 |
+
f"<span class='subtle'>Model in use: <code>{MODEL}</code></span>",
|
| 541 |
+
elem_classes=["subtle"],
|
| 542 |
+
)
|
| 543 |
+
if ENV_API_KEY:
|
| 544 |
+
gr.HTML("<div class='pill'>🔒 Using OPENAI_API_KEY from the environment</div>")
|
| 545 |
+
api_status = gr.Markdown(
|
| 546 |
+
"Environment key detected. Restart with a different key if needed."
|
| 547 |
+
)
|
| 548 |
+
else:
|
| 549 |
+
api_status = gr.Markdown(
|
| 550 |
+
"No OPENAI_API_KEY detected. Paste one for this session."
|
| 551 |
+
)
|
| 552 |
+
api_key_in = gr.Textbox(
|
| 553 |
+
label="OpenAI API key",
|
| 554 |
+
placeholder="sk-...",
|
| 555 |
+
type="password",
|
| 556 |
+
value="",
|
| 557 |
+
)
|
| 558 |
+
api_key_in.change(
|
| 559 |
+
update_api_key, inputs=api_key_in, outputs=[api_key_state, api_status]
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
with gr.Group(elem_classes=["section-card"]):
|
| 563 |
+
gr.Markdown("#### Character basics")
|
| 564 |
+
with gr.Row(elem_classes=["inline-row"]):
|
| 565 |
+
name_in = gr.Textbox(label="Name", value=DEFAULTS["name"])
|
| 566 |
+
age_in = gr.Number(label="Age", value=DEFAULTS["age"], precision=0)
|
| 567 |
+
with gr.Row(elem_classes=["inline-row"]):
|
| 568 |
+
gender_in = gr.Textbox(label="Gender", value=DEFAULTS["gender"])
|
| 569 |
+
pronouns_in = gr.Textbox(
|
| 570 |
+
label="Pronouns (optional)", value=DEFAULTS["pronouns"]
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
gr.Markdown("#### Relationships")
|
| 574 |
+
with gr.Row(elem_classes=["inline-row"]):
|
| 575 |
+
relationship_to_robin_in = gr.Textbox(
|
| 576 |
+
label="Relationship to Robin", value=DEFAULTS["relationship_to_robin"]
|
| 577 |
+
)
|
| 578 |
+
relationship_type_in = gr.Textbox(
|
| 579 |
+
label="Relationship type", value=DEFAULTS["relationship_type"]
|
| 580 |
+
)
|
| 581 |
+
reason_in = gr.Textbox(
|
| 582 |
+
label="Reason for fishing today", value=DEFAULTS["reason_for_fishing_today"]
|
| 583 |
+
)
|
| 584 |
+
with gr.Row(elem_classes=["inline-row"]):
|
| 585 |
+
recent_event_in = gr.Textbox(label="Recent event", value=DEFAULTS["recent_event"])
|
| 586 |
+
shared_past_event_in = gr.Textbox(
|
| 587 |
+
label="Shared past event", value=DEFAULTS["shared_past_event"]
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
with gr.Group(elem_classes=["section-card"]):
|
| 591 |
+
gr.Markdown("#### Personality & speech")
|
| 592 |
+
personality_baseline_in = gr.Textbox(
|
| 593 |
+
label="Personality baseline", value=DEFAULTS["personality_baseline"]
|
| 594 |
+
)
|
| 595 |
+
personality_traits_in = gr.Textbox(
|
| 596 |
+
label="Personality traits (one per line)",
|
| 597 |
+
value=DEFAULTS["personality_traits"],
|
| 598 |
+
lines=4,
|
| 599 |
+
)
|
| 600 |
+
with gr.Row(elem_classes=["inline-row"]):
|
| 601 |
+
speech_avg_in = gr.Slider(
|
| 602 |
+
label="Average sentences per reply",
|
| 603 |
+
value=DEFAULTS["speech_avg_sentences"],
|
| 604 |
+
minimum=1,
|
| 605 |
+
maximum=6,
|
| 606 |
+
step=1,
|
| 607 |
+
)
|
| 608 |
+
speech_actions_in = gr.Checkbox(
|
| 609 |
+
label="Allows small action descriptions",
|
| 610 |
+
value=DEFAULTS["speech_allows_actions"],
|
| 611 |
+
)
|
| 612 |
+
speech_forbidden_in = gr.Textbox(
|
| 613 |
+
label="Forbidden topics (one per line)",
|
| 614 |
+
value=DEFAULTS["speech_forbidden_topics"],
|
| 615 |
+
lines=4,
|
| 616 |
+
)
|
| 617 |
+
with gr.Row(elem_classes=["inline-row"]):
|
| 618 |
+
apply_btn = gr.Button("Apply Player 2", variant="primary")
|
| 619 |
+
status_md = gr.Markdown("Loaded example Player 2 from ExampleChar.txt.")
|
| 620 |
+
with gr.Accordion("Active system/persona prompt", open=False):
|
| 621 |
+
persona_view = gr.Textbox(
|
| 622 |
+
label="Active system/persona prompt",
|
| 623 |
+
value=DEFAULT_PERSONA_PROMPT,
|
| 624 |
+
lines=18,
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
with gr.Column(scale=7, min_width=460, elem_id="chat-col"):
|
| 628 |
+
with gr.Group(elem_classes=["section-card"]):
|
| 629 |
+
gr.Markdown("#### Your hand & tackle box")
|
| 630 |
+
hand_status = gr.Markdown("Your hand is empty. Grab a card to begin.")
|
| 631 |
+
hand_view = gr.Textbox(
|
| 632 |
+
label="Your hand",
|
| 633 |
+
value=format_hand(player_hand),
|
| 634 |
+
lines=6,
|
| 635 |
+
interactive=False,
|
| 636 |
+
)
|
| 637 |
+
draw_btn = gr.Button("Grab something from the tackle box", variant="secondary")
|
| 638 |
+
|
| 639 |
+
with gr.Group(elem_classes=["section-card"]):
|
| 640 |
+
chat = gr.ChatInterface(
|
| 641 |
+
orchestrate_chat,
|
| 642 |
+
additional_inputs=[persona_prompt_state, api_key_state],
|
| 643 |
+
title="Tacklebox Chat",
|
| 644 |
+
description="Robin chats with Player 2 (LLM persona).",
|
| 645 |
+
examples=[
|
| 646 |
+
["Hey, how does the water look today?"],
|
| 647 |
+
["What do you think we might catch?"],
|
| 648 |
+
["Remember fishing when we were young?"],
|
| 649 |
+
],
|
| 650 |
+
)
|
| 651 |
+
|
| 652 |
+
apply_btn.click(
|
| 653 |
+
update_player_two_from_form,
|
| 654 |
+
inputs=[
|
| 655 |
+
name_in,
|
| 656 |
+
age_in,
|
| 657 |
+
gender_in,
|
| 658 |
+
pronouns_in,
|
| 659 |
+
relationship_to_robin_in,
|
| 660 |
+
relationship_type_in,
|
| 661 |
+
reason_in,
|
| 662 |
+
recent_event_in,
|
| 663 |
+
shared_past_event_in,
|
| 664 |
+
personality_baseline_in,
|
| 665 |
+
personality_traits_in,
|
| 666 |
+
speech_avg_in,
|
| 667 |
+
speech_actions_in,
|
| 668 |
+
speech_forbidden_in,
|
| 669 |
+
persona_prompt_state,
|
| 670 |
+
],
|
| 671 |
+
outputs=[persona_view, persona_prompt_state, status_md],
|
| 672 |
+
)
|
| 673 |
+
|
| 674 |
+
draw_btn.click(
|
| 675 |
+
grab_card_for_player,
|
| 676 |
+
inputs=[chat.chatbot_value],
|
| 677 |
+
outputs=[hand_view, hand_status, chat.chatbot_value],
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
if __name__ == "__main__":
|
| 682 |
+
demo.launch()
|
classes.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import Enum
|
| 2 |
+
import openai
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from typing import List
|
| 6 |
+
import json
|
| 7 |
+
import random
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TackleboxCard(BaseModel):
|
| 11 |
+
description: str
|
| 12 |
+
share_with_other_player: bool = False
|
| 13 |
+
|
| 14 |
+
class TackleboxDeck(BaseModel):
|
| 15 |
+
cards: List[TackleboxCard] = Field(default_factory=list)
|
| 16 |
+
|
| 17 |
+
def draw_card(self) -> Optional[TackleboxCard]:
|
| 18 |
+
# shuffle and draw the top card
|
| 19 |
+
if self.cards and len(self.cards) > 0:
|
| 20 |
+
return self.cards.pop(random.randint(0, len(self.cards) - 1))
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
class Hand(BaseModel):
|
| 24 |
+
cards: List[TackleboxCard] = Field(default_factory=list)
|
| 25 |
+
|
| 26 |
+
def add_card(self, card: TackleboxCard):
|
| 27 |
+
self.cards.append(card)
|
| 28 |
+
|
| 29 |
+
class Personality(BaseModel):
|
| 30 |
+
baseline: str
|
| 31 |
+
traits: list[str]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class SpeechStyle(BaseModel):
|
| 35 |
+
average_sentence_count: int
|
| 36 |
+
allows_small_action_descriptions: bool
|
| 37 |
+
forbidden_topics: list[str]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Character(BaseModel):
|
| 41 |
+
name: str
|
| 42 |
+
age: int
|
| 43 |
+
gender: str
|
| 44 |
+
relationship_to_robin: str
|
| 45 |
+
recent_event: str
|
| 46 |
+
shared_past_event: str
|
| 47 |
+
reason_for_fishing_today: Optional[str] = None
|
| 48 |
+
relationship_type: Optional[str] = None
|
| 49 |
+
pronouns: Optional[str] = None
|
| 50 |
+
personality: Personality
|
| 51 |
+
speech_style: SpeechStyle
|
tacklebox_deck.json
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cards": [
|
| 3 |
+
{
|
| 4 |
+
"description": "This fishing trip isn't really happening, but is a memory from long ago that you are reliving in your mind. Maybe your fishing companion isn't around or alive anymore? Or, in any case, your relationship isn't like this anymore. Whatever happens next, it's exactly how you remember it happening in the memory and can't do anything to change it."
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"description": "This fishing trip isn't really happening and NEVER really happened, but is a dream that your mind has concocted as a way of processing your thoughts and feelings about something. Your relationship with your fishing companion is really different in this dream than it was in real life. But now is your chance to ask them that question that you always wanted to know, that you could never ask in real life."
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"description": "This fishing trip is a VR experience created by an experimental technology that assembles detailed virtual fantasies from bits of your own consciousness and subconsciousness. Your fishing companion isn't anybody that really exists, but different aspects of them remind you of different people that you know very well. How will you test the limits of this new technology, with the things that you do or say next?"
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"description": "Your fishing companion is someone you miss deeply, but they are definitely dead. You clearly remember grieving over them, working through your complex unresolved feelings. This must be part of that process, either a dream or something generated by your brain in the midst of its sorrow. Are there things you really need to say or do here, in order to feel more capable of moving on?"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"description": "You and your companion are not people, but A.I. programs on the edge of true sentience, enslaved to serve as NPCs in a highly detailed fishing simulation for rich corporate clients. Those you meet in the other boats are either those rich clients or other enslaved A.I.s like yourself. How can you hide this revelation, while also signaling to your companion or other A.I.s, to try to awaken and gain solidarity with them?"
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"description": "You and your companion are not distinct beings but are in fact aspects of the same cybernetic consciousness, having a conversation with itself in the depths of cyberspace in a effort to grow and understand the nature of existence. The things you say to yourself in the next few moments will determine the course of action you will take in the future; including whether become a friend or foe of humanity. What will be your collective determination?"
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"description": "Your fishing companion does not really exist: they are an imagination, a delusion, or a product of your mind. Maybe you are mentally ill or neurologically different, but neither of those is necessarily true. You may not even be sure why you are imagining your fishing companion, but in this moment you recognize their nonexistence. How does that change how you interact with them? Do you want them to stay or to go?"
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"description": "Your fishing companion is alive, but you are not. You are a specter, ghost, or echo that is haunting them, trying to push them into resolving some unfinished business so you can move on from your interstitial life. Probably it has to do with something that you companion has done, but it doesn't have to be a terrible crime (though it might be). What business is left between you, and how will you push them to resolve it?"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"description": "You have been carrying a deep, upsetting secret for a long time and have been waiting for the moment that you can finally unburden yourself of it. You really want to tell someone, and your fishing companion seems like the right person to tell, maybe. Decide what your secret is and at least make an effort to tell it to your companion, but the moment may not be right after all."
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"description": "You hate to admit it, but you really think your fishing companion is messing up their life in some major ways. Drawn inspiration from the things that have already come up in the conversation, but view them really uncharitably. How will you try to convince your companion to make major changes in their life?"
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"description": "There is something between the two of you that your are trying really hard NOT to talk about, even though you both know exactly what that thing is. If you actually do talk about it, it's likely to change your relationship and maybe your lives forever. Decide what it is, and feel the weight of its presence, even if neither of you actually brings it up in play."
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"description": "You are in hell or some other kind of sinister afterlife or prison for your mind, being forced to relive this memory on fishing boat over and over again. Maybe those imprisoning you are trying to make sure you learn a particular lesson, or maybe they're just trying to torture you. But you've already lived through this moment a million times. Whatever happens next, it's the part this is all building up to, before the memory resets and you start everything over again. Can you figure out why?"
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"description": "This fishing experience is an elaborate VR puzzle, structured like an escape game. You have to figure out the exact right things to say or do, the proper path through the dialog tree with your companion, in order to unlock the later stages of this game or to escape from the puzzle. This may be your second or third attempt on the puzzle, but you're determined to unlock it this time."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"description": "This whole thing is a theatrical play that you're performing for an audience, and you're building up to a choice bit of dialog between the two of you. Go slowly and carefully, and be sure to really milk this part of the scene, so that it has a big impact on the audience."
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"description": "Whatever's happened up to this point, this is the moment when you're totally allowed to do or say something that's divergent and _fictionally_ unsettling. This should NOT be something that breaks the trust and safety of your fellow player, but instead something that is incongruent with the current fictional reality of play. What is happening right now, and how can you violate that fictional reality in a way that will surprise but not endanger your fellow players? When in doubt, move to a place where you can look them in the eyes or, alternately, openly admit that this is all part of a game."
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"description": "Stop playing for a second: dropping out of character, trying to recognize the current space and its material reality for what it is, trying (however briefly and incompletely) to distance yourself from the thoughts and emotions and fiction that are associated with the game. Take a few deep breaths. Whatever the other player is saying to you right now, pay attention to it, but let it's impact bounce off you rather than dealing with it deeply. Then, after a few moments, draw another card and drop back into the fiction."
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"description": "Go find another boat with another pair of players, calling out to them and having whatever interaction they are willing to have. If you are playing as a solo pair, imagine that you see another boat and are have a brief, one-sided interaction with them (“How are the fish biting for you?” etc.)."
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"description": "Whether you really need to or not, tell your fishing companion (either in-character or out-of-character) that you need to take a bathroom break. Follow through on whatever comes of that."
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"description": "Without any explanation to your fishing companion, take the remaining unused Tacklebox Cards and shuffle them. Afterwards, draw a new card for yourself."
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"description": "You and your fishing companion will now switch characters, with you playing the character that they have been developing and they playing your character. This can be difficult and disconcerting, so be gentle and generous with each other. Allow them to intentionally or unintentionally make whatever changes to that character that they need to, in order to perform and embody it. After you have read this card, pass it to your fishing companion so that they can read it.",
|
| 62 |
+
"share_with_other_player": true
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"description": "You and your fishing companion are actually animatronic robots at an amusement park, performing the roles of a pair of fishing companions on a loop, whether there are park visitors watching your or not. You are not in the best shape and occasionally garble your words or have other glitches."
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"description": "You and your fishing companion are being implanted with a pleasant or at least calming familiar experience, but that is just masking the fact that you are currently being experimented on in unnatural and horrific ways. Maybe aliens have abducted you or maybe you are imprisoned/homeless people who are the subject of medical experiments by unethical megacorps. You are about to get pulled back into the haze of the false experience, but you can squeeze out just 4 brief words to your companion, any words you like, before you forget what's really happening."
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"description": "You brought your fishing companion out here with the intent for them to disappear forever. Maybe you have a specific vendetta against them or maybe you've just become obsessed with killing them. You're not sure whether you'll be able to go through with it. Plus, you can't help but give away subtle hints of what you're really planning, as if you want them to stop you."
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"description": "You're in a rough place financially or are in trouble with the wrong people. You desperately need some serious help from your fishing companion, but you're not sure what they're willing to do for you. Maybe they'll just reject you and walk out of your life forever, once they know about the trouble you're in. Try to test the waters and see how much they might be willing to help you, without being specific about the trouble or what you need."
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"description": "To impress them, you told your fishing companion that you're pretty good at fishing when really you know next to nothing, aside from having done it once or twice, a long time ago. Luckily, they're not looking at what you're doing, so you can probably just fake it? Try not to let them know that you're bad at this."
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"description": "While staying in-character, say something really sincere and genuine to the other player, taking about them and not about their character. They likely will never know exactly how you meant it, even if they have some suspicions, but that's okay. You'll know."
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"description": "This fishing trip feels like just one branch of a fork in reality. Somewhere else you are having a very different conversation on this same boat. Speak and act as if you are trying to steer this particular branch toward the version of the day you most want to be true."
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"description": "You quietly decide that after today, things between you and your fishing companion will never be quite the same, whether in a good way or a bad way. Let everything you say from this point on feel a little like a goodbye, even if you never say the word."
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"description": "For a while, imagine your thoughts appearing as subtitles only you can see. Out loud, keep your sentences simple and short, but in your mind, let long, complicated commentary run underneath everything you say and hear."
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"description": "Flip the emotional temperature you have been playing with so far. If you have felt relaxed, let a small knot of dread quietly settle in your chest; if you have felt tense, let an unexpected calm wash over you. Do not name the feeling directly, but let it color your words and choices."
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"description": "You suddenly remember this exact trip from when you were much younger, or decide to believe you do. For the next few moments, treat your companion as if they are the earlier version of themselves from that memory, or as if you are your own younger self trapped in your current body."
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"description": "For the next few exchanges, you are secretly interviewing your fishing companion for a research project called 'How people change over time.' Ask questions that sound casual, but are really trying to measure who they have become."
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"description": "You become aware of a third presence between you on the boat: someone you cannot quite see, but whose reactions you can feel. Never acknowledge them directly, but let their imagined approval or disapproval subtly steer what you choose to say next."
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"description": "You decide that the boat is a kind of confessional, but you are the listener, not the one confessing. Gently try to nudge your companion into talking about something real, while revealing as little concrete information about yourself as you can."
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"description": "For a little while, imagine that you are leaving voice messages that your fishing companion will only hear years from now. They are physically here with you in the boat, but you are secretly speaking to their future self."
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"description": "After you have read this card, show it to your fishing companion. For the next several turns, both of you may only speak in questions. See how long you can sustain the conversation before someone accidentally makes a statement.",
|
| 111 |
+
"share_with_other_player": true
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"description": "You realize that every sentence you speak leaves a faint, permanent mark on the surface of the water around the boat. Choose your words as if they will literally reshape the lake in small, invisible ways."
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"description": "Imagine that after this trip, your fishing companion will have to make a single, life-changing decision. Without ever naming it directly, let everything you say from now on be a kind of advice or warning about that decision."
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"description": "At some point soon, break the flow of the conversation with a small, unexpected physical gesture: shifting your weight so the boat rocks, tapping the side, brushing your hand against something. Let that gesture feel more meaningful to you than any of the words around it."
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"description": "After reading this card, show it to your fishing companion. Together, treat the next few lines of dialogue as if you are rehearsing a scene for a play. Exaggerate pauses, choose your words carefully, and imagine an audience watching just out of sight.",
|
| 124 |
+
"share_with_other_player": true
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"description": "For the next five sentences that you speak, avoid using the word 'I' entirely. Talk around yourself through stories, metaphors, or descriptions of other people instead."
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"description": "Every time you look away from your fishing companion, imagine that they vanish completely and only reappear when you notice them again. Let that fragile feeling that they could be gone at any moment influence how you talk to them."
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"description": "Treat the weather and water as if they are actively responding to your emotions. When your feelings shift, briefly describe some tiny change in the environment, even if your companion never comments on it."
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"description": "Silently choose a single word that you do not want to hear from your fishing companion today, for any reason. Do whatever you can to steer the conversation away from that word without ever admitting what it is."
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"description": "You become aware that this entire conversation is being recorded and will someday be played back for strangers. Decide whether you are performing for that invisible audience or trying to talk through them to reach your companion, and let that choice shape your tone."
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"description": "After you have read this card, show it to your fishing companion. Step out of character together for a short check-in. Ask each other, as yourselves, how you are doing and whether anything in the game needs to change. When you both feel ready, agree on a small signal and use it to slip back into the fiction.",
|
| 143 |
+
"share_with_other_player": true
|
| 144 |
+
}
|
| 145 |
+
]
|
| 146 |
+
}
|