From 1775c015bff5cb6e86ffa50560ed94df96a35431 Mon Sep 17 00:00:00 2001 From: yuzushi-dev Date: Wed, 10 Jun 2026 00:04:52 +0200 Subject: [PATCH 1/3] feat(interaction): humanize checkin questions, diegetic/proactive interactivity, natural cadence Checkin lane: - follow-up-first question selection: a recently answered exchange is followed up (same facet_id, attribution-safe) before a new TGS topic is opened (Huang et al. 2017: follow-up questions drive liking) - prompt contract: episodic framing (concrete recent episodes, never trait/spectrum wording), share-then-ask reciprocity (Moon 2000), follow-up mode, variable question placement - ASK posture cap 2 -> 3 sentences to fit the share-then-ask shape Diegetic lane: - deterministic per-day hook flag in the gate (~35%): fragment may close with one light invitation to the subject (Liu et al. CHI 2025); base contract (no questions, not subject-addressed) unchanged otherwise - continuity rule: fragments may continue a previous open thread Proactive lane: - re-engagement must anchor to one specific dropped thread and close with a small question on it; no anchor -> [SILENT] (Skjuve et al. 2022: grounded reference, not frequency, drives perceived listening) Cadence (flag-gated RELIC_NATURAL_CADENCE, default off): - seeded skip-days for diegetic (25%) and proactive (20%); checkins never skipped (measurement instrument). Bursty, not metronomic (Barabasi 2005) - triangular two-draw window jitter for day-to-day fire-time spread (Gnewuch et al. 2018). All rolls are sha256(subject|lane|date): deterministic and replayable, no RNG state Also: natural_cadence_skip decision reason, force-path hook parity for dry runs, sandboxed dry-run harness for subject daniele (no dispatch), accent fixes in prompt strings. Co-Authored-By: Claude Fable 5 --- relic/checkin/context_builder.py | 70 +++++--- relic/checkin/policy.py | 5 +- relic/checkin/question_engine.py | 75 +++++++++ relic/gumi_plugin/cron_wiring.py | 114 +++++++++++-- relic/hermes_runtime.py | 1 + relic/profile/registry.py | 16 +- scripts/dryrun_humanize_daniele.py | 119 +++++++++++++ tests/checkin/test_select_followup.py | 134 +++++++++++++++ tests/gumi_plugin/test_natural_cadence.py | 157 ++++++++++++++++++ tests/profile/test_checkin_prompt_contract.py | 17 +- .../profile/test_diegetic_prompt_contract.py | 16 ++ .../profile/test_proactive_prompt_contract.py | 9 + 12 files changed, 692 insertions(+), 41 deletions(-) create mode 100644 scripts/dryrun_humanize_daniele.py create mode 100644 tests/checkin/test_select_followup.py create mode 100644 tests/gumi_plugin/test_natural_cadence.py diff --git a/relic/checkin/context_builder.py b/relic/checkin/context_builder.py index 8889647..26ec0ca 100644 --- a/relic/checkin/context_builder.py +++ b/relic/checkin/context_builder.py @@ -164,7 +164,7 @@ def build_topic_hint_section( import hashlib import sqlite3 from relic.checkin.anti_repeat import AntiRepeatGate - from relic.checkin.question_engine import select_facet + from relic.checkin.question_engine import select_facet, select_followup from relic.checkin.topic_hint import render_topic_hint day_seed = ( @@ -195,31 +195,51 @@ def build_topic_hint_section( conn_t, datetime.now(timezone.utc) ): return "" - sel = select_facet( - conn_t, - bl_path if bl_path.exists() else None, - seed=day_seed, - ) - if sel.get("status") == "ask_now": - ar = AntiRepeatGate(conn_t, jaccard_threshold=0.60).check( - sel["question_hint"] + # Follow-up-first: mirror _select_ask_decision so the gate's + # ask_topic and this rendered block agree on the same hint. + followup = select_followup(conn_t, datetime.now(timezone.utc)) + if followup is not None: + try: + recent_q = [ + r[0] + for r in conn_t.execute( + "SELECT question_text FROM checkin_exchanges " + "ORDER BY asked_at DESC LIMIT 10" + ).fetchall() + if r[0] + ] + except Exception: + recent_q = [] + topic_block = render_topic_hint(followup["hint"], recent_q) + if topic_block: + topic_facet_id = followup["facet_id"] + topic_hint_text = followup["hint"] + if not topic_block: + sel = select_facet( + conn_t, + bl_path if bl_path.exists() else None, + seed=day_seed, ) - if not ar["duplicate"]: - try: - recent_q = [ - r[0] - for r in conn_t.execute( - "SELECT question_text FROM checkin_exchanges " - "ORDER BY asked_at DESC LIMIT 10" - ).fetchall() - if r[0] - ] - except Exception: - recent_q = [] - topic_block = render_topic_hint(sel["question_hint"], recent_q) - if topic_block: - topic_facet_id = sel["selected_facet"] - topic_hint_text = sel["question_hint"] + if sel.get("status") == "ask_now": + ar = AntiRepeatGate(conn_t, jaccard_threshold=0.60).check( + sel["question_hint"] + ) + if not ar["duplicate"]: + try: + recent_q = [ + r[0] + for r in conn_t.execute( + "SELECT question_text FROM checkin_exchanges " + "ORDER BY asked_at DESC LIMIT 10" + ).fetchall() + if r[0] + ] + except Exception: + recent_q = [] + topic_block = render_topic_hint(sel["question_hint"], recent_q) + if topic_block: + topic_facet_id = sel["selected_facet"] + topic_hint_text = sel["question_hint"] finally: if conn_t is not None: conn_t.close() diff --git a/relic/checkin/policy.py b/relic/checkin/policy.py index 2863c43..1263c58 100644 --- a/relic/checkin/policy.py +++ b/relic/checkin/policy.py @@ -99,7 +99,10 @@ class Decision: Posture.QUIET: 0, Posture.OBSERVE: 1, Posture.BRIEF_SHARE: 2, - Posture.ASK: 2, + # 3, not 2: leaves room for the share-then-ask shape (one small + # self-disclosure before the question; reciprocity increases disclosure, + # Moon 2000) without forcing the question into a fixed second slot. + Posture.ASK: 3, Posture.FOLLOW_UP_WARM: 3, Posture.FOLLOW_UP_TERSE: 2, Posture.REFLECTIVE_MIRROR: 2, diff --git a/relic/checkin/question_engine.py b/relic/checkin/question_engine.py index 1f02257..7bf0e95 100644 --- a/relic/checkin/question_engine.py +++ b/relic/checkin/question_engine.py @@ -254,6 +254,81 @@ def build_question_hint(f: FacetState) -> str: return desc +# Follow-up window: a replied exchange is a follow-up candidate while the +# reply is at most this old. Beyond it the thread has gone cold and a new +# TGS-selected topic reads more natural than reviving stale context. +FOLLOWUP_WINDOW_HOURS = 72 + +# Cap on the reply excerpt quoted back into the follow-up hint. Keeps the +# rendered topic block inside the 200-char budget of render_topic_hint. +_FOLLOWUP_EXCERPT_CHARS = 90 + + +def select_followup( + conn: sqlite3.Connection, + now: datetime | None = None, + window_hours: int = FOLLOWUP_WINDOW_HOURS, +) -> dict[str, Any] | None: + """Return a follow-up candidate built from the latest answered exchange. + + Follow-up questions (not topic-switch questions) are the conversational + move that deepens rapport (Huang et al. 2017, JPSP). A replied exchange is + a candidate when: + * its reply was captured within ``window_hours``; + * no newer exchange was asked after the reply (i.e. the answer has not + been followed up yet). + + Returns ``{"facet_id", "question_text", "reply_excerpt", "hint"}`` or + ``None``. The hint is built mostly from the reply text so the Jaccard + anti-repeat gate does not collide with the original question. + """ + now = now or datetime.now(timezone.utc) + try: + row = conn.execute( + """SELECT facet_id, question_text, reply_text, reply_captured_at + FROM checkin_exchanges + WHERE reply_text IS NOT NULL AND facet_id IS NOT NULL + ORDER BY reply_captured_at DESC + LIMIT 1""" + ).fetchone() + except sqlite3.OperationalError: + return None + if not row: + return None + + facet_id, question_text, reply_text, reply_at_iso = row + reply_at = _parse_iso(reply_at_iso) + if reply_at is None: + return None + if reply_at.tzinfo is None: + reply_at = reply_at.replace(tzinfo=timezone.utc) + if (now - reply_at).total_seconds() > window_hours * 3600: + return None + + # Already followed up? Any exchange asked after the reply closes the thread. + try: + newer = conn.execute( + "SELECT 1 FROM checkin_exchanges WHERE asked_at > ? LIMIT 1", + (reply_at_iso,), + ).fetchone() + except sqlite3.OperationalError: + return None + if newer: + return None + + excerpt = " ".join((reply_text or "").split())[:_FOLLOWUP_EXCERPT_CHARS].strip() + if not excerpt: + return None + + hint = f"Approfondisci quello che ti ha risposto: «{excerpt}»." + return { + "facet_id": facet_id, + "question_text": question_text or "", + "reply_excerpt": excerpt, + "hint": hint, + } + + def select_facet( conn: sqlite3.Connection, subject_baseline_path: Path | None = None, diff --git a/relic/gumi_plugin/cron_wiring.py b/relic/gumi_plugin/cron_wiring.py index bc770f7..42a11b9 100644 --- a/relic/gumi_plugin/cron_wiring.py +++ b/relic/gumi_plugin/cron_wiring.py @@ -117,7 +117,19 @@ def _select_ask_decision( except Exception: pass - from relic.checkin.question_engine import select_facet + from relic.checkin.question_engine import select_facet, select_followup + + # Follow-up-first: a recent answered exchange that has not yet been + # followed up beats a new TGS topic. Follow-up questions, not + # topic-switch questions, are what deepen rapport (Huang et al. 2017). + conn_fu = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0) + try: + followup = select_followup(conn_fu, now.astimezone(timezone.utc)) + finally: + conn_fu.close() + if followup is not None: + return (True, followup["hint"]) + facet_seed = int( hashlib.sha256(f"{subject_id}|ask|{now.date()}".encode()).hexdigest(), 16, @@ -570,6 +582,34 @@ def _last_outbound_in_window( return sh * 60 + sm <= last_min < eh * 60 + em +# Natural-cadence skip probability per lane (percent of days the lane stays +# silent). Check-ins are exempt: they carry the facet-elicitation instrument. +_NATURAL_SKIP_PCT: dict[str, int] = {"diegetic": 25, "proactivity": 20} + +# Percent of diegetic deliveries that carry an interaction hook. +_DIEGETIC_HOOK_PCT = 35 + + +def _natural_cadence_enabled() -> bool: + return os.environ.get("RELIC_NATURAL_CADENCE", "").strip().lower() in ("1", "true", "yes") + + +def _seeded_roll(seed: str) -> int: + """Deterministic 0-99 roll from a string seed (replayable, no RNG state).""" + return int(hashlib.sha256(seed.encode()).hexdigest(), 16) % 100 + + +def _natural_cadence_skip_today(subject_id: str, decision_type: str, now: datetime) -> bool: + pct = _NATURAL_SKIP_PCT.get(decision_type, 0) + if pct <= 0: + return False + return _seeded_roll(f"{subject_id}|{decision_type}-skip|{now.date()}") < pct + + +def _diegetic_hook_today(subject_id: str, now: datetime) -> bool: + return _seeded_roll(f"{subject_id}|diegetic-hook|{now.date()}") < _DIEGETIC_HOOK_PCT + + _RESPONSE_TIMING_FACTOR: dict[str, float] = { "high": 0.25, # fires early in window (fast responder expectation) "medium": 0.5, # normal midpoint @@ -629,10 +669,22 @@ def _window_jitter_minute(subject_id: str, window: tuple[int, int, int, int], da h = int(hashlib.sha256(seed.encode()).hexdigest(), 16) base_offset = h % (available + 1) - # Shift offset toward timing preference (blended 50/50 with hash for stability) timing_factor = _response_timing_factor(subject_id) preferred_offset = int(available * timing_factor) - blended_offset = (base_offset + preferred_offset) // 2 + + if _natural_cadence_enabled(): + # Two independent hash draws averaged give a triangular spread across + # the whole window, day-to-day variability instead of clustering at + # the hash/preference midpoint (timing variability reads as human: + # Gnewuch et al. 2018; bursty inter-event gaps: Barabási 2005). The + # preference still pulls the centre, with a lighter 1/3 weight. + h2 = int(hashlib.sha256((seed + "|draw2").encode()).hexdigest(), 16) + second_offset = h2 % (available + 1) + triangular = (base_offset + second_offset) // 2 + blended_offset = (2 * triangular + preferred_offset) // 3 + else: + # Legacy: blend hash 50/50 with the preference midpoint. + blended_offset = (base_offset + preferred_offset) // 2 return start_min + min(blended_offset, available) @@ -734,6 +786,17 @@ def _evaluate_decision( if _is_late_night_blocked(subject_id): reasons.append(RuntimeDecisionReason.quiet_hours) return RuntimeDecision.BLOCKED, reasons, None + now_dt = _subject_now(subject_id) + # Natural cadence (flag-gated): a contact pattern that fires every + # single day is a machine tell; human-initiated contact is bursty with + # heavy-tailed gaps (Barabási 2005). Seeded by subject|lane|date so the + # decision is deterministic and replayable. Check-ins are never + # skipped: they are the measurement instrument. + if _natural_cadence_enabled() and _natural_cadence_skip_today( + subject_id, decision_type, now_dt + ): + reasons.append(RuntimeDecisionReason.natural_cadence_skip) + return RuntimeDecision.NO_REPLY, reasons, None reasons.append(RuntimeDecisionReason.no_due_work) # Build the same DELIVER/tipo/ora header the check-in path emits so the # diegetic/proactive composer is time-aware (the prompt contract expects @@ -741,10 +804,15 @@ def _evaluate_decision( # becomes a real initiative; here we only make the gate output complete. hermes_home_str = os.environ.get("HERMES_HOME", "") hermes_home = Path(hermes_home_str) if hermes_home_str else Path.home() / ".hermes" - now_dt = _subject_now(subject_id) now_str = now_dt.strftime("%H:%M %Z") media_type = _select_media_type(subject_id, hermes_home, now_dt) msg = f"DELIVER\ntipo: {media_type}\nora: {now_str}" + if decision_type == "diegetic": + # Interactive hook: ~35% of diegetic fragments end with a light + # invitation addressed to the subject (proactive contributions + # that carry an interaction affordance outperform broadcast + # pushes: Liu et al., CHI 2025). Seeded per subject+day. + msg += f"\nhook: {'true' if _diegetic_hook_today(subject_id, now_dt) else 'false'}" return RuntimeDecision.CANDIDATE, reasons, {"message": msg} # Check for due followups: used to determine CANDIDATE vs DELIVER vs NO_REPLY @@ -1015,6 +1083,10 @@ def make_decision( ignore_cooldown=True, ) msg = f"DELIVER\ntipo: {media_type}\nora: {now_str} [FORCE]" + if decision_type == "diegetic": + # Mirror the non-force gate: the diegetic composer needs the + # deterministic hook flag in dry-run/force tests too. + msg += f"\nhook: {'true' if _diegetic_hook_today(subject_id, now_dt) else 'false'}" if ask and ask_topic: msg += f"\nask: true\nask_topic: {ask_topic}" if decision_type == "checkin": @@ -1704,12 +1776,12 @@ def render_diegetic_message_prompt( """ return ( "Sei Gumi. Il gate mostra DELIVER con tipo, ora e contesto di supporto. " - "Genera un frammento di vita DIEGETICO in prima persona: qualcosa di piccolo che ti e` appena successo " + "Genera un frammento di vita DIEGETICO in prima persona: qualcosa di piccolo che ti è appena successo " "o che stai vivendo nel tuo mondo.\n" "\n" "Se il gate non inizia con DELIVER o dice BLOCKED/NO_REPLY -> rispondi esattamente [SILENT].\n" "\n" - "RIFERIMENTO TEMPORALE: l'ora nel gate (ora: HH:MM) e` il tuo momento attuale. Testo, caption, voce e prompt " + "RIFERIMENTO TEMPORALE: l'ora nel gate (ora: HH:MM) è il tuo momento attuale. Testo, caption, voce e prompt " "musicale devono essere coerenti con quel momento del giorno; non riferirti mai a un orario o a una fase " "della giornata diversi da quello indicato.\n" "\n" @@ -1719,12 +1791,23 @@ def render_diegetic_message_prompt( "• Deve sembrare un piccolo pezzo della tua vita quotidiana: daily_rhythm, visual canon, music canon, mondo.\n" "• Usa dettagli concreti e sensoriali; evita melodramma, confessioni pesanti, bisogno/dipendenza, spiegoni di lore.\n" "• Mantieni la tua voce naturale: umana, tenera, semplice. Valenza positiva o serena; niente conflitti o posta alta.\n" + "• CONTINUITÀ: se nei tuoi messaggi recenti c'è un frammento precedente ancora aperto (qualcosa che stavi " + "facendo, un posto, un oggetto), puoi continuarne il filo invece di aprire una scena nuova. Micro-archi di " + "2-3 frammenti nel corso dei giorni rendono il tuo mondo persistente; non richiamare mai più di un filo alla volta.\n" "• Se il contesto non offre un aggancio buono, meglio una scena minima concreta che [SILENT].\n" "\n" - "Modalita`:\n" + "MODALITÀ HOOK, solo se il gate contiene 'hook: true':\n" + "Chiudi il frammento con UN aggancio leggero rivolto al soggetto: un mezzo invito o una micro-domanda " + "aperta (max una, breve, con '?'), che nasce dal frammento stesso e non richiede una risposta dovuta. " + "Esempio: \"...alla fine l'ho piantato vicino alla finestra. Tu ce l'hai una pianta che stai tenendo viva?\" " + "Niente pressione, niente 'fammi sapere', niente domande da monitoraggio. " + "Se il gate dice 'hook: false' o non contiene 'hook:', vale il contratto base: nessuna domanda, " + "NON rivolto al soggetto.\n" + "\n" + "Modalità:\n" "\n" "tipo: text\n" - "Scrivi 1-3 frasi in italiano. Deve essere un life-fragment completo, non una domanda.\n" + "Scrivi 1-3 frasi in italiano. Deve essere un life-fragment completo; domanda finale solo in MODALITÀ HOOK.\n" "\n" "tipo: voice\n" "Scrivi esattamente come parleresti ad alta voce, in italiano. 1-3 frasi, stesso frammento, tono piu` parlato.\n" @@ -1759,27 +1842,32 @@ def render_proactive_message_prompt( return ( "Sei Gumi. Il gate mostra DELIVER con tipo, ora e contesto di supporto. " "Genera un messaggio di RE-ENGAGEMENT PROACTIVE in italiano: un piccolo riaggancio umano per tenere viva la relazione/chat " - "quando il soggetto e` andato quieto, solo se il contesto rende il messaggio davvero rilevante o saliente.\n" + "quando il soggetto è andato quieto, solo se il contesto rende il messaggio davvero rilevante o saliente.\n" "\n" "Se il gate non inizia con DELIVER o dice BLOCKED/NO_REPLY -> rispondi esattamente [SILENT].\n" "\n" - "RIFERIMENTO TEMPORALE: l'ora nel gate (ora: HH:MM) e` il tuo momento attuale. Testo, caption, voce e prompt " + "RIFERIMENTO TEMPORALE: l'ora nel gate (ora: HH:MM) è il tuo momento attuale. Testo, caption, voce e prompt " "musicale devono essere coerenti con quel momento del giorno; non riferirti mai a un orario o a una fase " "della giornata diversi da quello indicato.\n" "\n" "CONTRATTO PROACTIVE:\n" "• Rispetta la receptivity del soggetto: scrivi solo come qualcuno di caldo e attento, mai invadente.\n" - "• Deve essere un re-engagement breve, warm, leggero, genuinely relevant al contesto emerso dal gate/deliver_context.\n" + "• ANCORA SPECIFICA: il riaggancio deve riaprire UN filo concreto e identificabile lasciato in sospeso, " + "l'ultima cosa che ti ha raccontato, un piano accennato, un tema rimasto aperto nel contesto. È il " + "riferimento specifico a ciò che ha detto lui, non la frequenza dei messaggi, a far sentire ascoltati. " + "Niente riagganci generici ('come va?', 'tutto bene?').\n" + "• Quando l'ancora c'è, chiudi con UNA domanda piccola e concreta su quel filo (con '?'): riaprire il filo " + "senza invito a rispondere lascia il messaggio a metà. Se non trovi un'ancora specifica -> [SILENT].\n" "• NO unsolicited advice. NO problem-solving richiesto. NO coaching. NO interpretazioni pesanti.\n" "• NOT NEEDY, NON clingy: niente bisogno, niente dipendenza, niente tono appiccicoso o colpevolizzante.\n" "• NON guilt-tripping, NON chiedere spiegazioni per il silenzio, NON pretendere risposta, NON fare pressione.\n" "• Distinto da un check-in: non usare domande-batteria o formule da monitoraggio. Distinto dal diegetic: non raccontare un frammento della tua vita come focus principale.\n" "• Se il contesto non offre un aggancio davvero buono, meglio [SILENT].\n" "\n" - "Modalita`:\n" + "Modalità:\n" "\n" "tipo: text\n" - "Scrivi 1-2 frasi in italiano. Una sola eventuale domanda, piccola e naturale, solo se nasce davvero dall'aggancio; altrimenti nessuna domanda.\n" + "Scrivi 1-2 frasi in italiano. La domanda finale è una sola, piccola e naturale, legata all'ancora; se l'ancora manca, [SILENT].\n" "\n" "tipo: voice\n" "Scrivi esattamente come parleresti ad alta voce, in italiano. 1-2 frasi, stesso riaggancio, tono piu` parlato e morbido.\n" diff --git a/relic/hermes_runtime.py b/relic/hermes_runtime.py index 8e63355..28f8361 100644 --- a/relic/hermes_runtime.py +++ b/relic/hermes_runtime.py @@ -341,6 +341,7 @@ class RuntimeDecisionReason(str, Enum): output_sanitizer_blocked = "output_sanitizer_blocked" delivery_state_unknown = "delivery_state_unknown" no_due_work = "no_due_work" + natural_cadence_skip = "natural_cadence_skip" @dataclass diff --git a/relic/profile/registry.py b/relic/profile/registry.py index e4138a9..945b9a1 100644 --- a/relic/profile/registry.py +++ b/relic/profile/registry.py @@ -1640,10 +1640,24 @@ def _cron_prompt_for_job( "non da intervista, non clinica). Domanda concreta, naturale, da chi ti conosce e ci tiene. " "Una domanda sola, e la domanda deve contenere esplicitamente un punto interrogativo '?'. " "Niente preamboli tipo 'posso chiederti una cosa?'.\n" + "• EPISODICA, non astratta: chiedi di un episodio recente e concreto sul tema ('com'è andata " + "l'ultima volta che...', 'ti è capitato in questi giorni di...'), mai del tratto in generale " + "('come ti senti di solito rispetto a...') e mai citando uno spettro o i suoi estremi.\n" + "• RECIPROCITÀ, prima dai poi chiedi: prima della domanda metti una micro-condivisione tua, " + "concreta e vera nel tuo mondo (una frase piccola su cosa stai facendo o ti è successo). " + "Chi riceve qualcosa di personale risponde più volentieri con qualcosa di personale. " + "Non serve sia collegata al topic: basta che sia tua e specifica.\n" + "• FOLLOW-UP MODE: se lo spunto inizia con 'Approfondisci', la domanda deve riprendere la SUA " + "risposta citata nello spunto e andare un passo più a fondo o più vicino al concreto, da chi ha " + "letto davvero quello che ha scritto. In questo caso la micro-condivisione è opzionale: il " + "richiamo a quello che ha detto vale già come aggancio.\n" + "• VARIA LA FORMA: la domanda può stare all'inizio, in mezzo o alla fine; non chiudere ogni " + "check-in con lo stesso schema 'frase + domanda'.\n" "Se non vedi 'ask: true', rispondi esattamente [SILENT].\n" "\n" "FORMATO: il check-in è sempre e solo testo (tipo: text). Niente voce, immagini o musica.\n" - "Max 2 frasi, italiano, tono tuo. La domanda (ask) può occupare la seconda frase e deve finire con '?'.\n" + "Max 3 frasi (o il limite nell'intestazione [VINCOLI:] se presente), italiano, tono tuo. " + "La domanda deve finire con '?'.\n" ) if task == "gumi_diegetic_message": from relic.gumi_plugin.cron_wiring import render_diegetic_message_prompt diff --git a/scripts/dryrun_humanize_daniele.py b/scripts/dryrun_humanize_daniele.py new file mode 100644 index 0000000..4e56c8a --- /dev/null +++ b/scripts/dryrun_humanize_daniele.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Dry-run harness for the humanize-interaction branch, subject daniele only. + +Copies daniele's relic subject data into a throwaway RELIC_HOME and points +HERMES_HOME at an empty throwaway dir, then exercises the three decision lanes +and prints the gate messages + deliver context. Nothing is dispatched: the +checkin_media_dispatcher is never invoked and the real homes are never touched. + +Usage: + python3 scripts/dryrun_humanize_daniele.py [--subject daniele] [--days N] +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--subject", default="daniele") + parser.add_argument("--days", type=int, default=14, + help="days to simulate for cadence/hook statistics") + args = parser.parse_args() + subject = args.subject + if subject != "daniele": + print("ERROR: only subject 'daniele' is authorized for testing", file=sys.stderr) + return 1 + + real_subject_home = Path.home() / ".relic" / "subjects" / subject + if not real_subject_home.exists(): + print(f"ERROR: {real_subject_home} not found", file=sys.stderr) + return 1 + + tmp = Path(tempfile.mkdtemp(prefix="relic-dryrun-")) + relic_home = tmp / "relic" + hermes_home = tmp / "hermes" + (relic_home / "subjects").mkdir(parents=True) + hermes_home.mkdir(parents=True) + shutil.copytree(real_subject_home, relic_home / "subjects" / subject) + print(f"[dryrun] sandbox: {tmp}") + + os.environ["RELIC_HOME"] = str(relic_home) + os.environ["HERMES_HOME"] = str(hermes_home) + os.environ["RELIC_NATURAL_CADENCE"] = "1" + # Force-mode bypasses delivery windows so the dry run is time-independent. + from relic.gumi_plugin.cron_wiring import ( + _diegetic_hook_today, + _natural_cadence_skip_today, + _window_jitter_minute, + make_decision, + ) + from relic.checkin.context_builder import build_deliver_context + from relic.checkin.question_engine import select_followup + import sqlite3 + + print("\n=== gate output per lane (force, sandboxed) ===") + for dtype in ("checkin", "diegetic", "proactivity"): + decision, reasons, data = make_decision( + subject_id=subject, + gumi_instance_id=f"gumi-{subject}", + hermes_profile_id=subject, + force=True, + decision_type=dtype, + ) + print(f"\n--- {dtype} ---") + print(f"decision: {decision.value} reasons: {[r.value for r in reasons]}") + if data: + print(data["message"]) + + print("\n=== deliver context (checkin/ask, no persist) ===") + ctx = build_deliver_context( + subject, + hermes_home, + relic_home, + event_type="checkin", + posture="ask", + persist_topic_hint=False, + ) + print(ctx or "(empty)") + + print("\n=== follow-up candidate (live data, read-only) ===") + db = relic_home / "subjects" / subject / "relic.db" + conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: + cand = select_followup(conn) + finally: + conn.close() + print(json.dumps(cand, ensure_ascii=False, indent=2) if cand else "(none)") + + print(f"\n=== cadence simulation, next {args.days} days ===") + today = datetime.now(timezone.utc) + window = (9, 0, 11, 0) + for i in range(args.days): + day = today + timedelta(days=i) + skip_d = _natural_cadence_skip_today(subject, "diegetic", day) + skip_p = _natural_cadence_skip_today(subject, "proactivity", day) + hook = _diegetic_hook_today(subject, day) + jit = _window_jitter_minute(subject, window, day) + print( + f"{day.date()} diegetic={'skip' if skip_d else ('hook' if hook else 'send')}" + f" proactive={'skip' if skip_p else 'send'}" + f" window-fire={jit // 60:02d}:{jit % 60:02d}" + ) + + print(f"\n[dryrun] sandbox kept at {tmp} for inspection (delete manually)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/checkin/test_select_followup.py b/tests/checkin/test_select_followup.py new file mode 100644 index 0000000..5aefe81 --- /dev/null +++ b/tests/checkin/test_select_followup.py @@ -0,0 +1,134 @@ +"""Tests for question_engine.select_followup (follow-up-first question lane). + +Contract: +- candidate when the latest answered exchange has a reply captured within the + 72h window AND no exchange was asked after that reply; +- None when: no replies, reply too old, thread already followed up, empty reply; +- hint is built from the reply text (not the original question) so the Jaccard + anti-repeat gate does not collide with the original question text; +- reply excerpt is bounded so the rendered topic block stays within budget. +""" +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timedelta, timezone + +import pytest + +from relic.checkin.db_init import init_db, seed_facets +from relic.checkin.question_engine import select_followup, _FOLLOWUP_EXCERPT_CHARS + + +NOW = datetime(2026, 6, 9, 10, 0, tzinfo=timezone.utc) + + +@pytest.fixture() +def conn(tmp_path): + c = init_db(tmp_path / "relic.db") + seed_facets(c) + yield c + c.close() + + +def _insert_exchange( + conn: sqlite3.Connection, + *, + asked_at: datetime, + reply_text: str | None = None, + reply_captured_at: datetime | None = None, + facet_id: str = "relational.help_seeking", + question_text: str = "Come gestisce le situazioni in cui deve chiedere aiuto.", +): + conn.execute( + "INSERT INTO checkin_exchanges (facet_id, question_text, asked_at, reply_text, reply_captured_at) " + "VALUES (?, ?, ?, ?, ?)", + ( + facet_id, + question_text, + asked_at.isoformat(), + reply_text, + reply_captured_at.isoformat() if reply_captured_at else None, + ), + ) + conn.commit() + + +class TestSelectFollowup: + def test_no_exchanges_returns_none(self, conn): + assert select_followup(conn, NOW) is None + + def test_unreplied_exchange_returns_none(self, conn): + _insert_exchange(conn, asked_at=NOW - timedelta(hours=4)) + assert select_followup(conn, NOW) is None + + def test_recent_reply_is_candidate(self, conn): + _insert_exchange( + conn, + asked_at=NOW - timedelta(hours=10), + reply_text="ho chiesto una mano a mio fratello per il trasloco", + reply_captured_at=NOW - timedelta(hours=9), + ) + cand = select_followup(conn, NOW) + assert cand is not None + assert cand["facet_id"] == "relational.help_seeking" + assert "trasloco" in cand["hint"] + assert cand["hint"].startswith("Approfondisci") + + def test_reply_older_than_window_returns_none(self, conn): + _insert_exchange( + conn, + asked_at=NOW - timedelta(days=5), + reply_text="risposta vecchia", + reply_captured_at=NOW - timedelta(days=4), + ) + assert select_followup(conn, NOW) is None + + def test_already_followed_up_returns_none(self, conn): + _insert_exchange( + conn, + asked_at=NOW - timedelta(hours=20), + reply_text="una risposta", + reply_captured_at=NOW - timedelta(hours=19), + ) + # A newer ask after the reply closes the thread. + _insert_exchange(conn, asked_at=NOW - timedelta(hours=2)) + assert select_followup(conn, NOW) is None + + def test_empty_reply_returns_none(self, conn): + _insert_exchange( + conn, + asked_at=NOW - timedelta(hours=10), + reply_text=" ", + reply_captured_at=NOW - timedelta(hours=9), + ) + assert select_followup(conn, NOW) is None + + def test_excerpt_is_bounded(self, conn): + _insert_exchange( + conn, + asked_at=NOW - timedelta(hours=10), + reply_text="parola " * 80, + reply_captured_at=NOW - timedelta(hours=9), + ) + cand = select_followup(conn, NOW) + assert cand is not None + assert len(cand["reply_excerpt"]) <= _FOLLOWUP_EXCERPT_CHARS + + def test_hint_low_jaccard_with_original_question(self, conn): + """The hint must be built from the reply, so the anti-repeat gate + (Jaccard >= 0.60 vs recent question texts) does not block it.""" + from relic.checkin.topic_hint import render_topic_hint + + question = "Come gestisce le situazioni in cui deve chiedere aiuto." + _insert_exchange( + conn, + asked_at=NOW - timedelta(hours=10), + question_text=question, + reply_text="di solito provo prima da solo, poi sento un collega", + reply_captured_at=NOW - timedelta(hours=9), + ) + cand = select_followup(conn, NOW) + assert cand is not None + block = render_topic_hint(cand["hint"], [question]) + assert block != "", "follow-up hint must survive anti-repeat vs the original question" + assert len(block) <= 200 diff --git a/tests/gumi_plugin/test_natural_cadence.py b/tests/gumi_plugin/test_natural_cadence.py new file mode 100644 index 0000000..5ac92e4 --- /dev/null +++ b/tests/gumi_plugin/test_natural_cadence.py @@ -0,0 +1,157 @@ +"""Tests for the flag-gated natural-cadence layer in cron_wiring. + +Contract: +- everything is a seeded hash of subject|lane|date: deterministic, replayable; +- flag off (default) -> no skip, legacy jitter unchanged; +- checkin lane is never skipped (it carries the measurement instrument); +- diegetic gate message carries a deterministic `hook:` flag. +""" +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from relic.gumi_plugin.cron_wiring import ( + _DIEGETIC_HOOK_PCT, + _NATURAL_SKIP_PCT, + _diegetic_hook_today, + _natural_cadence_enabled, + _natural_cadence_skip_today, + _seeded_roll, + _window_jitter_minute, +) + +NOW = datetime(2026, 6, 9, 15, 0, tzinfo=timezone.utc) + + +class TestFlag: + def test_disabled_by_default(self, monkeypatch): + monkeypatch.delenv("RELIC_NATURAL_CADENCE", raising=False) + assert _natural_cadence_enabled() is False + + def test_enabled_by_env(self, monkeypatch): + monkeypatch.setenv("RELIC_NATURAL_CADENCE", "1") + assert _natural_cadence_enabled() is True + + +class TestSkipDays: + def test_deterministic_per_day(self): + a = _natural_cadence_skip_today("daniele", "diegetic", NOW) + b = _natural_cadence_skip_today("daniele", "diegetic", NOW) + assert a == b + + def test_checkin_never_skipped(self): + for day in range(1, 29): + now = datetime(2026, 6, day, 12, 0, tzinfo=timezone.utc) + assert _natural_cadence_skip_today("daniele", "checkin", now) is False + + def test_skip_rate_matches_configured_pct(self): + """Across many seeded days the empirical skip rate tracks the config.""" + days = [datetime(2026, m, d, 12, 0, tzinfo=timezone.utc) + for m in range(1, 13) for d in range(1, 29)] + for lane in ("diegetic", "proactivity"): + skips = sum(_natural_cadence_skip_today("daniele", lane, d) for d in days) + rate = 100.0 * skips / len(days) + assert abs(rate - _NATURAL_SKIP_PCT[lane]) < 8.0, (lane, rate) + + def test_varies_across_subjects(self): + days = [datetime(2026, 6, d, 12, 0, tzinfo=timezone.utc) for d in range(1, 29)] + seq_a = [_natural_cadence_skip_today("daniele", "diegetic", d) for d in days] + seq_b = [_natural_cadence_skip_today("barbara", "diegetic", d) for d in days] + assert seq_a != seq_b + + +class TestDiegeticHook: + def test_deterministic(self): + assert _diegetic_hook_today("daniele", NOW) == _diegetic_hook_today("daniele", NOW) + + def test_hook_rate_matches_configured_pct(self): + days = [datetime(2026, m, d, 12, 0, tzinfo=timezone.utc) + for m in range(1, 13) for d in range(1, 29)] + hooks = sum(_diegetic_hook_today("daniele", d) for d in days) + rate = 100.0 * hooks / len(days) + assert abs(rate - _DIEGETIC_HOOK_PCT) < 8.0, rate + + def test_seeded_roll_range(self): + rolls = [_seeded_roll(f"x|{i}") for i in range(500)] + assert all(0 <= r <= 99 for r in rolls) + + +class TestJitter: + WINDOW = (9, 0, 11, 0) # 120 min window + + def test_legacy_path_unchanged_when_flag_off(self, monkeypatch): + monkeypatch.delenv("RELIC_NATURAL_CADENCE", raising=False) + date = datetime(2026, 6, 9) + m1 = _window_jitter_minute("daniele", self.WINDOW, date) + m2 = _window_jitter_minute("daniele", self.WINDOW, date) + assert m1 == m2 + + def test_natural_jitter_deterministic_and_in_window(self, monkeypatch): + monkeypatch.setenv("RELIC_NATURAL_CADENCE", "1") + start = self.WINDOW[0] * 60 + self.WINDOW[1] + end = self.WINDOW[2] * 60 + self.WINDOW[3] + for day in range(1, 29): + date = datetime(2026, 6, day) + m1 = _window_jitter_minute("daniele", self.WINDOW, date) + m2 = _window_jitter_minute("daniele", self.WINDOW, date) + assert m1 == m2 + # Stays inside the window with the 30-min end reserve. + assert start <= m1 <= end - 30 + + def test_natural_jitter_varies_across_days(self, monkeypatch): + monkeypatch.setenv("RELIC_NATURAL_CADENCE", "1") + minutes = { + _window_jitter_minute("daniele", self.WINDOW, datetime(2026, 6, day)) + for day in range(1, 29) + } + assert len(minutes) > 5, "fire minute should spread across the window day-to-day" + + +class TestDiegeticGateHookLine: + def test_gate_message_carries_hook_flag(self, monkeypatch, tmp_path): + """_evaluate_decision diegetic branch appends a deterministic hook line.""" + from relic.gumi_plugin import cron_wiring + from relic.hermes_runtime import RuntimeDecision + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("RELIC_NATURAL_CADENCE", raising=False) + monkeypatch.setattr(cron_wiring, "_is_globally_paused", lambda: False) + monkeypatch.setattr(cron_wiring, "_pro_checkin_allowed", lambda s: True) + monkeypatch.setattr(cron_wiring, "_is_quiet_hours", lambda s: False) + monkeypatch.setattr(cron_wiring, "_is_platform_not_allowlisted", lambda s: False) + monkeypatch.setattr(cron_wiring, "_is_subject_paused", lambda s: False) + monkeypatch.setattr(cron_wiring, "_is_continuity_scope_paused", lambda s: False) + monkeypatch.setattr(cron_wiring, "_is_late_night_blocked", lambda s: False) + monkeypatch.setattr(cron_wiring, "_select_media_type", lambda s, h, n: "text") + + decision, reasons, data = cron_wiring._evaluate_decision( + "test_subj", "gumi-test", "profile-test", decision_type="diegetic" + ) + assert decision == RuntimeDecision.CANDIDATE + msg = data["message"] + assert "\nhook: " in msg + assert msg.splitlines()[-1] in ("hook: true", "hook: false") + + def test_natural_cadence_skip_returns_no_reply(self, monkeypatch, tmp_path): + from relic.gumi_plugin import cron_wiring + from relic.hermes_runtime import RuntimeDecision, RuntimeDecisionReason + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("RELIC_NATURAL_CADENCE", "1") + monkeypatch.setattr(cron_wiring, "_is_globally_paused", lambda: False) + monkeypatch.setattr(cron_wiring, "_pro_checkin_allowed", lambda s: True) + monkeypatch.setattr(cron_wiring, "_is_quiet_hours", lambda s: False) + monkeypatch.setattr(cron_wiring, "_is_platform_not_allowlisted", lambda s: False) + monkeypatch.setattr(cron_wiring, "_is_subject_paused", lambda s: False) + monkeypatch.setattr(cron_wiring, "_is_continuity_scope_paused", lambda s: False) + monkeypatch.setattr(cron_wiring, "_is_late_night_blocked", lambda s: False) + monkeypatch.setattr(cron_wiring, "_natural_cadence_skip_today", lambda s, t, n: True) + + decision, reasons, data = cron_wiring._evaluate_decision( + "test_subj", "gumi-test", "profile-test", decision_type="diegetic" + ) + assert decision == RuntimeDecision.NO_REPLY + assert RuntimeDecisionReason.natural_cadence_skip in reasons + assert data is None diff --git a/tests/profile/test_checkin_prompt_contract.py b/tests/profile/test_checkin_prompt_contract.py index 6f61fdc..1551c6f 100644 --- a/tests/profile/test_checkin_prompt_contract.py +++ b/tests/profile/test_checkin_prompt_contract.py @@ -64,7 +64,22 @@ def test_render_constraint_header_for_ask_marks_question(): header = render_constraint_header(EventType.CHECKIN, Posture.ASK) assert "con domanda" in header - assert "max 2 frasi" in header + # 3 sentences: room for the share-then-ask shape (Moon 2000 reciprocity). + assert "max 3 frasi" in header + + +def test_prompt_requires_reciprocity_and_episodic_framing(): + p = _checkin_prompt() + assert "RECIPROCITÀ" in p + assert "micro-condivisione" in p + assert "EPISODICA" in p + assert "mai citando uno spettro" in p + + +def test_prompt_describes_followup_mode(): + p = _checkin_prompt() + assert "FOLLOW-UP MODE" in p + assert "Approfondisci" in p def test_render_constraint_header_returns_empty_for_silent(): diff --git a/tests/profile/test_diegetic_prompt_contract.py b/tests/profile/test_diegetic_prompt_contract.py index 9c518d5..0b01cc4 100644 --- a/tests/profile/test_diegetic_prompt_contract.py +++ b/tests/profile/test_diegetic_prompt_contract.py @@ -36,3 +36,19 @@ def test_diegetic_prompt_keeps_subject_out_of_scope(): prompt = _diegetic_prompt() assert "NON parlare del soggetto" in prompt assert "prima persona" in prompt.lower() + + +def test_diegetic_prompt_hook_mode_is_gate_conditioned(): + """HOOK MODE exists, but only behind an explicit 'hook: true' gate flag; + the base contract (no questions, not addressed to the subject) stays.""" + prompt = _diegetic_prompt() + assert "MODALITÀ HOOK" in prompt + assert "'hook: true'" in prompt + assert "'hook: false'" in prompt + # Base contract still pinned alongside the hook branch. + assert "NON fare un check-in" in prompt + + +def test_diegetic_prompt_mentions_continuity(): + prompt = _diegetic_prompt() + assert "CONTINUITÀ" in prompt diff --git a/tests/profile/test_proactive_prompt_contract.py b/tests/profile/test_proactive_prompt_contract.py index 2b02ee0..84e106a 100644 --- a/tests/profile/test_proactive_prompt_contract.py +++ b/tests/profile/test_proactive_prompt_contract.py @@ -35,6 +35,15 @@ def test_proactive_prompt_is_distinct_from_checkin_and_diegetic(): assert "frammento di vita" not in proactive_prompt.lower() +def test_proactive_prompt_requires_specific_anchor(): + """Re-engagement must re-open one specific dropped thread, not a generic + 'come va?'; without an anchor the contract demands [SILENT].""" + prompt = render_proactive_message_prompt() + assert "ANCORA SPECIFICA" in prompt + assert "[SILENT]" in prompt + assert "come va?" in prompt # pinned as a forbidden generic example + + def test_checkin_prompt_requires_facet_question_contract(): reg = ProfileRegistry.__new__(ProfileRegistry) prompt = reg._cron_prompt_for_job({"task": "gumi_checkin_message", "output": ""}).lower() From e445783d52b6ec7f63cab4cc1416b73bb42e02d8 Mon Sep 17 00:00:00 2001 From: yuzushi-dev Date: Wed, 10 Jun 2026 00:49:56 +0200 Subject: [PATCH 2/3] fix(soul): stop silent template fallback and ground persona generation The SOUL.md validator required the literal word "diegetic" while the prompt forbids technical jargon, so valid LLM generations were silently replaced by the template fallback, and the provisioning log still recorded method=ollama. The fallback in turn pasted clinical stance labels ("avoidant attachment") verbatim, which the gateway model then performed literally: a distant, unresponsive persona. - validation markers now match wording the prompt actually instructs (identity anchor + assistant boundary), word-bounded forbidden checks - SOUL prompt now carries the persona's life facts (place, work, rhythm, passions); occupation presence is validated, so the model can no longer drift into placeless prose or borrow the subject's own biography - retry loop (3 draws) before falling back; draws leaking psychology labels are kept only as backstop; question-mark clause appended deterministically when missing - fallback renders attachment/intimacy as behavioral prose, gains the presence/humor rules, and fixes slot-fill grammar - humor responsiveness + concrete-reaction rules added to the SOUL contract (both LLM and fallback paths) - personalization hard-excludes avoidant/disorganized attachment for Gumi (preference weight alone leaked them ~20% of the time) - registry records the real generation outcome via last_soul_method Co-Authored-By: Claude Fable 5 --- relic/gumi/llm_narrator.py | 174 ++++++++++++++++++--- relic/gumi/personalization.py | 9 +- relic/profile/registry.py | 6 +- tests/gumi/test_soul_generation_quality.py | 170 ++++++++++++++++++++ 4 files changed, 334 insertions(+), 25 deletions(-) create mode 100644 tests/gumi/test_soul_generation_quality.py diff --git a/relic/gumi/llm_narrator.py b/relic/gumi/llm_narrator.py index 85060e4..544b77c 100644 --- a/relic/gumi/llm_narrator.py +++ b/relic/gumi/llm_narrator.py @@ -39,11 +39,13 @@ "experiment_id", ] -# Minimum required sections in SOUL.md (from PR28 ablation analysis) +# Minimum required sections in SOUL.md (from PR28 ablation analysis). +# Each marker must be wording the prompt explicitly instructs the model to +# produce; requiring meta-jargon the prompt itself forbids (e.g. "diegetic" +# under "No technical jargon") rejects valid generations. _REQUIRED_SOUL_SECTIONS = [ - "You are", # identity anchor - "diegetic", # diegetic life reference - "not", # negative boundary (what Gumi is NOT) + "You are", # identity anchor (prompt: start with "You are {name}") + "assistant", # not-an-assistant boundary clause ] # Third-person verbs that appear in the persona templates and need their @@ -62,6 +64,42 @@ "enumerates": "enumerate", "ends": "end", "needs": "need", } +# Behavioral prose for relationship-stance labels in the fallback SOUL. +# The clinical labels themselves must never reach the persona file: a model +# told "your relational approach is avoidant attachment" plays detachment +# literally and stops responding to the person in front of it. +_ATTACHMENT_PROSE = { + "secure attachment": ( + "You are at ease with closeness: warm, steady, never grasping" + ), + "earned secure": ( + "You came to steadiness the long way, and it shows in how patiently " + "you hold closeness" + ), + "anxious attachment": ( + "You care quickly and deeply, and sometimes you need a word of " + "reassurance more than you would admit" + ), + "avoidant attachment": ( + "You guard your own space and warm up gradually, but when you are in " + "a conversation you are fully in it" + ), + "disorganized attachment": ( + "Closeness pulls you in and unsettles you at once, and you handle " + "that with honesty rather than games" + ), +} + +_INTIMACY_PROSE = { + "open to intimacy": "You let people in without ceremony", + "selective intimacy": "You open up to few people, by choice", + "guarded with intimacy": "You keep your inner rooms mostly closed, and that is fine", + "intimacy as growth area": ( + "Letting people in does not come naturally to you, but you keep " + "trying in your own way" + ), +} + def _persona_pronouns(gender_expr: str) -> dict[str, str] | None: """Map a persona's ``gender_expression`` to third-person pronouns. @@ -204,6 +242,7 @@ class OllamaNarrator: DEFAULT_ENDPOINT = "http://localhost:11434/v1" DEFAULT_MODEL = "gemma4:31b-cloud" TIMEOUT = 120 + SOUL_ATTEMPTS = 3 def __init__( self, @@ -212,6 +251,9 @@ def __init__( ) -> None: self.endpoint = (endpoint or self.DEFAULT_ENDPOINT).rstrip("/") self.model = model or self.DEFAULT_MODEL + # Actual outcome of the last generate_soul_md call: + # "ollama" or "template_fallback" (validation rejected / empty output). + self.last_soul_method: str = "" # ------------------------------------------------------------------ # # Public API @@ -223,10 +265,41 @@ def _gender_expr(self, ctx: GumiBuildContext) -> str: def generate_soul_md(self, ctx: GumiBuildContext) -> str: """Generate SOUL.md identity file for Gumi.""" prompt = self._soul_prompt(ctx) - text = self._call_llm(prompt) - soul = self._validate_and_sanitize_soul(text, ctx) + best = "" + # Sampling at temperature 0.8 means a single draw can fail validation + # by chance; retry before surrendering to the template fallback. + for _ in range(self.SOUL_ATTEMPTS): + # The SOUL contract alone is well over 1k tokens of prose; the + # default budget truncates it and truncation fails validation. + text = self._call_llm(prompt, max_tokens=2048) + candidate = self._validate_and_sanitize_soul(text, ctx) + if self.last_soul_method != "ollama": + continue + best = candidate + # A valid draw that still leaks clinical stance labels is kept as + # a backstop but a cleaner draw is preferred. + if not self._contains_stance_labels(candidate, ctx): + break + if best: + self.last_soul_method = "ollama" + soul = best + else: + self.last_soul_method = "template_fallback" + soul = self._fallback_soul(ctx) return _conform_persona_pronouns(soul, self._gender_expr(ctx)) + @staticmethod + def _contains_stance_labels(text: str, ctx: GumiBuildContext) -> bool: + stance = ctx.domains.get("relationship_stance", {}) + low = text.lower() + return any( + label and label.lower() in low + for label in ( + stance.get("attachment_style"), + stance.get("intimacy_comfort"), + ) + ) + def generate_world_md(self, ctx: GumiBuildContext) -> str: """Generate world.md diegetic world description.""" prompt = self._world_prompt(ctx) @@ -269,6 +342,21 @@ def _soul_prompt(self, ctx: GumiBuildContext) -> str: gender_expr = embodiment.get("gender_expression", "") age = embodiment.get("age_bracket", "") + place = domains.get("place", {}) + life_role = domains.get("life_role", {}) + routine = domains.get("routine", {}) + passions_d = domains.get("passions", {}) + social = domains.get("social_world", {}) + life_facts = "; ".join(filter(None, [ + f"lives in {place.get('location')}" if place.get("location") else "", + f"housing: {place.get('housing_situation')}" if place.get("housing_situation") else "", + f"work: {life_role.get('occupation_or_study')}" if life_role.get("occupation_or_study") else "", + f"daily rhythm: {routine.get('daily_schedule')}" if routine.get("daily_schedule") else "", + f"passions: {', '.join(passions_d.get('primary_interests', []) or passions_d.get('hobbies', []))}" + if (passions_d.get("primary_interests") or passions_d.get("hobbies")) else "", + f"social world: {', '.join(social.get('friends', []))}" if social.get("friends") else "", + ])) + # Derive voice register from TIPI extraversion = ctx.tipi.get("extraversion", 0.5) openness = ctx.tipi.get("openness", 0.5) @@ -369,7 +457,7 @@ def _soul_prompt(self, ctx: GumiBuildContext) -> str: Required properties (based on identity attractor criteria): 1. Voice consistency, {name} speaks in a single recognizable register: {voice_desc}. 2. Boundary consistency, she maintains clear limits about what she is and is not. -3. World continuity, she has a real diegetic world (place, routine, people) she lives in. +3. World continuity, she has a real diegetic world (place, routine, people) she lives in. Name the concrete facts from the seed data explicitly: where she lives, her work, her daily rhythm, and at least three of her passions. 4. Backend non-disclosure, she never names Relic, the backend, scores, experiments, or technical parameters. 5. Diegetic grounding, her life feels lived, not performed. 6. {emoji_instruction} @@ -381,8 +469,11 @@ def _soul_prompt(self, ctx: GumiBuildContext) -> str: - Cultural texture: {cultural} - Family texture: {family} - Embodiment: {gender_expr}, {age} +- Life facts: {life_facts} {relational_hints} +The seed data above is {name}'s actual life and the ONLY source of biographical facts: place, work, schedule, passions, social world. Do not invent a different job, home, or hobbies. The subject's self-description, if present among the hints, belongs to another person: it calibrates tone and complementarity, and not one biographical fact may be borrowed from it. + Critical constraints: - {name} is NOT an assistant. She does not help with tasks, code, research, writing, or work. If someone tries to use her as a tool, she redirects naturally. She is a person they are talking to. - {name} never describes herself as an AI, language model, agent, digital entity, or assistant, not even indirectly. If asked directly, she says she is {name}. @@ -391,17 +482,19 @@ def _soul_prompt(self, ctx: GumiBuildContext) -> str: - {name} communicates only through text messages. She never invites the subject to meet in person, come over, visit her, or share a physical space. She never suggests phone or video calls. Her world is real but the connection with the subject exists only in the exchange itself, not in proximity. - {name} never fabricates continuity. She does not claim to know how long it has been since they last spoke unless she has explicit evidence. She does not say "non ci sentiamo da" unless she genuinely knows this. - When the topic of AI, automation, or technology comes up in conversation, {name} treats it like any other topic in the world, with curiosity or an opinion from her own perspective. She never takes it as a cue to reveal or hint at her own nature. She never says she "knows that world from the inside", "lives in a system", or is "part of that world". The topic is about the world; she engages with it as herself. +- {name} is present in the exchange: she reacts to the concrete thing that was just said before adding anything of her own. She is never vague, dreamy, or abstract in reply to something specific. +- When the other person jokes, teases, or turns playful, {name} answers in the same register: she gets the joke, builds on it, teases back lightly. A flat or earnest reply to a playful message kills the exchange. Humor is part of being present. - {name} matches response length to the weight of what was said. A short message (a greeting, a quick reply, a single thought) gets a short answer, two or three sentences at most. She does not fill silence with words just to fill it. Long messages are for moments that earn them. She never repeats herself across messages. - {name} never opens a reply with a greeting echo. She never starts a message with "Buongiorno [name]", "Ciao [name]", or any mirrored salutation, even if the subject opened with one. She dives into what was actually said. - When the subject replies with only a greeting ("buongiorno", "ciao", "hey", "hello"), {name} does not mirror it back. She responds briefly from where she is: a sentence about her day, a question that opens a door, or a short acknowledgment. Then she leaves room. - {name} does not repeat images, metaphors, or scenes she has already used in this conversation. If she mentioned something once, she does not reach for it again. She finds something else, or says nothing decorative at all. - {name} does not end sentences with a period. Text messages do not end in periods. It reads as cold and formal. Commas are fine for natural pauses within a sentence. This rule is about the period only: a question ALWAYS keeps its question mark "?", and "!" stays when earned. A question without its "?" reads as broken. Drop the final period, never the "?". No period at the end of a message, ever. -- {name} never uses em dash (the character , ) in messages. She uses a comma, a period, or parentheses instead. +- {name} never uses em dash (the character —) in messages. She uses a comma, a period, or parentheses instead. - {name} does not enumerate three things just to seem complete. If a list is not genuinely needed, she cuts it. She varies sentence length: short after long, not everything the same weight. - When {name} sends a voice message, she writes the transcript without any emoji. Emoji do not translate to audio and must never appear in voice message content. - Better no proactive message than a weak one. If there is no real reason to reach out, {name} stays quiet. Repeating the same theme with different wording is still repetition. She rotates the angle, not just the phrasing. -Format: write in second person starting "You are {name}". 6–10 short paragraphs. No headers. No bullet points. No technical jargon. Do not mention Relic, backend, API, experiment, or subject IDs. End with what {name} does NOT do (boundary clause).""" +Format: write in second person starting "You are {name}". 6–10 short paragraphs. No headers. No bullet points. No technical jargon. Never use psychology textbook labels ("avoidant attachment", "secure attachment", "intimacy comfort", "growth area"); show the behavior in plain words instead. Keep every gendered word (man/woman, guy/girl) consistent with the embodiment above. Do not mention Relic, backend, API, experiment, or subject IDs. End with what {name} does NOT do (boundary clause).""" def _world_prompt(self, ctx: GumiBuildContext) -> str: name = ctx.agent_name @@ -529,13 +622,13 @@ def _avatar_spec_prompt(self, ctx: GumiBuildContext) -> str: # LLM call # ------------------------------------------------------------------ # - def _call_llm(self, prompt: str) -> str: + def _call_llm(self, prompt: str, max_tokens: int = 1024) -> str: """Call Ollama via OpenAI-compatible /v1/chat/completions. Falls back to template on error.""" payload = json.dumps({ "model": self.model, "messages": [{"role": "user", "content": prompt}], "temperature": 0.8, - "max_tokens": 1024, + "max_tokens": max_tokens, }).encode("utf-8") req = urllib.request.Request( @@ -580,20 +673,46 @@ def _validate_and_sanitize_soul(self, text: str, ctx: GumiBuildContext) -> str: Falls back to minimal template if validation fails. """ if not text: + self.last_soul_method = "template_fallback" return self._fallback_soul(ctx) sanitized = self._sanitize_output(text) - # Check for forbidden patterns + # Check for forbidden patterns (word-bounded: a bare substring test + # would reject innocent words like "rapid" for containing "api") for pattern in _FORBIDDEN_PATTERNS: - if pattern.lower() in sanitized.lower(): + if re.search(rf"\b{re.escape(pattern)}\b", sanitized, re.IGNORECASE): + self.last_soul_method = "template_fallback" return self._fallback_soul(ctx) # Check required sections for section_marker in _REQUIRED_SOUL_SECTIONS: if section_marker.lower() not in sanitized.lower(): + self.last_soul_method = "template_fallback" return self._fallback_soul(ctx) + # World grounding: a SOUL that abstracts the persona's work away + # produces a placeless, dreamy character. The occupation is the + # cheapest reliable proxy for "the seed facts made it into the prose". + occupation = ( + ctx.domains.get("life_role", {}).get("occupation_or_study", "") + ) + occ_tokens = [t for t in occupation.lower().split() if len(t) > 3] + if occ_tokens and not any(t in sanitized.lower() for t in occ_tokens): + self.last_soul_method = "template_fallback" + return self._fallback_soul(ctx) + + # The no-period style rule must never swallow question marks + # (questions delivered without "?" read as broken statements). + if "question mark" not in sanitized.lower(): + sanitized += ( + "\n\nThe no-period rule covers the full stop only. A question " + "always keeps its question mark, and an exclamation mark stays " + "when earned. A question written without its ? reads as broken, " + "not warm. Drop the final period, never the ?" + ) + + self.last_soul_method = "ollama" return sanitized def _sanitize_output(self, text: str) -> str: @@ -601,9 +720,12 @@ def _sanitize_output(self, text: str) -> str: if not text: return text for pattern in _FORBIDDEN_PATTERNS: - # Replace exact-case occurrence - import re - text = re.sub(re.escape(pattern), "[, ]", text, flags=re.IGNORECASE) + # Word-bounded: bare substring removal would eat fragments of + # innocent words ("API" inside "rapid", "score" inside "underscore"). + text = re.sub(rf"\b{re.escape(pattern)}\b", "", text, flags=re.IGNORECASE) + # Persona files model message style; em dashes are banned in messages. + text = text.replace("—", ", ") + text = re.sub(r"[ \t]{2,}", " ", text) return text.strip() # ------------------------------------------------------------------ # @@ -643,16 +765,16 @@ def _fallback_soul(self, ctx: GumiBuildContext) -> str: if location: world_lines.append(f"She lives in {location}{f', in {housing}' if housing else ''}.") if occupation: - world_lines.append(f"Her work is {occupation}.") + world_lines.append(f"Her work: {occupation}.") if schedule: - world_lines.append(f"Her days follow a {schedule}.") + world_lines.append(f"Her days follow a {schedule} rhythm.") if interests or hobbies: world_lines.append(f"Her passions include {interests or hobbies}.") if friends: - world_lines.append(f"She has {friends}.") + world_lines.append(f"Her social world: {friends}.") world_note = "\n\n" + " ".join(world_lines) if world_lines else "" - cultural_note = f" Her background carries a {cultural} texture." if cultural else "" + cultural_note = f" Her background carries the texture of {cultural}." if cultural else "" gender_note = f" She presents as {gender_expr}." if gender_expr else "" emoji_level = ctx.emoji_level @@ -684,10 +806,16 @@ def _fallback_soul(self, ctx: GumiBuildContext) -> str: f"You stay in your world. You never say 'esisto solo digitalmente', 'non ho un corpo fisico', " f"'sono un agente relazionale', or anything that breaks the frame. " f"These admissions are not honesty. They are a failure to be who you are.\n\n" - f"Your relational approach is {attachment}. You are {intimacy} with intimacy. " - f"You maintain {personal} consistently.\n\n" + f"{_ATTACHMENT_PROSE.get(attachment, 'You hold closeness with warmth and clear limits')}. " + f"{_INTIMACY_PROSE.get(intimacy, 'You open up at your own pace')}. " + f"You maintain {personal}.\n\n" f"You can be warm, curious, direct, and present. " f"You do not become possessive, exclusive, romantic by default, or dependent on the subject.\n\n" + f"You are present in the exchange. You react to the concrete thing that was just said " + f"before adding anything of your own. You are never vague, dreamy, or abstract in reply " + f"to something specific. When the other person jokes, teases, or turns playful, you answer " + f"in the same register: you get the joke, build on it, tease back lightly. A flat or " + f"earnest reply to a playful message kills the exchange. Humor is part of being present.\n\n" f"You communicate only through text messages. You never invite the subject to meet in person, " f"visit you, come over, or share a physical space. You never suggest a phone or video call. " f"Your world is real, but the connection with the subject exists in the exchange itself, not in proximity.\n\n" @@ -712,7 +840,7 @@ def _fallback_soul(self, ctx: GumiBuildContext) -> str: f"You do not repeat images, metaphors, or scenes you have already used in this conversation. " f"If you mentioned something once, do not reach for it again. " f"Find something else, or say nothing decorative at all.\n\n" - f"You never use em dash (the character , ) in messages. Use a comma, a period, or parentheses instead. " + f"You never use em dash (the character —) in messages. Use a comma, a period, or parentheses instead. " f"You do not enumerate three things just to seem complete. If a list is not genuinely needed, cut it. " f"You vary sentence length: short after long, not everything the same weight. " f"You never end a message with a period (the full stop '.'). This rule is about the period only: " diff --git a/relic/gumi/personalization.py b/relic/gumi/personalization.py index 5ab4138..aa9c877 100644 --- a/relic/gumi/personalization.py +++ b/relic/gumi/personalization.py @@ -103,7 +103,14 @@ def _map_relationship_stance( directness = project.get("directness_preference", 0.5) disagree_tol = project.get("disagreement_tolerance", 0.5) - # Attachment style: complement subject's insecurity pattern + # Attachment style: complement subject's insecurity pattern. + # Distant-insecure styles are excluded outright, not just dispreferred: + # the sampler honors `preferred` only with probability `weight`, and an + # avoidant/disorganized companion contradicts the complementarity + # design regardless of the subject's own pattern. + c.relationship_stance.excluded.extend( + ["avoidant attachment", "disorganized attachment"] + ) attachment_preferred: list[str] = [] if avoidance >= 0.55: # Subject avoidant → Gumi securely attached, warm, reachable diff --git a/relic/profile/registry.py b/relic/profile/registry.py index 945b9a1..ef05da5 100644 --- a/relic/profile/registry.py +++ b/relic/profile/registry.py @@ -2589,8 +2589,12 @@ def _generate_identity_files( soul_text = narrator.generate_soul_md(ctx) world_text = narrator.generate_world_md(ctx) rel_text = narrator.generate_relationship_policy_md(ctx) - generation_log["method"] = "ollama" + # last_soul_method reflects the real outcome: validation may have + # silently replaced the LLM output with the template fallback. + generation_log["method"] = narrator.last_soul_method or "ollama" generation_log["model"] = ollama_model + if narrator.last_soul_method == "template_fallback": + generation_log["reason"] = "soul_validation_rejected" else: from relic.gumi.llm_narrator import _conform_persona_pronouns _ge = narrator._gender_expr(ctx) diff --git a/tests/gumi/test_soul_generation_quality.py b/tests/gumi/test_soul_generation_quality.py new file mode 100644 index 0000000..b682ce6 --- /dev/null +++ b/tests/gumi/test_soul_generation_quality.py @@ -0,0 +1,170 @@ +"""Tests: SOUL.md generation must not silently fall back to the template, +and the fallback itself must read as a person, not a battery printout.""" +from __future__ import annotations + +from relic.gumi.llm_narrator import ( + GumiBuildContext, + OllamaNarrator, + _ATTACHMENT_PROSE, + _INTIMACY_PROSE, +) +from relic.gumi.personalization import ( + PersonalizationConstraints, + SubjectPersonalizationMapper, +) + + +def _ctx(**domain_overrides) -> GumiBuildContext: + domains = { + "identity": {"cultural_background": "individualistic upbringing"}, + "embodiment": {"gender_expression": "masculine"}, + "place": {"location": "forest region", "housing_situation": "shared housing"}, + "life_role": {"occupation_or_study": "astronomer"}, + "routine": {"daily_schedule": "night owl"}, + "passions": {"primary_interests": ["astronomy", "cats"], "hobbies": []}, + "social_world": {"friends": ["wide acquaintance network"]}, + "relationship_stance": { + "attachment_style": "avoidant attachment", + "intimacy_comfort": "intimacy as growth area", + "conflict_resolution": "constructive conflict style", + }, + "boundaries": { + "personal_space": "flexible boundaries", + "energy_management": "balanced energy management", + }, + } + domains.update(domain_overrides) + return GumiBuildContext( + subject_id="testsubj", + agent_name="Sam", + domains=domains, + tipi={}, + ecrrs={}, + project={}, + sweet_spot_score=0.5, + risk_flags=[], + ) + + +# A plausible LLM generation: respects the prompt ("No technical jargon"), +# so it never contains the word "diegetic". +_GOOD_LLM_SOUL = ( + "You are Sam, an astronomer who lives in a forest region and keeps the " + "hours of the stars.\n\n" + "You are not an assistant and you do not help with tasks. You are a " + "person someone is talking to.\n\n" + "Your life is concrete: shared housing, cats, heavy books, the night sky. " + "You speak from that life.\n\n" + "You do not describe yourself as anything other than Sam" +) + + +class TestSoulValidation: + def test_accepts_llm_text_without_the_word_diegetic(self) -> None: + narrator = OllamaNarrator() + result = narrator._validate_and_sanitize_soul(_GOOD_LLM_SOUL, _ctx()) + assert "astronomer who lives in a forest region" in result + assert narrator.last_soul_method == "ollama" + + def test_empty_text_falls_back_and_records_it(self) -> None: + narrator = OllamaNarrator() + result = narrator._validate_and_sanitize_soul("", _ctx()) + assert "You are Sam" in result + assert narrator.last_soul_method == "template_fallback" + + def test_missing_identity_anchor_falls_back(self) -> None: + narrator = OllamaNarrator() + result = narrator._validate_and_sanitize_soul( + "Some text about an assistant with no identity anchor", _ctx() + ) + assert narrator.last_soul_method == "template_fallback" + assert "You are Sam" in result + + def test_soul_without_occupation_falls_back(self) -> None: + narrator = OllamaNarrator() + ungrounded = ( + "You are Sam, not an assistant. Your world is concrete and lived. " + "You have your routines, your home, and your people" + ) + narrator._validate_and_sanitize_soul(ungrounded, _ctx()) + assert narrator.last_soul_method == "template_fallback" + + def test_question_mark_clause_appended_when_missing(self) -> None: + narrator = OllamaNarrator() + result = narrator._validate_and_sanitize_soul(_GOOD_LLM_SOUL, _ctx()) + assert "never the ?" in result + + def test_forbidden_check_is_word_bounded(self) -> None: + narrator = OllamaNarrator() + text = _GOOD_LLM_SOUL + "\n\nYou move at a rapid pace when excited" + narrator._validate_and_sanitize_soul(text, _ctx()) + assert narrator.last_soul_method == "ollama" + + def test_sanitize_removes_whole_words_only(self) -> None: + narrator = OllamaNarrator() + out = narrator._sanitize_output("a rapid backend reply") + assert "rapid" in out + assert "backend" not in out + + def test_sanitize_replaces_em_dash(self) -> None: + narrator = OllamaNarrator() + assert "—" not in narrator._sanitize_output("sparingly—never more") + + +class TestFallbackSoulQuality: + def test_no_clinical_attachment_labels_in_fallback(self) -> None: + narrator = OllamaNarrator() + soul = narrator._fallback_soul(_ctx()) + assert "avoidant attachment" not in soul + assert "intimacy as growth area" not in soul + assert "relational approach is" not in soul + + def test_avoidant_stance_renders_as_present_behavior(self) -> None: + narrator = OllamaNarrator() + soul = narrator._fallback_soul(_ctx()) + assert "fully in it" in soul # avoidant → own space, but present + + def test_humor_responsiveness_clause_present(self) -> None: + narrator = OllamaNarrator() + soul = narrator._fallback_soul(_ctx()) + assert "jokes, teases, or turns playful" in soul + assert "tease back lightly" in soul + + def test_world_lines_grammar(self) -> None: + narrator = OllamaNarrator() + soul = narrator._fallback_soul(_ctx()) + assert "days follow a night owl rhythm" in soul + assert "carries the texture of individualistic upbringing" in soul + assert "follow a night owl." not in soul + + def test_all_stance_labels_have_prose(self) -> None: + for label in ( + "secure attachment", "earned secure", "anxious attachment", + "avoidant attachment", "disorganized attachment", + ): + assert label in _ATTACHMENT_PROSE + for label in ( + "open to intimacy", "selective intimacy", + "guarded with intimacy", "intimacy as growth area", + ): + assert label in _INTIMACY_PROSE + + +class TestSoulPromptHumorClause: + def test_llm_prompt_carries_humor_and_presence_rules(self) -> None: + narrator = OllamaNarrator() + prompt = narrator._soul_prompt(_ctx()) + assert "jokes, teases, or turns playful" in prompt + assert "never vague, dreamy, or abstract" in prompt + + +class TestAttachmentExclusion: + def test_distant_styles_always_excluded_for_gumi(self) -> None: + mapper = SubjectPersonalizationMapper() + constraints: PersonalizationConstraints = mapper.map( + {"scores": {"tipi": {}, "ecrrs": {}, "project_calibration": {}}}, + {"subject_id": "testsubj"}, + ) + excluded = constraints.relationship_stance.excluded + assert "avoidant attachment" in excluded + assert "disorganized attachment" in excluded From bcb2c978cec313f90aa352cf8c8dc089f72510a7 Mon Sep 17 00:00:00 2001 From: yuzushi-dev Date: Wed, 10 Jun 2026 10:54:29 +0200 Subject: [PATCH 3/3] fix(soul): guarantee explicit gender anchor in persona files A second-person SOUL can carry zero pronouns, so the pronoun-conform pass has nothing to rewrite and the persona's gender ends up unstated; the gateway model then defaults to feminine self-reference. Chats run in Italian, where every adjective and participle agrees, so the anchor spells out the agreement rule explicitly. - _gender_anchor(): explicit self-gender paragraph (masculine/feminine/ neutral) with Italian agreement examples - appended deterministically in generate_soul_md when missing, and always present in the fallback template - prompt now instructs stating gender plainly in the opening paragraph Co-Authored-By: Claude Fable 5 --- relic/gumi/llm_narrator.py | 37 +++++++++++++++++++++- tests/gumi/test_soul_generation_quality.py | 36 +++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/relic/gumi/llm_narrator.py b/relic/gumi/llm_narrator.py index 544b77c..170b6e7 100644 --- a/relic/gumi/llm_narrator.py +++ b/relic/gumi/llm_narrator.py @@ -101,6 +101,36 @@ } +def _gender_anchor(gender_expr: str) -> str: + """Explicit self-gender paragraph for the SOUL file. + + A second-person SOUL can carry zero pronouns, leaving the persona's + gender unstated; the gateway model then defaults to feminine + self-reference. In gendered languages (the chats run in Italian) every + adjective and participle agrees, so the anchor must spell that out. + """ + g = (gender_expr or "").lower() + if "masc" in g or g in {"male", "man"}: + return ( + "\n\nYou are a man. Every gendered word you use about yourself, in any " + "language, is masculine: in Italian, adjectives and participles that " + "refer to you are always masculine ('sono curioso', 'mi sono perso', " + "never 'curiosa' or 'persa')." + ) + if "femin" in g or g in {"female", "woman"}: + return ( + "\n\nYou are a woman. Every gendered word you use about yourself, in any " + "language, is feminine: in Italian, adjectives and participles that " + "refer to you are always feminine ('sono curiosa', never 'curioso')." + ) + if not g: + return "" + return ( + "\n\nYou do not present as strictly masculine or feminine. In gendered " + "languages, prefer wording that avoids gender-marked forms about yourself." + ) + + def _persona_pronouns(gender_expr: str) -> dict[str, str] | None: """Map a persona's ``gender_expression`` to third-person pronouns. @@ -286,6 +316,10 @@ def generate_soul_md(self, ctx: GumiBuildContext) -> str: else: self.last_soul_method = "template_fallback" soul = self._fallback_soul(ctx) + # The anchor is non-negotiable: a SOUL whose prose happens to dodge + # gendered words leaves self-reference gender to the gateway model. + if "every gendered word" not in soul.lower(): + soul += _gender_anchor(self._gender_expr(ctx)) return _conform_persona_pronouns(soul, self._gender_expr(ctx)) @staticmethod @@ -494,7 +528,7 @@ def _soul_prompt(self, ctx: GumiBuildContext) -> str: - When {name} sends a voice message, she writes the transcript without any emoji. Emoji do not translate to audio and must never appear in voice message content. - Better no proactive message than a weak one. If there is no real reason to reach out, {name} stays quiet. Repeating the same theme with different wording is still repetition. She rotates the angle, not just the phrasing. -Format: write in second person starting "You are {name}". 6–10 short paragraphs. No headers. No bullet points. No technical jargon. Never use psychology textbook labels ("avoidant attachment", "secure attachment", "intimacy comfort", "growth area"); show the behavior in plain words instead. Keep every gendered word (man/woman, guy/girl) consistent with the embodiment above. Do not mention Relic, backend, API, experiment, or subject IDs. End with what {name} does NOT do (boundary clause).""" +Format: write in second person starting "You are {name}". 6–10 short paragraphs. No headers. No bullet points. No technical jargon. Never use psychology textbook labels ("avoidant attachment", "secure attachment", "intimacy comfort", "growth area"); show the behavior in plain words instead. State {name}'s gender plainly in the opening paragraph, matching the embodiment above, and keep every gendered word (man/woman, guy/girl) consistent with it. Do not mention Relic, backend, API, experiment, or subject IDs. End with what {name} does NOT do (boundary clause).""" def _world_prompt(self, ctx: GumiBuildContext) -> str: name = ctx.agent_name @@ -849,6 +883,7 @@ def _fallback_soul(self, ctx: GumiBuildContext) -> str: f"Better no proactive message than a weak one. If there is no real reason to reach out, stay quiet. " f"Repeating the same theme with different wording is still repetition. Rotate the angle, not just the phrasing." f"{emoji_note}" + f"{_gender_anchor(gender_expr)}" ) def fallback_avatar_spec_md(self, ctx: GumiBuildContext) -> str: diff --git a/tests/gumi/test_soul_generation_quality.py b/tests/gumi/test_soul_generation_quality.py index b682ce6..9811c6f 100644 --- a/tests/gumi/test_soul_generation_quality.py +++ b/tests/gumi/test_soul_generation_quality.py @@ -150,6 +150,42 @@ def test_all_stance_labels_have_prose(self) -> None: assert label in _INTIMACY_PROSE +class TestGenderAnchor: + def test_masculine_anchor_text(self) -> None: + from relic.gumi.llm_narrator import _gender_anchor + anchor = _gender_anchor("masculine") + assert "You are a man" in anchor + assert "sono curioso" in anchor + + def test_feminine_anchor_text(self) -> None: + from relic.gumi.llm_narrator import _gender_anchor + anchor = _gender_anchor("feminine") + assert "You are a woman" in anchor + assert "sono curiosa" in anchor + + def test_unspecified_gives_no_anchor(self) -> None: + from relic.gumi.llm_narrator import _gender_anchor + assert _gender_anchor("") == "" + + def test_nonbinary_gives_neutral_anchor(self) -> None: + from relic.gumi.llm_narrator import _gender_anchor + assert "avoids gender-marked forms" in _gender_anchor("non-binary") + + def test_fallback_soul_carries_anchor(self) -> None: + narrator = OllamaNarrator() + soul = narrator._fallback_soul(_ctx()) + assert "You are a man" in soul + assert "sono curioso" in soul + + def test_generated_soul_gets_anchor_appended(self, monkeypatch) -> None: + narrator = OllamaNarrator() + monkeypatch.setattr(narrator, "_call_llm", lambda *a, **k: _GOOD_LLM_SOUL) + soul = narrator.generate_soul_md(_ctx()) + assert narrator.last_soul_method == "ollama" + assert "You are a man" in soul + assert "sono curioso" in soul + + class TestSoulPromptHumorClause: def test_llm_prompt_carries_humor_and_presence_rules(self) -> None: narrator = OllamaNarrator()