From d32864de6aa543d89c3e93eb5dc63e220335e2a6 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 10:04:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Replaced=203-tier=20step=20function=20w?= =?UTF-8?q?ith=205-tier=20continuous=20interpolatio=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/chat_service.py" - "backend/tests/test_chat.py" GSD-Task: S04/T01 --- backend/chat_service.py | 89 +++++++++++++++++++++++++++-------- backend/tests/test_chat.py | 95 +++++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 22 deletions(-) diff --git a/backend/chat_service.py b/backend/chat_service.py index 13dbea9..8e8d144 100644 --- a/backend/chat_service.py +++ b/backend/chat_service.py @@ -292,42 +292,91 @@ def _build_context_block(items: list[dict[str, Any]]) -> str: def _build_personality_block(creator_name: str, profile: dict[str, Any], weight: float) -> str: """Build a personality voice injection block from a creator's personality_profile JSONB. - The ``weight`` (0.0–1.0) determines how strongly the personality should - come through. At low weights the instruction is softer ("subtly adopt"); - at high weights it is emphatic ("fully embody"). + The ``weight`` (0.0–1.0) controls progressive inclusion of personality + fields via 5 tiers of continuous interpolation: + + - < 0.2: no personality block (empty string) + - 0.2–0.39: basic tone — teaching_style, formality, energy + subtle hint + - 0.4–0.59: + descriptors, explanation_approach + adopt-voice instruction + - 0.6–0.79: + signature_phrases (count scaled by weight) + creator-voice + - 0.8–0.89: + distinctive_terms, sound_descriptions, sound_words, + self_references, pacing + fully-embody instruction + - >= 0.9: + full summary paragraph """ + if weight < 0.2: + return "" + vocab = profile.get("vocabulary", {}) tone = profile.get("tone", {}) style = profile.get("style_markers", {}) - phrases = vocab.get("signature_phrases", []) - descriptors = tone.get("descriptors", []) teaching_style = tone.get("teaching_style", "") energy = tone.get("energy", "moderate") formality = tone.get("formality", "conversational") + descriptors = tone.get("descriptors", []) + phrases = vocab.get("signature_phrases", []) parts: list[str] = [] - # Intensity qualifier - if weight >= 0.8: - parts.append(f"Fully embody {creator_name}'s voice and style.") - elif weight >= 0.4: - parts.append(f"Respond in {creator_name}'s voice.") + # --- Tier 1 (0.2–0.39): basic tone --- + if weight < 0.4: + parts.append( + f"When relevant, subtly reference {creator_name}'s communication style." + ) + elif weight < 0.6: + parts.append(f"Adopt {creator_name}'s tone and communication style.") + elif weight < 0.8: + parts.append( + f"Respond as {creator_name} would, using their voice and manner." + ) else: - parts.append(f"Subtly adopt {creator_name}'s communication style.") + parts.append( + f"Fully embody {creator_name} — use their exact phrases, energy, and teaching approach." + ) if teaching_style: parts.append(f"Teaching style: {teaching_style}.") - if descriptors: - parts.append(f"Tone: {', '.join(descriptors[:5])}.") - if phrases: - parts.append(f"Use their signature phrases: {', '.join(phrases[:6])}.") parts.append(f"Match their {formality} {energy} tone.") - # Style markers - if style.get("uses_analogies"): - parts.append("Use analogies when helpful.") - if style.get("audience_engagement"): - parts.append(f"Audience engagement: {style['audience_engagement']}.") + # --- Tier 2 (0.4+): descriptors, explanation_approach, uses_analogies, audience_engagement --- + if weight >= 0.4: + if descriptors: + parts.append(f"Tone: {', '.join(descriptors[:5])}.") + explanation = style.get("explanation_approach", "") + if explanation: + parts.append(f"Explanation approach: {explanation}.") + if style.get("uses_analogies"): + parts.append("Use analogies when helpful.") + if style.get("audience_engagement"): + parts.append(f"Audience engagement: {style['audience_engagement']}.") + + # --- Tier 3 (0.6+): signature phrases (count scaled by weight) --- + if weight >= 0.6 and phrases: + count = max(2, round(weight * len(phrases))) + parts.append(f"Use their signature phrases: {', '.join(phrases[:count])}.") + + # --- Tier 4 (0.8+): distinctive_terms, sound_descriptions, sound_words, self_references, pacing --- + if weight >= 0.8: + distinctive = vocab.get("distinctive_terms", []) + if distinctive: + parts.append(f"Distinctive terms: {', '.join(distinctive)}.") + sound_desc = vocab.get("sound_descriptions", []) + if sound_desc: + parts.append(f"Sound descriptions: {', '.join(sound_desc)}.") + sound_words = style.get("sound_words", []) + if sound_words: + parts.append(f"Sound words: {', '.join(sound_words)}.") + self_refs = style.get("self_references", "") + if self_refs: + parts.append(f"Self-references: {self_refs}.") + pacing = style.get("pacing", "") + if pacing: + parts.append(f"Pacing: {pacing}.") + + # --- Tier 5 (0.9+): full summary paragraph --- + if weight >= 0.9: + summary = profile.get("summary", "") + if summary: + parts.append(summary) return " ".join(parts) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 6564655..16aeb0b 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -681,12 +681,103 @@ async def test_personality_prompt_injected_when_weight_and_profile(chat_client): assert len(captured_messages) >= 2 system_prompt = captured_messages[0]["content"] - # Personality block should be appended + # weight=0.7 → tier 3: signature phrases YES, distinctive_terms NO assert "Keota" in system_prompt - assert "let's gooo" in system_prompt + assert "Respond as Keota would" in system_prompt assert "hands-on demo-driven" in system_prompt assert "casual" in system_prompt assert "high" in system_prompt + assert "let's gooo" in system_prompt # signature phrases included at 0.6+ + assert "enthusiastic" in system_prompt # descriptors at 0.4+ + assert "example-first" in system_prompt # explanation_approach at 0.4+ + # Tier 4 fields (0.8+) should NOT be present + assert "sauce" not in system_prompt # distinctive_terms + assert "crispy" not in system_prompt # sound_descriptions + assert "brrr" not in system_prompt # sound_words + + +def test_personality_block_continuous_interpolation_tiers(): + """Progressive field inclusion across 5 interpolation tiers.""" + from chat_service import _build_personality_block + + profile = _FAKE_PERSONALITY_PROFILE + + # weight < 0.2: empty + for w in (0.0, 0.1, 0.15, 0.19): + result = _build_personality_block("Keota", profile, w) + assert result == "", f"weight={w} should produce empty block" + + # weight 0.2–0.39: basic tone only + for w in (0.2, 0.3, 0.39): + result = _build_personality_block("Keota", profile, w) + assert "subtly reference Keota" in result + assert "hands-on demo-driven" in result + assert "casual" in result and "high" in result + # Should NOT include descriptors, explanation_approach, phrases + assert "enthusiastic" not in result + assert "example-first" not in result + assert "let's gooo" not in result + + # weight 0.4–0.59: + descriptors, explanation_approach + for w in (0.4, 0.5, 0.59): + result = _build_personality_block("Keota", profile, w) + assert "Adopt Keota" in result + assert "enthusiastic" in result # descriptors + assert "example-first" in result # explanation_approach + assert "analogies" in result # uses_analogies + # Should NOT include phrases or tier-4 fields + assert "let's gooo" not in result + assert "sauce" not in result + + # weight 0.6–0.79: + signature phrases + for w in (0.6, 0.7, 0.79): + result = _build_personality_block("Keota", profile, w) + assert "Respond as Keota would" in result + assert "let's gooo" in result # signature phrases + assert "enthusiastic" in result # still has descriptors + # Should NOT include tier-4 fields + assert "sauce" not in result + assert "crispy" not in result + + # weight 0.8–0.89: + distinctive_terms, sound_descriptions, etc. + for w in (0.8, 0.85, 0.89): + result = _build_personality_block("Keota", profile, w) + assert "Fully embody Keota" in result + assert "sauce" in result # distinctive_terms + assert "crispy" in result # sound_descriptions + assert "brrr" in result # sound_words + assert "I always" in result # self_references + assert "fast" in result # pacing + # Should NOT include summary + assert "High-energy producer" not in result + + # weight >= 0.9: + summary + for w in (0.9, 0.95, 1.0): + result = _build_personality_block("Keota", profile, w) + assert "Fully embody Keota" in result + assert "High-energy producer" in result # summary + + +def test_personality_block_phrase_count_scales_with_weight(): + """Signature phrase count = max(2, round(weight * len(phrases))).""" + from chat_service import _build_personality_block + + # Profile with 6 phrases to make scaling visible + profile = { + "vocabulary": { + "signature_phrases": ["p1", "p2", "p3", "p4", "p5", "p6"], + }, + "tone": {}, + "style_markers": {}, + } + # weight=0.6: max(2, round(0.6*6)) = max(2,4) = 4 → first 4 phrases + result = _build_personality_block("Test", profile, 0.6) + assert "p4" in result + assert "p5" not in result + + # weight=1.0: max(2, round(1.0*6)) = 6 → all phrases + result = _build_personality_block("Test", profile, 1.0) + assert "p6" in result @pytest.mark.asyncio