oneblackmage/session-scripts / session_base_multi.py
oneblackmage's picture
download
raw
15.5 kB
#!/usr/bin/env python3
"""
Shared infrastructure for multi-patient therapy session generation.
Extends session_base.py with support for:
- Multiple patients in a single session (group therapy, couples/family)
- Name-tagged ChatML messages: {"role": "user", "name": "partner_a", "content": "..."}
- Custom turn-taking patterns (round-robin, therapist-picks, naturalistic)
Each multi-patient script imports from this module.
"""
import json
import os
import random
import re
import sys
import time
import argparse
from pathlib import Path
from session_base import (
OLLAMA_BASE,
THERAPIST_MODEL,
PATIENT_MODEL,
THERAPIST_TEMP,
PATIENT_TEMP,
MAX_RETRIES,
RETRY_DELAY,
STYLE_MAX_RETRIES,
FORBIDDEN_OUTPUT_OPENINGS,
PLATITUDE_PATTERNS,
ROBOTIC_SIGNALS,
SYCOPHANCY_MARKERS,
THERAPIST_STYLE_PROFILES,
PIXEL_SYSTEM_BASE,
PATIENT_SYSTEM,
ollama_chat,
_check_style,
run_generation,
)
MULTI_PATIENT_SYSTEM = (
"You are a method actor playing a therapy participant in a simulated session. "
"You are ONE specific person in a multi-person session (group therapy or couples/family therapy). "
"Generate realistic, emotionally authentic dialogue for YOUR character only. "
"Speak only as your character — no narration, no stage directions, no labels. "
"Keep responses concise (2-5 sentences) and emotionally genuine. "
"React to what the therapist AND other participants say. "
"Do NOT start with 'This sounds crazy but' or similar phrases. "
"Vary your openings — start mid-thought, with a question, or with a specific moment."
)
def _strip_name_for_ollama(messages):
"""Strip 'name' field and merge consecutive same-role messages for Ollama compat."""
result = []
for msg in messages:
clean = {"role": msg["role"], "content": msg["content"]}
if result and result[-1]["role"] == clean["role"]:
# Merge consecutive same-role messages
name_prefix = msg.get("name", "")
if name_prefix:
result[-1]["content"] += f"\n\n[{name_prefix}]: {clean['content']}"
else:
result[-1]["content"] += f"\n\n{clean['content']}"
else:
if msg.get("name"):
clean["content"] = f"[{msg['name']}]: {clean['content']}"
result.append(clean)
return result
def generate_multi_patient_turn(
persona,
patient_name,
presentation,
category_name,
conversation,
turn_num,
total_patient_turns,
all_personas,
):
"""Generate a single patient's response in a multi-patient session."""
if turn_num == 1:
direction = f"The patient is arriving for a session. Their presenting concern: {presentation}. They're nervous but willing to talk."
elif turn_num <= 3:
direction = (
"The patient is opening up, sharing more details. Starting to trust the therapist and other participants."
)
elif turn_num <= 5:
direction = "The patient is going deeper — revealing the emotional impact, not just the surface problem. Becoming more vulnerable."
elif turn_num == total_patient_turns:
direction = "Final turn. The patient is reflecting on what they've discussed, maybe feeling a shift or maybe just sitting with it."
else:
direction = "The patient is processing what the therapist and other participants said. May push back, have a realization, or share something they held back."
conv_text = ""
for msg in conversation:
if msg["role"] == "assistant":
role = "Therapist"
elif msg.get("name"):
role = msg["name"]
else:
role = "Patient"
conv_text += f"{role}: {msg['content']}\n"
other_participants = [p for p in all_personas if p["name"] != patient_name]
others_desc = "\n".join(
f"- {p['name']} (age {p['age']}, {p['gender']}, {p['occupation']})" for p in other_participants
)
prompt = f"""You are playing {patient_name} in a multi-person therapy session. Stay completely in character.
YOUR CHARACTER:
Name: {patient_name}
Age: {persona["age"]}, Gender: {persona["gender"]}, Occupation: {persona["occupation"]}
Presenting concern: {persona["presenting"]}
OTHER PARTICIPANTS IN THE ROOM:
{others_desc}
SESSION FOCUS: {category_name}{presentation}
DIRECTION FOR THIS TURN:
{direction}
This is your turn {turn_num} of {total_patient_turns}.
CONVERSATION SO FAR:
{conv_text if conv_text else "(First turn — session is just starting.)"}
What does {patient_name} say next? Generate ONLY {patient_name}'s spoken words — no labels, no narration, no other participant's dialogue. 2-5 sentences."""
messages = [
{"role": "system", "content": MULTI_PATIENT_SYSTEM},
{"role": "user", "content": prompt},
]
return ollama_chat(messages, model=PATIENT_MODEL, temperature=PATIENT_TEMP, num_predict=250)
def generate_therapist_turn_multi(
category,
conversation,
turn_num,
style_profile,
all_patient_names,
):
"""Generate therapist response in a multi-patient session."""
technique = random.choice(category["therapist_techniques"])
technique_guidance = (
f"\n\n[INTERNAL CLINICAL GUIDANCE — embody, never state explicitly]: "
f"Use this technique naturally: {technique}. "
f"Weave it into the conversation — don't announce it. "
f"Respond to what the participants actually said, don't pivot to a technique if it doesn't fit."
)
style_guidance = (
f"\n\nSTYLE: {style_profile['description']}\n"
f"NEVER start with: {', '.join(style_profile['forbidden_openings'])}\n"
f"Good examples: {'; '.join(style_profile['good_examples'][:3])}\n"
f"MAX {style_profile['max_sentences']} sentences, {style_profile['max_words']} words."
)
addon = f"\n\n{category['therapist_prompt_addon']}"
participants_desc = f"Participants in this session: {', '.join(all_patient_names)}."
system_content = PIXEL_SYSTEM_BASE + technique_guidance + style_guidance + addon + f"\n\n{participants_desc}"
# Strip 'name' field — Ollama doesn't support it
clean_conversation = _strip_name_for_ollama(conversation)
messages = [{"role": "system", "content": system_content}, *clean_conversation]
return ollama_chat(messages, model=THERAPIST_MODEL, temperature=THERAPIST_TEMP, num_predict=400)
def generate_therapist_turn_multi_validated(
category,
conversation,
turn_num,
style_profile,
all_patient_names,
):
"""Generate therapist turn with style validation + retry."""
for attempt in range(STYLE_MAX_RETRIES):
output = generate_therapist_turn_multi(category, conversation, turn_num, style_profile, all_patient_names)
passed, reason = _check_style(output, style_profile)
if passed:
return output
print(f" [style retry {attempt + 1}/{STYLE_MAX_RETRIES}] {reason}")
return output
def generate_multi_patient_session(
category_key,
category,
personas,
presentation,
session_idx,
session_id_prefix,
min_turns=16,
max_turns=24,
turn_pattern="naturalistic",
):
"""Generate a complete multi-patient therapy session.
Args:
personas: list of persona dicts, each with "name" key
turn_pattern: "round_robin", "therapist_picks", or "naturalistic"
"""
total_turns = random.randint(min_turns // 2, max_turns // 2) * 2
total_therapist_turns = total_turns // 2
style_keys = list(THERAPIST_STYLE_PROFILES.keys())
style_profile = THERAPIST_STYLE_PROFILES[style_keys[session_idx % len(style_keys)]]
patient_names = [p["name"] for p in personas]
conversation = []
# Track how many turns each patient has had
patient_turn_counts = {name: 0 for name in patient_names}
total_patient_turns_target = total_therapist_turns # each therapist turn followed by 1-2 patient turns
# First patient opens the session, then therapist responds (matching single-patient pattern)
first_persona = personas[0]
patient_turn_counts[first_persona["name"]] += 1
first_patient_msg = generate_multi_patient_turn(
first_persona,
first_persona["name"],
presentation,
category["name"],
conversation,
1,
total_patient_turns_target,
personas,
)
conversation.append({"role": "user", "name": first_persona["name"], "content": first_patient_msg})
for round_num in range(1, total_therapist_turns + 1):
# Therapist turn
therapist_msg = generate_therapist_turn_multi_validated(
category, conversation, round_num, style_profile, patient_names
)
conversation.append({"role": "assistant", "content": therapist_msg})
# Determine which patient(s) speak after this therapist turn
if turn_pattern == "round_robin":
# Each round, the next patient in rotation speaks
patient_idx = (round_num - 1) % len(personas)
selected_patients = [personas[patient_idx]]
elif turn_pattern == "therapist_picks":
# Simulate therapist directing to a specific patient
patient_idx = (round_num - 1) % len(personas)
selected_patients = [personas[patient_idx]]
else: # naturalistic
# 1-2 patients respond, weighted by who's spoken least
n_responders = random.choice([1, 1, 1, 2]) # mostly 1, sometimes 2
sorted_by_turns = sorted(personas, key=lambda p: patient_turn_counts[p["name"]])
selected_patients = sorted_by_turns[:n_responders]
for persona in selected_patients:
patient_turn_counts[persona["name"]] += 1
patient_turn_num = patient_turn_counts[persona["name"]]
patient_msg = generate_multi_patient_turn(
persona,
persona["name"],
presentation,
category["name"],
conversation,
patient_turn_num,
total_patient_turns_target,
personas,
)
conversation.append({"role": "user", "name": persona["name"], "content": patient_msg})
session_id = f"{session_id_prefix}_{category_key}_{session_idx:04d}"
return {
"messages": [
{"role": "system", "content": PIXEL_SYSTEM_BASE},
*conversation,
],
"metadata": {
"source_family": category.get("source_family", session_id_prefix),
"category": category_key,
"category_name": category["name"],
"presentation": presentation,
"session_id": session_id,
"participants": [
{"name": p["name"], "age": p["age"], "gender": p["gender"], "occupation": p["occupation"]}
for p in personas
],
"n_participants": len(personas),
"style_profile": style_profile["description"][:50],
"turns": len(conversation),
"difficulty": category["difficulty"],
"turn_pattern": turn_pattern,
},
}
def session_exists(output_file, session_id):
if not output_file.exists():
return False
with open(output_file) as f:
for line in f:
if session_id in line:
return True
return False
def run_multi_generation(
categories_dict,
output_dir,
output_filename,
session_id_prefix,
source_family,
default_sessions_per_category=50,
description="Multi-Patient Therapy Session Generation",
min_turns=16,
max_turns=24,
turn_pattern="naturalistic",
):
"""Main generation loop for multi-patient sessions."""
parser = argparse.ArgumentParser(description=description)
parser.add_argument("--categories", default="all", help="Comma-separated category keys or 'all'")
parser.add_argument("--sessions-per-category", type=int, default=default_sessions_per_category)
parser.add_argument("--resume", action="store_true")
parser.add_argument("--spot-check", type=int, default=None, help="Generate N sessions from first category only")
args = parser.parse_args()
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_file = output_dir / output_filename
if args.categories == "all":
cats = list(categories_dict.keys())
else:
cats = [c.strip() for c in args.categories.split(",")]
if args.spot_check:
cats = cats[:1]
total_sessions = args.spot_check
else:
total_sessions = len(cats) * args.sessions_per_category
print(f"\n=== {description.upper()} ===")
print(f"Categories: {len(cats)} ({', '.join(cats)})")
print(f"Sessions per category: {args.spot_check or args.sessions_per_category}")
print(f"Total sessions: {total_sessions}")
print(f"Output: {output_file}")
print(f"Therapist: {THERAPIST_MODEL}")
print(f"Patient: {PATIENT_MODEL}")
print(f"Turns: {min_turns}-{max_turns} (multi-patient)")
print(f"Turn pattern: {turn_pattern}")
print()
completed = 0
skipped = 0
failed = 0
start_time = time.time()
for cat_key in cats:
category = categories_dict[cat_key]
n_sessions = args.spot_check or args.sessions_per_category
category["source_family"] = source_family
print(f"\n--- {category['name']} ({cat_key}) ---")
print(f" {len(category['presentations'])} presentations × {len(category['persona_sets'])} persona sets")
for i in range(n_sessions):
presentation = category["presentations"][i % len(category["presentations"])]
persona_set = category["persona_sets"][i % len(category["persona_sets"])]
session_id = f"{session_id_prefix}_{cat_key}_{i:04d}"
if args.resume and session_exists(output_file, session_id):
skipped += 1
continue
try:
session = generate_multi_patient_session(
cat_key,
category,
persona_set,
presentation,
i,
session_id_prefix,
min_turns,
max_turns,
turn_pattern,
)
with open(output_file, "a") as f:
f.write(json.dumps(session) + "\n")
completed += 1
elapsed = time.time() - start_time
rate = completed / (elapsed / 3600) if elapsed > 0 else 0
remaining = (total_sessions - completed - skipped) / rate if rate > 0 else 0
print(
f" ✓ {session_id} {len(session['messages'])} msgs, {len(persona_set)} patients | done: {completed}/{total_sessions} | ~{remaining:.1f}h left"
)
except Exception as e:
failed += 1
print(f" ✗ {session_id} FAILED: {e}")
with open(output_dir / "errors.log", "a") as f:
f.write(f"{session_id}: {e}\n")
elapsed = time.time() - start_time
print(f"\n=== COMPLETE ===")
print(f"Generated: {completed}")
print(f"Skipped: {skipped}")
print(f"Failed: {failed}")
print(f"Elapsed: {elapsed / 3600:.1f}h")
print(f"Output: {output_file}")

Xet Storage Details

Size:
15.5 kB
·
Xet hash:
2df0b9daba5a4871c5713c52988b50330838d78c7afbda59988e4be15c3dcf20

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.