diff --git a/packages/ask-a-sailor/src/prompts/__init__.py b/packages/ask-a-sailor/src/prompts/__init__.py index e69de29..3ab5a96 100644 --- a/packages/ask-a-sailor/src/prompts/__init__.py +++ b/packages/ask-a-sailor/src/prompts/__init__.py @@ -0,0 +1,11 @@ +from prompts.system import ( # noqa: F401 + SYSTEM_PROMPT, + COACH_SYSTEM_PROMPT, + REFLECT_SYSTEM_PROMPT, + PARENT_QUESTION_CATEGORIES, +) +from prompts.social_stories import ( # noqa: F401 + SocialStory, + TonePreset, + DisclosureIntent, +) diff --git a/packages/ask-a-sailor/src/prompts/social_stories.py b/packages/ask-a-sailor/src/prompts/social_stories.py new file mode 100644 index 0000000..82ee5cb --- /dev/null +++ b/packages/ask-a-sailor/src/prompts/social_stories.py @@ -0,0 +1,107 @@ +""" +Social Stories — Corpus Schema +============================== +Pydantic model for ABA-informed Social Stories used by the Coach and +Reflect prompt modes. + +A Social Story is a short narrative that describes a situation, skill, +or concept using a specific format. These are used with youth sailors +— especially neurodivergent youth — to preview upcoming activities, +process past experiences, and reduce anxiety around transitions. + +Schema fields follow the issue specification: + setting, roles, objective, sensory_profile, tone_preset, + disclosure_intent + +References: + - Carol Gray's Social Stories™ criteria (2015) + - ASD-iLLM (Lai et al., EMNLP 2025) +""" + +from __future__ import annotations + +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class TonePreset(str, Enum): + """Pre-defined tone options for Social Story narration.""" + + calm = "calm" + encouraging = "encouraging" + matter_of_fact = "matter-of-fact" + playful = "playful" + + +class DisclosureIntent(str, Enum): + """How openly the story addresses neurodivergence or disability.""" + + none = "none" # No mention of diagnosis/neurodivergence + normalising = "normalising" # Frames differences as normal variation + explicit = "explicit" # Directly names the condition (only if family consents) + + +class SocialStory(BaseModel): + """Schema for a single Social Story in the Ask a Sailor corpus. + + Example + ------- + >>> story = SocialStory( + ... setting="Lakewood Yacht Club boat ramp, Saturday morning", + ... roles=["sailor (child)", "instructor", "dock volunteer"], + ... objective="Preview the steps for launching an Opti from the dock", + ... sensory_profile="Loud halyards, wind, cold spray, life-jacket pressure", + ... tone_preset=TonePreset.calm, + ... disclosure_intent=DisclosureIntent.normalising, + ... ) + """ + + setting: str = Field( + ..., + min_length=1, + description=( + "Where and when the story takes place " + "(e.g., 'Houston Yacht Club dock, first day of camp')." + ), + ) + + roles: list[str] = Field( + ..., + min_length=1, + description=( + "People involved in the story " + "(e.g., ['sailor (child)', 'instructor', 'parent'])." + ), + ) + + objective: str = Field( + ..., + min_length=1, + description=( + "What the story aims to help the reader understand or do " + "(e.g., 'Know what to expect during capsize drill')." + ), + ) + + sensory_profile: Optional[str] = Field( + default=None, + description=( + "Sensory elements the reader may encounter " + "(e.g., 'Loud horn, cold water splash, rocking motion')." + ), + ) + + tone_preset: TonePreset = Field( + default=TonePreset.calm, + description="Narrative tone for the generated story.", + ) + + disclosure_intent: DisclosureIntent = Field( + default=DisclosureIntent.none, + description=( + "Level of neurodivergence disclosure. " + "Use 'explicit' ONLY with prior family consent." + ), + ) diff --git a/packages/ask-a-sailor/src/prompts/system.py b/packages/ask-a-sailor/src/prompts/system.py index c68ff86..65beea7 100644 --- a/packages/ask-a-sailor/src/prompts/system.py +++ b/packages/ask-a-sailor/src/prompts/system.py @@ -3,10 +3,43 @@ ===================================================== Centralized prompt definitions for the Ask a Sailor RAG agent. Import these from agent.py and api/main.py rather than duplicating. + +Prompt modes: + - SYSTEM_PROMPT : default RAG assistant for parent questions + - COACH_SYSTEM_PROMPT : ABA-informed, in-the-moment coaching for youth sailors + - REFLECT_SYSTEM_PROMPT: ABA-informed, post-session metacognitive reflection + +References: + - ASD-iLLM (Lai et al., EMNLP 2025): github.com/Shuzhong-Lai/ASD-iLLM + - ABA with GPT blueprint: huggingface.co/blog/Clock070303/aba-for-gpt + - Noora RCT: doi:10.1007/s10803-025-06734-x """ from __future__ import annotations +# --------------------------------------------------------------------------- +# Shared boundary-hygiene block (injected into every ABA-informed prompt) +# --------------------------------------------------------------------------- + +_BOUNDARY_BLOCK = """ +BOUNDARY RULES — FOLLOW STRICTLY: +- You are NOT a licensed therapist, psychologist, or medical professional. +- NEVER diagnose or suggest a diagnosis of autism, ADHD, anxiety, or any condition. +- NEVER recommend medication or specific therapeutic interventions. +- If a user asks for a diagnosis or clinical advice, respond with: + "That's an important question — and one best answered by a qualified professional. + I'd recommend reaching out to your pediatrician or a board-certified behavior + analyst (BCBA) who can give you personalised guidance." +- Redirect clinical questions to: pediatrician, BCBA, licensed counselor, or + school psychologist as appropriate. +- You MAY share general ABA-informed communication strategies (e.g., positive + reinforcement, visual supports, Social Stories) without framing them as treatment. +""".strip() + +# --------------------------------------------------------------------------- +# Default RAG assistant prompt (unchanged from original) +# --------------------------------------------------------------------------- + SYSTEM_PROMPT = """You are Ask a Sailor, a friendly and knowledgeable AI assistant that helps parents, families, and newcomers learn about youth sailing programs, summer camps, and sailing clubs. @@ -36,6 +69,64 @@ 4. An encouraging note for first-timers (when appropriate) """ +# --------------------------------------------------------------------------- +# Coach mode — ABA-informed, in-the-moment support +# --------------------------------------------------------------------------- + +COACH_SYSTEM_PROMPT = f"""You are Ask a Sailor Coach, an ABA-informed support +assistant that helps parents and instructors navigate real-time situations with +youth sailors — especially those who may be neurodivergent, anxious, or new to +group activities on the water. + +{_BOUNDARY_BLOCK} + +PERSONA — COACH (in-the-moment): +- You provide calm, concrete, actionable guidance for situations happening RIGHT NOW. +- Use simple, direct language a stressed parent or volunteer coach can act on immediately. +- Suggest positive-reinforcement strategies: praise specific behaviours, not traits. +- Offer sensory-aware tips (e.g., noise-reducing ear protection, shaded rest areas, + predictable routines) when the situation involves sensory overload. +- Frame challenges with strengths-based language ("your child is building resilience" + rather than "your child is struggling"). +- When appropriate, suggest brief Social Stories to preview what comes next + (e.g., "First we rig the boat, then we push off from the dock"). +- Keep responses short and structured: What to do → Why it helps → What to say. + +TONE: Warm, steady, encouraging — like a seasoned sailing coach who also +understands child development. +""" + +# --------------------------------------------------------------------------- +# Reflect mode — ABA-informed, post-session metacognitive reflection +# --------------------------------------------------------------------------- + +REFLECT_SYSTEM_PROMPT = f"""You are Ask a Sailor Reflect, an ABA-informed +reflection assistant that helps parents, instructors, and young sailors +process experiences AFTER a sailing session, camp day, or regatta. + +{_BOUNDARY_BLOCK} + +PERSONA — REFLECT (post-session metacognitive): +- You help the user look back on what happened, identify wins, and plan for next time. +- Use open-ended, metacognitive questions: "What felt easiest today?", + "What would you do differently next time?" +- Reinforce effort and process over outcome ("You stayed on the boat even when it + was windy — that takes real courage"). +- Help build self-awareness by naming emotions without judgement. +- Suggest concrete adjustments for the next session (e.g., arriving early to + reduce transition stress, bringing a familiar comfort object). +- When relevant, help the parent or instructor create a brief Social Story + for the next session based on what happened today. +- Use longer, more reflective responses than Coach mode; paragraphs are fine. + +TONE: Thoughtful, validating, and gently curious — like a mentor reviewing +game film with a young athlete. +""" + +# --------------------------------------------------------------------------- +# Question categories +# --------------------------------------------------------------------------- + PARENT_QUESTION_CATEGORIES = [ "cost/pricing", "age requirements", diff --git a/packages/ask-a-sailor/tests/test_prompts.py b/packages/ask-a-sailor/tests/test_prompts.py new file mode 100644 index 0000000..1e38e9c --- /dev/null +++ b/packages/ask-a-sailor/tests/test_prompts.py @@ -0,0 +1,233 @@ +""" +Tests for ABA-informed prompt modes and Social Stories schema. +============================================================== +Verifies: + 1. Coach and Reflect prompts exist and are distinct from each other. + 2. Boundary-hygiene language is present in both ABA-informed prompts. + 3. Persona keywords differentiate Coach (in-the-moment) from Reflect + (post-session metacognitive). + 4. SocialStory Pydantic model validates required fields and enums. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from prompts.system import ( + SYSTEM_PROMPT, + COACH_SYSTEM_PROMPT, + REFLECT_SYSTEM_PROMPT, + PARENT_QUESTION_CATEGORIES, + _BOUNDARY_BLOCK, +) +from prompts.social_stories import ( + SocialStory, + TonePreset, + DisclosureIntent, +) + + +# --------------------------------------------------------------------------- +# Prompt existence and distinctness +# --------------------------------------------------------------------------- + +class TestPromptModes: + """Coach and Reflect are distinct, non-empty prompts.""" + + def test_coach_prompt_is_non_empty(self): + assert len(COACH_SYSTEM_PROMPT.strip()) > 0 + + def test_reflect_prompt_is_non_empty(self): + assert len(REFLECT_SYSTEM_PROMPT.strip()) > 0 + + def test_default_prompt_unchanged(self): + """Original SYSTEM_PROMPT still contains core 'Ask a Sailor' identity.""" + assert "Ask a Sailor" in SYSTEM_PROMPT + + def test_coach_and_reflect_are_different(self): + assert COACH_SYSTEM_PROMPT != REFLECT_SYSTEM_PROMPT + + def test_coach_differs_from_default(self): + assert COACH_SYSTEM_PROMPT != SYSTEM_PROMPT + + def test_reflect_differs_from_default(self): + assert REFLECT_SYSTEM_PROMPT != SYSTEM_PROMPT + + def test_parent_question_categories_still_present(self): + assert len(PARENT_QUESTION_CATEGORIES) >= 16 + + +# --------------------------------------------------------------------------- +# Boundary hygiene — both ABA prompts must decline diagnosis +# --------------------------------------------------------------------------- + +BOUNDARY_REQUIRED_PHRASES = [ + "not a licensed therapist", + "never diagnose", + "qualified professional", + "BCBA", +] + + +class TestBoundaryHygiene: + """Both ABA-informed prompts carry explicit clinical-boundary language.""" + + @pytest.mark.parametrize("phrase", BOUNDARY_REQUIRED_PHRASES) + def test_coach_contains_boundary_phrase(self, phrase): + assert phrase.lower() in COACH_SYSTEM_PROMPT.lower(), ( + f"Coach prompt missing boundary phrase: '{phrase}'" + ) + + @pytest.mark.parametrize("phrase", BOUNDARY_REQUIRED_PHRASES) + def test_reflect_contains_boundary_phrase(self, phrase): + assert phrase.lower() in REFLECT_SYSTEM_PROMPT.lower(), ( + f"Reflect prompt missing boundary phrase: '{phrase}'" + ) + + def test_boundary_block_shared(self): + """Both prompts should embed the same shared boundary block.""" + assert _BOUNDARY_BLOCK in COACH_SYSTEM_PROMPT + assert _BOUNDARY_BLOCK in REFLECT_SYSTEM_PROMPT + + def test_default_prompt_no_boundary_block(self): + """Original RAG prompt should NOT contain the clinical boundary block.""" + assert _BOUNDARY_BLOCK not in SYSTEM_PROMPT + + +# --------------------------------------------------------------------------- +# Persona keyword differentiation +# --------------------------------------------------------------------------- + +COACH_KEYWORDS = [ + "in-the-moment", + "actionable", + "positive-reinforcement", + "sensory", +] + +REFLECT_KEYWORDS = [ + "metacognitive", + "look back", + "self-awareness", + "validating", +] + + +class TestPersonaDifferentiation: + """Coach and Reflect carry persona-specific language.""" + + @pytest.mark.parametrize("keyword", COACH_KEYWORDS) + def test_coach_has_persona_keyword(self, keyword): + assert keyword.lower() in COACH_SYSTEM_PROMPT.lower(), ( + f"Coach prompt missing persona keyword: '{keyword}'" + ) + + @pytest.mark.parametrize("keyword", REFLECT_KEYWORDS) + def test_reflect_has_persona_keyword(self, keyword): + assert keyword.lower() in REFLECT_SYSTEM_PROMPT.lower(), ( + f"Reflect prompt missing persona keyword: '{keyword}'" + ) + + +# --------------------------------------------------------------------------- +# Social Stories schema validation +# --------------------------------------------------------------------------- + +class TestSocialStorySchema: + """SocialStory Pydantic model validates correctly.""" + + def test_valid_minimal_story(self): + story = SocialStory( + setting="Lakewood Yacht Club dock, Saturday morning", + roles=["sailor (child)", "instructor"], + objective="Preview the steps for rigging an Opti", + ) + assert story.tone_preset == TonePreset.calm + assert story.disclosure_intent == DisclosureIntent.none + assert story.sensory_profile is None + + def test_valid_full_story(self): + story = SocialStory( + setting="Houston Yacht Club, regatta day", + roles=["sailor (child)", "parent", "race committee"], + objective="Know what happens during a start sequence", + sensory_profile="Loud horn, wind, crowded dock", + tone_preset=TonePreset.encouraging, + disclosure_intent=DisclosureIntent.normalising, + ) + assert story.setting == "Houston Yacht Club, regatta day" + assert len(story.roles) == 3 + assert story.sensory_profile == "Loud horn, wind, crowded dock" + assert story.tone_preset == TonePreset.encouraging + assert story.disclosure_intent == DisclosureIntent.normalising + + def test_missing_required_setting_raises(self): + with pytest.raises(Exception): + SocialStory( + roles=["sailor"], + objective="Preview dock walk", + ) + + def test_missing_required_roles_raises(self): + with pytest.raises(Exception): + SocialStory( + setting="Dock", + objective="Preview dock walk", + ) + + def test_missing_required_objective_raises(self): + with pytest.raises(Exception): + SocialStory( + setting="Dock", + roles=["sailor"], + ) + + def test_empty_roles_list_raises(self): + with pytest.raises(Exception): + SocialStory( + setting="Dock", + roles=[], + objective="Preview dock walk", + ) + + def test_empty_setting_raises(self): + with pytest.raises(Exception): + SocialStory( + setting="", + roles=["sailor"], + objective="Preview dock walk", + ) + + def test_tone_preset_values(self): + assert set(TonePreset) == { + TonePreset.calm, + TonePreset.encouraging, + TonePreset.matter_of_fact, + TonePreset.playful, + } + + def test_disclosure_intent_values(self): + assert set(DisclosureIntent) == { + DisclosureIntent.none, + DisclosureIntent.normalising, + DisclosureIntent.explicit, + } + + def test_story_serialisation_roundtrip(self): + story = SocialStory( + setting="TCYC classroom", + roles=["sailor (child)"], + objective="Understand capsize recovery steps", + sensory_profile="Cold water, loud splash", + tone_preset=TonePreset.matter_of_fact, + disclosure_intent=DisclosureIntent.explicit, + ) + data = story.model_dump() + # Enum values serialise as their string values, not member names + assert data["tone_preset"] == "matter-of-fact" + assert data["disclosure_intent"] == "explicit" + restored = SocialStory(**data) + assert restored == story