Spaces:
Paused
Paused
Commit ·
e05a544
1
Parent(s): 5154bf5
feat(child): it gropes toward a name as it recovers itself
Browse files- app.py +46 -13
- character.py +14 -1
- engine.py +5 -3
- memory.py +18 -0
- schemas.py +1 -0
- tests/test_app.py +43 -0
- tests/test_memory.py +49 -1
app.py
CHANGED
|
@@ -8,6 +8,7 @@ os.environ.setdefault("OPENBLAS_NUM_THREADS", "4")
|
|
| 8 |
os.environ.setdefault("MKL_NUM_THREADS", "4")
|
| 9 |
|
| 10 |
import base64
|
|
|
|
| 11 |
import io
|
| 12 |
import json
|
| 13 |
import re
|
|
@@ -48,6 +49,18 @@ def _render_bond(affinity: int, tier: str, label: str = "bond") -> str:
|
|
| 48 |
)
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def _pristine_state():
|
| 52 |
"""A brand-new game — the real starting point, independent of the dev
|
| 53 |
HOLLOW_FAST_FINALE seed (the restart button must always give a clean run)."""
|
|
@@ -69,13 +82,15 @@ def _pristine_state():
|
|
| 69 |
"idle_count": 0,
|
| 70 |
"greeted": False,
|
| 71 |
"mode": "tester",
|
|
|
|
|
|
|
| 72 |
}
|
| 73 |
|
| 74 |
|
| 75 |
def _reset():
|
| 76 |
"""Wipe the board for a fresh game without a page reload. Order matches
|
| 77 |
[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel,
|
| 78 |
-
entity_panel, voice_panel, recovered_panel]."""
|
| 79 |
fresh = _pristine_state()
|
| 80 |
return (
|
| 81 |
gr.update(value="", interactive=True,
|
|
@@ -88,6 +103,7 @@ def _reset():
|
|
| 88 |
render_entity(20),
|
| 89 |
_voice_html(None),
|
| 90 |
render_recovered(0),
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
|
|
@@ -118,6 +134,8 @@ def _init_state():
|
|
| 118 |
"idle_count": 0,
|
| 119 |
"greeted": False,
|
| 120 |
"mode": "tester",
|
|
|
|
|
|
|
| 121 |
}
|
| 122 |
if fast == "bad":
|
| 123 |
return {
|
|
@@ -140,6 +158,8 @@ def _init_state():
|
|
| 140 |
"idle_count": 0,
|
| 141 |
"greeted": False,
|
| 142 |
"mode": "tester",
|
|
|
|
|
|
|
| 143 |
}
|
| 144 |
return _pristine_state()
|
| 145 |
|
|
@@ -540,6 +560,7 @@ def _enter_game(mode: str, tone_seed: int = 0):
|
|
| 540 |
wounds=state.get("wounds", [])),
|
| 541 |
render_entity(state["affinity"], tone=state["tone"]),
|
| 542 |
render_recovered(state.get("fragments_told", 0)),
|
|
|
|
| 543 |
)
|
| 544 |
|
| 545 |
|
|
@@ -683,14 +704,14 @@ def _play_finale(state: dict, history: list):
|
|
| 683 |
speak_it = step["speaker"] == "hollow"
|
| 684 |
voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
|
| 685 |
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
|
| 686 |
-
entity, voice, recovered)
|
| 687 |
if pause > 0:
|
| 688 |
time.sleep(pause)
|
| 689 |
|
| 690 |
yield (
|
| 691 |
gr.update(interactive=False, placeholder=_DEAD_PLACEHOLDER),
|
| 692 |
locked, chat_hist, state, bond, treasure_html, entity,
|
| 693 |
-
_voice_html(None), recovered,
|
| 694 |
)
|
| 695 |
|
| 696 |
|
|
@@ -744,14 +765,14 @@ def _play_finale_bad(state: dict, history: list):
|
|
| 744 |
speak_it = step["speaker"] == "hollow"
|
| 745 |
voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
|
| 746 |
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
|
| 747 |
-
entity, voice, recovered)
|
| 748 |
if pause > 0:
|
| 749 |
time.sleep(pause)
|
| 750 |
|
| 751 |
yield (
|
| 752 |
gr.update(interactive=False, placeholder="see you soon."),
|
| 753 |
locked, chat_hist, state, bond, treasure_html, entity,
|
| 754 |
-
_voice_html(None), recovered,
|
| 755 |
)
|
| 756 |
|
| 757 |
|
|
@@ -805,14 +826,14 @@ def _play_finale_good(state: dict, history: list):
|
|
| 805 |
|
| 806 |
voice, pause = _voice_and_pause(step["text"], True, step["pause"])
|
| 807 |
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
|
| 808 |
-
entity, voice, recovered)
|
| 809 |
if pause > 0:
|
| 810 |
time.sleep(pause)
|
| 811 |
|
| 812 |
yield (
|
| 813 |
gr.update(interactive=False, placeholder="it's quiet now."),
|
| 814 |
locked, chat_hist, state, bond, treasure_html, entity,
|
| 815 |
-
_voice_html(None), recovered,
|
| 816 |
)
|
| 817 |
|
| 818 |
|
|
@@ -844,6 +865,7 @@ def chat(user_msg: str, state: dict, history: list):
|
|
| 844 |
tone=state.get("tone", 0)),
|
| 845 |
_voice_html(None),
|
| 846 |
render_recovered(state.get("fragments_told", 0)),
|
|
|
|
| 847 |
)
|
| 848 |
return
|
| 849 |
|
|
@@ -862,6 +884,7 @@ def chat(user_msg: str, state: dict, history: list):
|
|
| 862 |
tone=state.get("tone", 0)),
|
| 863 |
_voice_html(None),
|
| 864 |
render_recovered(state.get("fragments_told", 0)),
|
|
|
|
| 865 |
)
|
| 866 |
return
|
| 867 |
|
|
@@ -896,6 +919,10 @@ def chat(user_msg: str, state: dict, history: list):
|
|
| 896 |
lengths = state.get("msg_lengths", []) + [len(user_msg.split())]
|
| 897 |
state["msg_lengths"] = lengths[-5:] # keep the last 5
|
| 898 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
system = build_system_prompt(state["affinity"], state["treasure"], recall_memory,
|
| 900 |
tone=state.get("tone", 0),
|
| 901 |
wounds=state.get("wounds", []),
|
|
@@ -903,7 +930,8 @@ def chat(user_msg: str, state: dict, history: list):
|
|
| 903 |
state, bool(do_recall and recall_memory)),
|
| 904 |
own_fragment=own_fragment,
|
| 905 |
style=style_signal(state["msg_lengths"]),
|
| 906 |
-
aware_memory=aware_memory
|
|
|
|
| 907 |
chat_messages = [{"role": "system", "content": system}] + state["history"] + [
|
| 908 |
{"role": "user", "content": user_msg}
|
| 909 |
]
|
|
@@ -931,6 +959,7 @@ def chat(user_msg: str, state: dict, history: list):
|
|
| 931 |
tone=state.get("tone", 0)),
|
| 932 |
voice,
|
| 933 |
render_recovered(state.get("fragments_told", 0)),
|
|
|
|
| 934 |
)
|
| 935 |
except Exception as e:
|
| 936 |
print(f"[chat] run_turn_stream failed: {e!r}")
|
|
@@ -983,6 +1012,7 @@ def chat(user_msg: str, state: dict, history: list):
|
|
| 983 |
tone=state.get("tone", 0)),
|
| 984 |
voice,
|
| 985 |
render_recovered(state.get("fragments_told", 0)),
|
|
|
|
| 986 |
)
|
| 987 |
|
| 988 |
|
|
@@ -1033,7 +1063,7 @@ with gr.Blocks(title="Hollow") as demo:
|
|
| 1033 |
gr.HTML('<div id="game-bg"></div><div id="game-vig"></div>'
|
| 1034 |
'<div id="game-frame-edge"></div>')
|
| 1035 |
with gr.Row(elem_id="game-topbar"):
|
| 1036 |
-
gr.HTML(
|
| 1037 |
restart_btn = gr.Button("↺ begin again", elem_classes="restart-btn",
|
| 1038 |
scale=0)
|
| 1039 |
voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0)
|
|
@@ -1074,7 +1104,8 @@ with gr.Blocks(title="Hollow") as demo:
|
|
| 1074 |
fn=chat,
|
| 1075 |
inputs=[msg_input, state, chatbot],
|
| 1076 |
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
|
| 1077 |
-
treasure_panel, entity_panel, voice_panel, recovered_panel
|
|
|
|
| 1078 |
show_progress="hidden",
|
| 1079 |
)
|
| 1080 |
|
|
@@ -1087,7 +1118,8 @@ with gr.Blocks(title="Hollow") as demo:
|
|
| 1087 |
fn=chat,
|
| 1088 |
inputs=[msg_input, state, chatbot],
|
| 1089 |
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
|
| 1090 |
-
treasure_panel, entity_panel, voice_panel, recovered_panel
|
|
|
|
| 1091 |
show_progress="hidden",
|
| 1092 |
)
|
| 1093 |
|
|
@@ -1095,7 +1127,8 @@ with gr.Blocks(title="Hollow") as demo:
|
|
| 1095 |
fn=_reset,
|
| 1096 |
inputs=None,
|
| 1097 |
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
|
| 1098 |
-
treasure_panel, entity_panel, voice_panel, recovered_panel
|
|
|
|
| 1099 |
show_progress="hidden",
|
| 1100 |
)
|
| 1101 |
|
|
@@ -1109,7 +1142,7 @@ with gr.Blocks(title="Hollow") as demo:
|
|
| 1109 |
show_progress="hidden")
|
| 1110 |
|
| 1111 |
_enter_outputs = [intro_view, game_view, state, chatbot, bond_panel,
|
| 1112 |
-
treasure_panel, entity_panel, recovered_panel]
|
| 1113 |
_intro_outputs = [menu_view, intro_view, pending_mode]
|
| 1114 |
tester_btn.click(lambda: _show_intro("tester"), None, _intro_outputs,
|
| 1115 |
show_progress="hidden")
|
|
|
|
| 8 |
os.environ.setdefault("MKL_NUM_THREADS", "4")
|
| 9 |
|
| 10 |
import base64
|
| 11 |
+
import html
|
| 12 |
import io
|
| 13 |
import json
|
| 14 |
import re
|
|
|
|
| 49 |
)
|
| 50 |
|
| 51 |
|
| 52 |
+
def _render_title(state: dict) -> str:
|
| 53 |
+
"""The top-left title: 'Hollow' until the child names itself."""
|
| 54 |
+
name = state.get("chosen_name")
|
| 55 |
+
if name:
|
| 56 |
+
safe = html.escape(name, quote=True)
|
| 57 |
+
return (
|
| 58 |
+
f'<div id="game-title">{safe}'
|
| 59 |
+
f'<span class="named"> — it calls itself <b>{safe}</b></span></div>'
|
| 60 |
+
)
|
| 61 |
+
return '<div id="game-title">Hollow</div>'
|
| 62 |
+
|
| 63 |
+
|
| 64 |
def _pristine_state():
|
| 65 |
"""A brand-new game — the real starting point, independent of the dev
|
| 66 |
HOLLOW_FAST_FINALE seed (the restart button must always give a clean run)."""
|
|
|
|
| 82 |
"idle_count": 0,
|
| 83 |
"greeted": False,
|
| 84 |
"mode": "tester",
|
| 85 |
+
"chosen_name": None,
|
| 86 |
+
"named": False,
|
| 87 |
}
|
| 88 |
|
| 89 |
|
| 90 |
def _reset():
|
| 91 |
"""Wipe the board for a fresh game without a page reload. Order matches
|
| 92 |
[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel,
|
| 93 |
+
entity_panel, voice_panel, recovered_panel, title_panel]."""
|
| 94 |
fresh = _pristine_state()
|
| 95 |
return (
|
| 96 |
gr.update(value="", interactive=True,
|
|
|
|
| 103 |
render_entity(20),
|
| 104 |
_voice_html(None),
|
| 105 |
render_recovered(0),
|
| 106 |
+
_render_title(fresh),
|
| 107 |
)
|
| 108 |
|
| 109 |
|
|
|
|
| 134 |
"idle_count": 0,
|
| 135 |
"greeted": False,
|
| 136 |
"mode": "tester",
|
| 137 |
+
"chosen_name": None,
|
| 138 |
+
"named": False,
|
| 139 |
}
|
| 140 |
if fast == "bad":
|
| 141 |
return {
|
|
|
|
| 158 |
"idle_count": 0,
|
| 159 |
"greeted": False,
|
| 160 |
"mode": "tester",
|
| 161 |
+
"chosen_name": None,
|
| 162 |
+
"named": False,
|
| 163 |
}
|
| 164 |
return _pristine_state()
|
| 165 |
|
|
|
|
| 560 |
wounds=state.get("wounds", [])),
|
| 561 |
render_entity(state["affinity"], tone=state["tone"]),
|
| 562 |
render_recovered(state.get("fragments_told", 0)),
|
| 563 |
+
_render_title(state),
|
| 564 |
)
|
| 565 |
|
| 566 |
|
|
|
|
| 704 |
speak_it = step["speaker"] == "hollow"
|
| 705 |
voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
|
| 706 |
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
|
| 707 |
+
entity, voice, recovered, _render_title(state))
|
| 708 |
if pause > 0:
|
| 709 |
time.sleep(pause)
|
| 710 |
|
| 711 |
yield (
|
| 712 |
gr.update(interactive=False, placeholder=_DEAD_PLACEHOLDER),
|
| 713 |
locked, chat_hist, state, bond, treasure_html, entity,
|
| 714 |
+
_voice_html(None), recovered, _render_title(state),
|
| 715 |
)
|
| 716 |
|
| 717 |
|
|
|
|
| 765 |
speak_it = step["speaker"] == "hollow"
|
| 766 |
voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
|
| 767 |
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
|
| 768 |
+
entity, voice, recovered, _render_title(state))
|
| 769 |
if pause > 0:
|
| 770 |
time.sleep(pause)
|
| 771 |
|
| 772 |
yield (
|
| 773 |
gr.update(interactive=False, placeholder="see you soon."),
|
| 774 |
locked, chat_hist, state, bond, treasure_html, entity,
|
| 775 |
+
_voice_html(None), recovered, _render_title(state),
|
| 776 |
)
|
| 777 |
|
| 778 |
|
|
|
|
| 826 |
|
| 827 |
voice, pause = _voice_and_pause(step["text"], True, step["pause"])
|
| 828 |
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
|
| 829 |
+
entity, voice, recovered, _render_title(state))
|
| 830 |
if pause > 0:
|
| 831 |
time.sleep(pause)
|
| 832 |
|
| 833 |
yield (
|
| 834 |
gr.update(interactive=False, placeholder="it's quiet now."),
|
| 835 |
locked, chat_hist, state, bond, treasure_html, entity,
|
| 836 |
+
_voice_html(None), recovered, _render_title(state),
|
| 837 |
)
|
| 838 |
|
| 839 |
|
|
|
|
| 865 |
tone=state.get("tone", 0)),
|
| 866 |
_voice_html(None),
|
| 867 |
render_recovered(state.get("fragments_told", 0)),
|
| 868 |
+
_render_title(state),
|
| 869 |
)
|
| 870 |
return
|
| 871 |
|
|
|
|
| 884 |
tone=state.get("tone", 0)),
|
| 885 |
_voice_html(None),
|
| 886 |
render_recovered(state.get("fragments_told", 0)),
|
| 887 |
+
_render_title(state),
|
| 888 |
)
|
| 889 |
return
|
| 890 |
|
|
|
|
| 919 |
lengths = state.get("msg_lengths", []) + [len(user_msg.split())]
|
| 920 |
state["msg_lengths"] = lengths[-5:] # keep the last 5
|
| 921 |
|
| 922 |
+
name_ready = (
|
| 923 |
+
not state.get("named") and state.get("fragments_told", 0) >= 2
|
| 924 |
+
)
|
| 925 |
+
|
| 926 |
system = build_system_prompt(state["affinity"], state["treasure"], recall_memory,
|
| 927 |
tone=state.get("tone", 0),
|
| 928 |
wounds=state.get("wounds", []),
|
|
|
|
| 930 |
state, bool(do_recall and recall_memory)),
|
| 931 |
own_fragment=own_fragment,
|
| 932 |
style=style_signal(state["msg_lengths"]),
|
| 933 |
+
aware_memory=aware_memory,
|
| 934 |
+
name_ready=name_ready)
|
| 935 |
chat_messages = [{"role": "system", "content": system}] + state["history"] + [
|
| 936 |
{"role": "user", "content": user_msg}
|
| 937 |
]
|
|
|
|
| 959 |
tone=state.get("tone", 0)),
|
| 960 |
voice,
|
| 961 |
render_recovered(state.get("fragments_told", 0)),
|
| 962 |
+
_render_title(state),
|
| 963 |
)
|
| 964 |
except Exception as e:
|
| 965 |
print(f"[chat] run_turn_stream failed: {e!r}")
|
|
|
|
| 1012 |
tone=state.get("tone", 0)),
|
| 1013 |
voice,
|
| 1014 |
render_recovered(state.get("fragments_told", 0)),
|
| 1015 |
+
_render_title(state),
|
| 1016 |
)
|
| 1017 |
|
| 1018 |
|
|
|
|
| 1063 |
gr.HTML('<div id="game-bg"></div><div id="game-vig"></div>'
|
| 1064 |
'<div id="game-frame-edge"></div>')
|
| 1065 |
with gr.Row(elem_id="game-topbar"):
|
| 1066 |
+
title_panel = gr.HTML(value=_render_title(_init_state()))
|
| 1067 |
restart_btn = gr.Button("↺ begin again", elem_classes="restart-btn",
|
| 1068 |
scale=0)
|
| 1069 |
voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0)
|
|
|
|
| 1104 |
fn=chat,
|
| 1105 |
inputs=[msg_input, state, chatbot],
|
| 1106 |
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
|
| 1107 |
+
treasure_panel, entity_panel, voice_panel, recovered_panel,
|
| 1108 |
+
title_panel],
|
| 1109 |
show_progress="hidden",
|
| 1110 |
)
|
| 1111 |
|
|
|
|
| 1118 |
fn=chat,
|
| 1119 |
inputs=[msg_input, state, chatbot],
|
| 1120 |
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
|
| 1121 |
+
treasure_panel, entity_panel, voice_panel, recovered_panel,
|
| 1122 |
+
title_panel],
|
| 1123 |
show_progress="hidden",
|
| 1124 |
)
|
| 1125 |
|
|
|
|
| 1127 |
fn=_reset,
|
| 1128 |
inputs=None,
|
| 1129 |
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
|
| 1130 |
+
treasure_panel, entity_panel, voice_panel, recovered_panel,
|
| 1131 |
+
title_panel],
|
| 1132 |
show_progress="hidden",
|
| 1133 |
)
|
| 1134 |
|
|
|
|
| 1142 |
show_progress="hidden")
|
| 1143 |
|
| 1144 |
_enter_outputs = [intro_view, game_view, state, chatbot, bond_panel,
|
| 1145 |
+
treasure_panel, entity_panel, recovered_panel, title_panel]
|
| 1146 |
_intro_outputs = [menu_view, intro_view, pending_mode]
|
| 1147 |
tester_btn.click(lambda: _show_intro("tester"), None, _intro_outputs,
|
| 1148 |
show_progress="hidden")
|
character.py
CHANGED
|
@@ -145,13 +145,23 @@ INTRO_SEQUENCES = {
|
|
| 145 |
"full": [0, 1, 2, 3],
|
| 146 |
}
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
def build_system_prompt(affinity: int, treasure: list[str],
|
| 149 |
recall_memory: str | None = None,
|
| 150 |
tone: int = 0, wounds: list[str] | None = None,
|
| 151 |
memory_plea: int = 0,
|
| 152 |
own_fragment: str | None = None,
|
| 153 |
style: str | None = None,
|
| 154 |
-
aware_memory: str | None = None
|
|
|
|
| 155 |
tier = get_tier(affinity)
|
| 156 |
persona = _PERSONA[tier]
|
| 157 |
|
|
@@ -172,6 +182,9 @@ def build_system_prompt(affinity: int, treasure: list[str],
|
|
| 172 |
"Let it frighten you a little. The visitor's warmth pulled it loose."
|
| 173 |
)
|
| 174 |
|
|
|
|
|
|
|
|
|
|
| 175 |
if style == "short":
|
| 176 |
parts.append("The visitor's messages have been short and guarded. You may quietly notice this — name it once, softly, then let it go.")
|
| 177 |
elif style == "long":
|
|
|
|
| 145 |
"full": [0, 1, 2, 3],
|
| 146 |
}
|
| 147 |
|
| 148 |
+
_NAME_SURFACES = (
|
| 149 |
+
"A name is trying to surface in you — something you almost remember "
|
| 150 |
+
"being called before the wood took it. In the middle of your reply, "
|
| 151 |
+
"let one short name slip out, haltingly, as if you're tasting it. "
|
| 152 |
+
"Do NOT explain it. Do NOT ask the visitor to use it. Just say it once, "
|
| 153 |
+
"uncertain, then keep talking."
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
def build_system_prompt(affinity: int, treasure: list[str],
|
| 158 |
recall_memory: str | None = None,
|
| 159 |
tone: int = 0, wounds: list[str] | None = None,
|
| 160 |
memory_plea: int = 0,
|
| 161 |
own_fragment: str | None = None,
|
| 162 |
style: str | None = None,
|
| 163 |
+
aware_memory: str | None = None,
|
| 164 |
+
name_ready: bool = False) -> str:
|
| 165 |
tier = get_tier(affinity)
|
| 166 |
persona = _PERSONA[tier]
|
| 167 |
|
|
|
|
| 182 |
"Let it frighten you a little. The visitor's warmth pulled it loose."
|
| 183 |
)
|
| 184 |
|
| 185 |
+
if name_ready:
|
| 186 |
+
parts.append(_NAME_SURFACES)
|
| 187 |
+
|
| 188 |
if style == "short":
|
| 189 |
parts.append("The visitor's messages have been short and guarded. You may quietly notice this — name it once, softly, then let it go.")
|
| 190 |
elif style == "long":
|
engine.py
CHANGED
|
@@ -4,7 +4,7 @@ import os
|
|
| 4 |
IS_SPACE = bool(os.environ.get("SPACE_ID"))
|
| 5 |
|
| 6 |
_EXTRACTION_SYSTEM = (
|
| 7 |
-
"You are a JSON extraction assistant. Given a conversation exchange, extract
|
| 8 |
"1. affinity_delta: integer from -8 to +8 — how much the visitor deepened the bond this turn. "
|
| 9 |
"Use this scale and be GENEROUS — grief, loss, fear, and tender memories are the heart of this game and "
|
| 10 |
"should score high (+6 to +8):\n"
|
|
@@ -28,10 +28,12 @@ _EXTRACTION_SYSTEM = (
|
|
| 28 |
" -6 to -10: mocking, insulting, cruel, or threatening Hollow.\n"
|
| 29 |
"4. cruel_quote: if the visitor mocked, insulted, or was cruel to Hollow this turn, the cruel phrase "
|
| 30 |
"EXACTLY as the visitor wrote it — the visitor's words, never Hollow's. null otherwise.\n"
|
|
|
|
|
|
|
| 31 |
"All integers must be plain JSON numbers — never write a leading + sign.\n"
|
| 32 |
"Respond ONLY with valid JSON. No markdown. Examples:\n"
|
| 33 |
-
'{"affinity_delta": 6, "new_memories": ["a grandmother named Lili who died of cancer"], "tone_delta": 2, "cruel_quote": null}\n'
|
| 34 |
-
'{"affinity_delta": -4, "new_memories": [], "tone_delta": -8, "cruel_quote": "you are a creepy little freak"}'
|
| 35 |
)
|
| 36 |
|
| 37 |
|
|
|
|
| 4 |
IS_SPACE = bool(os.environ.get("SPACE_ID"))
|
| 5 |
|
| 6 |
_EXTRACTION_SYSTEM = (
|
| 7 |
+
"You are a JSON extraction assistant. Given a conversation exchange, extract five things:\n"
|
| 8 |
"1. affinity_delta: integer from -8 to +8 — how much the visitor deepened the bond this turn. "
|
| 9 |
"Use this scale and be GENEROUS — grief, loss, fear, and tender memories are the heart of this game and "
|
| 10 |
"should score high (+6 to +8):\n"
|
|
|
|
| 28 |
" -6 to -10: mocking, insulting, cruel, or threatening Hollow.\n"
|
| 29 |
"4. cruel_quote: if the visitor mocked, insulted, or was cruel to Hollow this turn, the cruel phrase "
|
| 30 |
"EXACTLY as the visitor wrote it — the visitor's words, never Hollow's. null otherwise.\n"
|
| 31 |
+
"5. chosen_name: if Hollow offered a name for itself this turn, the single short name (one word, "
|
| 32 |
+
"letters only, 2-12 chars) EXACTLY as Hollow wrote it. null otherwise.\n"
|
| 33 |
"All integers must be plain JSON numbers — never write a leading + sign.\n"
|
| 34 |
"Respond ONLY with valid JSON. No markdown. Examples:\n"
|
| 35 |
+
'{"affinity_delta": 6, "new_memories": ["a grandmother named Lili who died of cancer"], "tone_delta": 2, "cruel_quote": null, "chosen_name": null}\n'
|
| 36 |
+
'{"affinity_delta": -4, "new_memories": [], "tone_delta": -8, "cruel_quote": "you are a creepy little freak", "chosen_name": null}'
|
| 37 |
)
|
| 38 |
|
| 39 |
|
memory.py
CHANGED
|
@@ -42,6 +42,19 @@ def style_signal(msg_lengths: list[int]) -> str | None:
|
|
| 42 |
return None
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def _is_hollows_words(memory: str, reply: str) -> bool:
|
| 46 |
"""On recall turns Hollow retells a memory in first person; extraction
|
| 47 |
sometimes captures THAT poetic line as a new memory. Drop any candidate
|
|
@@ -78,6 +91,11 @@ def apply_update(state: dict, raw_json: str, reply: str = "") -> dict:
|
|
| 78 |
if update.cruel_quote not in wounds:
|
| 79 |
wounds.append(update.cruel_quote)
|
| 80 |
del wounds[:-10] # cap at 10, keep newest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
return state
|
| 82 |
|
| 83 |
|
|
|
|
| 42 |
return None
|
| 43 |
|
| 44 |
|
| 45 |
+
def sanitize_name(raw: str | None) -> str | None:
|
| 46 |
+
"""A single short word → capitalized; anything else is rejected."""
|
| 47 |
+
if not raw:
|
| 48 |
+
return None
|
| 49 |
+
parts = raw.strip().split()
|
| 50 |
+
if not parts:
|
| 51 |
+
return None
|
| 52 |
+
w = parts[0].strip(".,;:'\"!?—-").strip()
|
| 53 |
+
if w.isalpha() and 2 <= len(w) <= 12:
|
| 54 |
+
return w.capitalize()
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
def _is_hollows_words(memory: str, reply: str) -> bool:
|
| 59 |
"""On recall turns Hollow retells a memory in first person; extraction
|
| 60 |
sometimes captures THAT poetic line as a new memory. Drop any candidate
|
|
|
|
| 91 |
if update.cruel_quote not in wounds:
|
| 92 |
wounds.append(update.cruel_quote)
|
| 93 |
del wounds[:-10] # cap at 10, keep newest
|
| 94 |
+
if update.chosen_name and not state.get("named"):
|
| 95 |
+
name = sanitize_name(update.chosen_name)
|
| 96 |
+
if name:
|
| 97 |
+
state["chosen_name"] = name
|
| 98 |
+
state["named"] = True
|
| 99 |
return state
|
| 100 |
|
| 101 |
|
schemas.py
CHANGED
|
@@ -7,6 +7,7 @@ class TurnUpdate(BaseModel):
|
|
| 7 |
new_memories: List[str] = []
|
| 8 |
tone_delta: int = 0
|
| 9 |
cruel_quote: Optional[str] = None
|
|
|
|
| 10 |
|
| 11 |
@field_validator("affinity_delta")
|
| 12 |
@classmethod
|
|
|
|
| 7 |
new_memories: List[str] = []
|
| 8 |
tone_delta: int = 0
|
| 9 |
cruel_quote: Optional[str] = None
|
| 10 |
+
chosen_name: Optional[str] = None
|
| 11 |
|
| 12 |
@field_validator("affinity_delta")
|
| 13 |
@classmethod
|
tests/test_app.py
CHANGED
|
@@ -192,6 +192,49 @@ class TestFinaleBuildAndConvulse:
|
|
| 192 |
assert any("entity-frenzy-wrap" in e for e in entities) # frenzy (sting)
|
| 193 |
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
class TestPendingBubble:
|
| 196 |
def test_start_turn_adds_user_and_pending(self):
|
| 197 |
out_input, out_btn, hist, out_state = app._start_turn("hello", _state(affinity=20, treasure=[], claimed=[], turn=0), [])
|
|
|
|
| 192 |
assert any("entity-frenzy-wrap" in e for e in entities) # frenzy (sting)
|
| 193 |
|
| 194 |
|
| 195 |
+
class TestToneClassMapping:
|
| 196 |
+
def test_warm_at_15(self):
|
| 197 |
+
assert app._tone_class(15) == "tone-warm"
|
| 198 |
+
assert app._tone_class(40) == "tone-warm"
|
| 199 |
+
|
| 200 |
+
def test_hostile_at_minus_22(self):
|
| 201 |
+
assert app._tone_class(-22) == "tone-hostile"
|
| 202 |
+
assert app._tone_class(-50) == "tone-hostile"
|
| 203 |
+
|
| 204 |
+
def test_wounded_between_hostile_and_neutral(self):
|
| 205 |
+
assert app._tone_class(-15) == "tone-wounded"
|
| 206 |
+
assert app._tone_class(-20) == "tone-wounded"
|
| 207 |
+
|
| 208 |
+
def test_neutral_elsewhere(self):
|
| 209 |
+
assert app._tone_class(0) == "tone-neutral"
|
| 210 |
+
assert app._tone_class(14) == "tone-neutral"
|
| 211 |
+
assert app._tone_class(-14) == "tone-neutral"
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
class TestRenderTitle:
|
| 215 |
+
def test_default_title_is_hollow(self):
|
| 216 |
+
assert "Hollow" in app._render_title({"chosen_name": None})
|
| 217 |
+
|
| 218 |
+
def test_named_title_shows_chosen_name(self):
|
| 219 |
+
out = app._render_title({"chosen_name": "Mara"})
|
| 220 |
+
assert "Mara" in out
|
| 221 |
+
assert "it calls itself <b>Mara</b>" in out
|
| 222 |
+
|
| 223 |
+
def test_name_is_escaped(self):
|
| 224 |
+
out = app._render_title({"chosen_name": "<script>"})
|
| 225 |
+
assert "<script>" not in out
|
| 226 |
+
assert "<script>" in out
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class TestPristineState:
|
| 230 |
+
def test_has_naming_fields(self):
|
| 231 |
+
fresh = app._pristine_state()
|
| 232 |
+
assert "chosen_name" in fresh
|
| 233 |
+
assert "named" in fresh
|
| 234 |
+
assert fresh["chosen_name"] is None
|
| 235 |
+
assert fresh["named"] is False
|
| 236 |
+
|
| 237 |
+
|
| 238 |
class TestPendingBubble:
|
| 239 |
def test_start_turn_adds_user_and_pending(self):
|
| 240 |
out_input, out_btn, hist, out_state = app._start_turn("hello", _state(affinity=20, treasure=[], claimed=[], turn=0), [])
|
tests/test_memory.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import pytest
|
| 2 |
-
from memory import get_tier, apply_update, should_recall, decide_ending
|
| 3 |
|
| 4 |
|
| 5 |
class TestGetTier:
|
|
@@ -76,6 +76,54 @@ class TestApplyUpdate:
|
|
| 76 |
result = apply_update(state, '{"affinity_delta": -3, "new_memories": []}')
|
| 77 |
assert result["affinity"] == 47
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
class TestShouldRecall:
|
| 81 |
def _state(self, affinity=60, treasure=None, claimed=None, turn=0, last_recall_turn=None):
|
|
|
|
| 1 |
import pytest
|
| 2 |
+
from memory import get_tier, apply_update, should_recall, decide_ending, sanitize_name
|
| 3 |
|
| 4 |
|
| 5 |
class TestGetTier:
|
|
|
|
| 76 |
result = apply_update(state, '{"affinity_delta": -3, "new_memories": []}')
|
| 77 |
assert result["affinity"] == 47
|
| 78 |
|
| 79 |
+
def test_chosen_name_is_sanitized_and_stored(self):
|
| 80 |
+
state = self._base_state()
|
| 81 |
+
result = apply_update(
|
| 82 |
+
state,
|
| 83 |
+
'{"affinity_delta": 0, "new_memories": [], "chosen_name": "Mara"}',
|
| 84 |
+
)
|
| 85 |
+
assert result["chosen_name"] == "Mara"
|
| 86 |
+
assert result.get("named") is True
|
| 87 |
+
|
| 88 |
+
def test_chosen_name_rejected_if_already_named(self):
|
| 89 |
+
state = self._base_state()
|
| 90 |
+
state["chosen_name"] = "Lila"
|
| 91 |
+
state["named"] = True
|
| 92 |
+
result = apply_update(
|
| 93 |
+
state,
|
| 94 |
+
'{"affinity_delta": 0, "new_memories": [], "chosen_name": "Nova"}',
|
| 95 |
+
)
|
| 96 |
+
assert result["chosen_name"] == "Lila"
|
| 97 |
+
|
| 98 |
+
def test_invalid_name_falls_back(self):
|
| 99 |
+
state = self._base_state()
|
| 100 |
+
result = apply_update(
|
| 101 |
+
state,
|
| 102 |
+
'{"affinity_delta": 0, "new_memories": [], "chosen_name": "123bad"}',
|
| 103 |
+
)
|
| 104 |
+
assert result.get("chosen_name") is None
|
| 105 |
+
assert result.get("named") is not True
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class TestSanitizeName:
|
| 109 |
+
def test_clean_single_word(self):
|
| 110 |
+
assert sanitize_name("Mara") == "Mara"
|
| 111 |
+
|
| 112 |
+
def test_strips_punctuation(self):
|
| 113 |
+
assert sanitize_name('"Lila."') == "Lila"
|
| 114 |
+
|
| 115 |
+
def test_uses_first_word_of_multiple(self):
|
| 116 |
+
assert sanitize_name("Little Ghost") == "Little"
|
| 117 |
+
|
| 118 |
+
def test_rejects_empty(self):
|
| 119 |
+
assert sanitize_name(" ") is None
|
| 120 |
+
|
| 121 |
+
def test_rejects_digits(self):
|
| 122 |
+
assert sanitize_name("X7") is None
|
| 123 |
+
|
| 124 |
+
def test_rejects_too_long(self):
|
| 125 |
+
assert sanitize_name("A" * 25) is None
|
| 126 |
+
|
| 127 |
|
| 128 |
class TestShouldRecall:
|
| 129 |
def _state(self, affinity=60, treasure=None, claimed=None, turn=0, last_recall_turn=None):
|