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:
parent
c3d1afa2ce
commit
d32864de6a
2 changed files with 162 additions and 22 deletions
|
|
@ -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
|
||||
# --- 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue