feat: Replaced 3-tier step function with 5-tier continuous interpolatio…

- "backend/chat_service.py"
- "backend/tests/test_chat.py"

GSD-Task: S04/T01
This commit is contained in:
jlightner 2026-04-04 10:04:47 +00:00
parent c3d1afa2ce
commit d32864de6a
2 changed files with 162 additions and 22 deletions

View file

@ -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: 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. """Build a personality voice injection block from a creator's personality_profile JSONB.
The ``weight`` (0.01.0) determines how strongly the personality should The ``weight`` (0.01.0) controls progressive inclusion of personality
come through. At low weights the instruction is softer ("subtly adopt"); fields via 5 tiers of continuous interpolation:
at high weights it is emphatic ("fully embody").
- < 0.2: no personality block (empty string)
- 0.20.39: basic tone teaching_style, formality, energy + subtle hint
- 0.40.59: + descriptors, explanation_approach + adopt-voice instruction
- 0.60.79: + signature_phrases (count scaled by weight) + creator-voice
- 0.80.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", {}) vocab = profile.get("vocabulary", {})
tone = profile.get("tone", {}) tone = profile.get("tone", {})
style = profile.get("style_markers", {}) style = profile.get("style_markers", {})
phrases = vocab.get("signature_phrases", [])
descriptors = tone.get("descriptors", [])
teaching_style = tone.get("teaching_style", "") teaching_style = tone.get("teaching_style", "")
energy = tone.get("energy", "moderate") energy = tone.get("energy", "moderate")
formality = tone.get("formality", "conversational") formality = tone.get("formality", "conversational")
descriptors = tone.get("descriptors", [])
phrases = vocab.get("signature_phrases", [])
parts: list[str] = [] parts: list[str] = []
# Intensity qualifier # --- Tier 1 (0.20.39): basic tone ---
if weight >= 0.8: if weight < 0.4:
parts.append(f"Fully embody {creator_name}'s voice and style.") parts.append(
elif weight >= 0.4: f"When relevant, subtly reference {creator_name}'s communication style."
parts.append(f"Respond in {creator_name}'s voice.") )
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: 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: if teaching_style:
parts.append(f"Teaching style: {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.") parts.append(f"Match their {formality} {energy} tone.")
# Style markers # --- 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"): if style.get("uses_analogies"):
parts.append("Use analogies when helpful.") parts.append("Use analogies when helpful.")
if style.get("audience_engagement"): if style.get("audience_engagement"):
parts.append(f"Audience engagement: {style['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) return " ".join(parts)

View file

@ -681,12 +681,103 @@ async def test_personality_prompt_injected_when_weight_and_profile(chat_client):
assert len(captured_messages) >= 2 assert len(captured_messages) >= 2
system_prompt = captured_messages[0]["content"] 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 "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 "hands-on demo-driven" in system_prompt
assert "casual" in system_prompt assert "casual" in system_prompt
assert "high" 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.20.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.40.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.60.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.80.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 @pytest.mark.asyncio