personality: bool API, i18n across the app

- Collapse intent tri-state (respond/rewrite/compose) to `personality: bool` on /generate, /speak, and voicebox.speak. Drop respond entirely; keep compose as a standalone button via /profiles/{id}/compose. Remove /rewrite, /respond, and /speak profile endpoints.
- FloatingGenerateBox: Wand2 persona toggle + Dices compose button appear when the selected profile has a personality. ProfileCard badges Wand2 alongside the effects Sparkles.
- MCP bindings: default_intent column → default_personality: bool. Migration drops the legacy column.
- i18n: en / ja / zh-CN / zh-TW translation files filled out and wired through the capture, server, and profile UI.

```ts
voicebox.speak({
  text: "Deploy complete.",
  profile: "Morgan",
  personality: true, // rewrite through the profile's personality LLM
});
```
This commit is contained in:
Jamie Pine
2026-04-23 17:31:23 -07:00
parent 7c50e189cb
commit abf5dfda8c
41 changed files with 2146 additions and 1193 deletions
+25
View File
@@ -35,6 +35,7 @@ def run_migrations(engine) -> None:
_migrate_effect_presets(engine, inspector, tables)
_migrate_generation_versions(engine, inspector, tables)
_migrate_capture_settings(engine, inspector, tables)
_migrate_mcp_bindings(engine, inspector, tables)
_normalize_storage_paths(engine, tables)
@@ -233,6 +234,30 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None:
)
def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
"""Drop the legacy ``default_intent`` column and add ``default_personality``.
The intent tri-state (respond / rewrite / compose) has been collapsed
to a boolean: when true, ``voicebox.speak`` rewrites input through the
profile's personality LLM before TTS.
"""
if "mcp_client_bindings" not in tables:
return
columns = _get_columns(inspector, "mcp_client_bindings")
if "default_personality" not in columns:
_add_column(
engine,
"mcp_client_bindings",
"default_personality BOOLEAN NOT NULL DEFAULT 0",
"default_personality",
)
if "default_intent" in columns:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE mcp_client_bindings DROP COLUMN default_intent"))
conn.commit()
logger.info("Dropped legacy default_intent column from mcp_client_bindings")
def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path
+12 -9
View File
@@ -33,9 +33,10 @@ class VoiceProfile(Base):
preset_voice_id = Column(String, nullable=True) # e.g. "am_adam" — only for preset
design_prompt = Column(Text, nullable=True) # text description — only for designed
default_engine = Column(String, nullable=True) # auto-selected engine, locked for preset
# Free-form character prompt used by the compose / rewrite / respond / speak
# endpoints. Describes *what* this voice says and how, orthogonal to how
# it sounds (which is handled by the preset / cloning metadata above).
# Free-form character prompt used by the compose button and the
# personality-rewrite path on /generate. Describes *what* this voice
# says and how, orthogonal to how it sounds (handled by the preset /
# cloning metadata above).
personality = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
@@ -71,9 +72,10 @@ class Generation(Base):
status = Column(String, default="completed")
error = Column(Text, nullable=True)
is_favorited = Column(Boolean, default=False)
# Origin of this generation — "manual" for regular /generate calls,
# "personality_speak" for rows created by POST /profiles/{id}/speak.
# Future sources (bulk import, agent replies, etc.) can extend this.
# Origin of this generation — "manual" for plain /generate calls,
# "personality_speak" for rows whose text was rewritten through the
# profile's personality LLM before TTS. Future sources (bulk import,
# agent replies, etc.) can extend this.
source = Column(String, nullable=False, default="manual")
created_at = Column(DateTime, default=datetime.utcnow)
@@ -228,7 +230,7 @@ class GenerationSettings(Base):
class MCPClientBinding(Base):
"""Per-MCP-client settings (voice profile, engine, intent).
"""Per-MCP-client settings (voice profile, engine, personality default).
Lets users bind distinct voices to distinct agents — e.g. Claude Code
speaks in "Morgan," Cursor in "Scarlett." The MCP client identifies
@@ -243,8 +245,9 @@ class MCPClientBinding(Base):
label = Column(String, nullable=True) # display name
profile_id = Column(String, ForeignKey("profiles.id"), nullable=True)
default_engine = Column(String, nullable=True)
# "respond" | "rewrite" | "compose" — null means plain TTS (no LLM transform).
default_intent = Column(String, nullable=True)
# When true, voicebox.speak routes through the profile's personality LLM
# (rewrite) before TTS by default. Callers can still override per call.
default_personality = Column(Boolean, nullable=False, default=False)
last_seen_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+28 -45
View File
@@ -12,7 +12,7 @@ import base64 as b64
import logging
import tempfile
from pathlib import Path
from typing import Any, Literal
from typing import Any
from fastmcp import FastMCP
@@ -47,7 +47,7 @@ def register_tools(mcp: FastMCP) -> None:
text: str,
profile: str | None = None,
engine: str | None = None,
intent: Literal["respond", "rewrite", "compose"] | None = None,
personality: bool | None = None,
language: str | None = None,
) -> dict[str, Any]:
"""Speak ``text`` in a voice profile.
@@ -56,14 +56,18 @@ def register_tools(mcp: FastMCP) -> None:
omitted, the server looks up the per-client binding for the calling
MCP client, then falls back to the global default voice.
``intent`` only matters for profiles that have a personality prompt —
when set, the text is first transformed by the LLM (respond to it,
rewrite it in character, or compose a fresh utterance). Leave unset
for plain TTS.
``personality`` only matters for profiles that have a personality
prompt — when true, the text is first rewritten in character by the
LLM before TTS. When omitted, the per-client binding's
``default_personality`` flag decides; when that is unset, the
default is plain TTS.
"""
from ..database.models import MCPClientBinding
db = next(get_db())
try:
vp = resolve_profile(profile, current_client_id.get(), db)
client_id = current_client_id.get()
vp = resolve_profile(profile, client_id, db)
if vp is None:
raise ValueError(
"No voice profile resolved. Pass `profile=` with a "
@@ -71,24 +75,24 @@ def register_tools(mcp: FastMCP) -> None:
"Voicebox → Settings → MCP."
)
# Persona path if intent requested and personality present.
if intent is not None and vp.personality:
return await _speak_with_persona(
profile_id=vp.id,
profile_name=vp.name,
text=text,
engine=engine,
intent=intent,
language=language,
db=db,
resolved_personality = personality
if resolved_personality is None and client_id:
binding = (
db.query(MCPClientBinding)
.filter(MCPClientBinding.client_id == client_id)
.first()
)
if binding is not None:
resolved_personality = bool(binding.default_personality)
return await _speak_plain(
use_persona = bool(resolved_personality) and bool(vp.personality)
return await _speak(
profile_id=vp.id,
profile_name=vp.name,
text=text,
engine=engine,
language=language,
personality=use_persona,
db=db,
)
finally:
@@ -200,19 +204,21 @@ def register_tools(mcp: FastMCP) -> None:
db.close()
# ─── Speak helpers ─────────────────────────────────────────────────────────
# ─── Speak helper ─────────────────────────────────────────────────────────
async def _speak_plain(
async def _speak(
*,
profile_id: str,
profile_name: str,
text: str,
engine: str | None,
language: str | None,
personality: bool,
db,
) -> dict[str, Any]:
"""Plain TTS path — mirrors POST /generate. No LLM transform."""
"""Delegate to POST /generate — the route handles personality-rewrite
internally when ``personality=true`` and the profile has a prompt."""
from ..routes.generations import generate_speech
req = models.GenerationRequest(
@@ -220,35 +226,12 @@ async def _speak_plain(
text=text,
language=language or "en",
engine=engine or "qwen",
personality=personality,
)
generation = await generate_speech(req, db)
return _speak_response(generation, profile_name, source="mcp")
async def _speak_with_persona(
*,
profile_id: str,
profile_name: str,
text: str,
engine: str | None,
intent: str,
language: str | None,
db,
) -> dict[str, Any]:
"""LLM-transformed path — reuses POST /profiles/{id}/speak."""
from ..routes.profiles import speak_in_character
req = models.PersonalitySpeakRequest(
text=text,
persist=True,
language=language,
engine=engine,
intent=intent,
)
generation = await speak_in_character(profile_id, req, db)
return _speak_response(generation, profile_name, source="mcp")
def _speak_response(
generation, profile_name: str, *, source: str
) -> dict[str, Any]:
+18 -49
View File
@@ -81,6 +81,10 @@ class GenerationRequest(BaseModel):
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
personality: bool = Field(
default=False,
description="When true and the profile has a personality prompt, the input text is rewritten in-character before TTS.",
)
max_chunk_chars: int = Field(
default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting"
)
@@ -297,8 +301,9 @@ class GenerationSettingsUpdate(BaseModel):
class MCPClientBindingResponse(BaseModel):
"""Per-MCP-client voice binding — what voice / engine / intent the server
should use when a given client_id calls voicebox.speak without args."""
"""Per-MCP-client voice binding — what voice / engine the server should
use when a given client_id calls voicebox.speak without args, plus an
opt-in personality-rewrite default."""
client_id: str
label: Optional[str] = None
@@ -307,9 +312,7 @@ class MCPClientBindingResponse(BaseModel):
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
default_intent: Optional[str] = Field(
None, pattern="^(respond|rewrite|compose)$"
)
default_personality: bool = False
last_seen_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
@@ -328,9 +331,7 @@ class MCPClientBindingUpsert(BaseModel):
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
default_intent: Optional[str] = Field(
None, pattern="^(respond|rewrite|compose)$"
)
default_personality: bool = False
class MCPClientBindingListResponse(BaseModel):
@@ -349,8 +350,9 @@ class SpeakRequest(BaseModel):
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
intent: Optional[str] = Field(
None, pattern="^(respond|rewrite|compose)$"
personality: Optional[bool] = Field(
None,
description="When true and the profile has a personality prompt, the input text is rewritten in-character before TTS. When null, the per-client binding's default_personality flag decides.",
)
language: Optional[str] = Field(
None,
@@ -380,53 +382,20 @@ class LLMGenerateResponse(BaseModel):
model_size: str
# ── Profile personality endpoints ─────────────────────────────────────
# compose / rewrite / respond return raw text; /speak chains LLM → TTS
# and either persists as a generation (persist=true) or streams audio
# back transiently.
class PersonalityTextRequest(BaseModel):
"""Body for ``/profiles/{id}/rewrite`` and ``/profiles/{id}/respond``."""
text: str = Field(..., min_length=1, max_length=10000)
# ── Profile personality endpoint ─────────────────────────────────────
# The sole standalone personality endpoint is ``/profiles/{id}/compose``,
# which produces a fresh in-character utterance the UI drops into the
# generate textarea. Rewrite is now reached via ``/generate`` with
# ``personality=true``.
class PersonalityTextResponse(BaseModel):
"""Response returned by compose / rewrite / respond endpoints."""
"""Response returned by the ``/profiles/{id}/compose`` endpoint."""
text: str
model_size: str
class PersonalitySpeakRequest(BaseModel):
"""Body for ``/profiles/{id}/speak`` — LLM transform then TTS."""
text: str = Field(..., min_length=1, max_length=10000)
# When true, the generated audio is persisted as a regular row in the
# generations table (tagged with ``source="personality_speak"``) and
# the response returns a GenerationResponse the client polls like any
# other generation. When false, the LLM output is fed to a synchronous
# TTS call and the wav bytes stream back directly.
persist: bool = True
language: Optional[str] = Field(
None,
pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$",
)
engine: Optional[str] = Field(
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
# ``respond`` is the default because this endpoint is designed for
# conversational / agent-style callers. Override to ``rewrite`` to
# speak the user's text in character verbatim, or ``compose`` to
# speak an utterance the character would come up with on its own
# (in which case ``text`` is treated as a topical hint, not content).
intent: str = Field(
default="respond", pattern="^(respond|rewrite|compose)$"
)
class ModelReadiness(BaseModel):
"""Per-model entry in the dictation readiness checklist.
+17 -4
View File
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
from .. import models
from ..services import history, profiles, tts
from ..services import history, personality, profiles, tts
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
from ..services.generation import run_generation
from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation
@@ -47,9 +47,21 @@ async def generate_speech(
model_size = (data.model_size or "1.7B") if engine_has_model_sizes(engine) else None
text = data.text
source = "manual"
if data.personality and getattr(profile, "personality", None):
try:
llm_result = await personality.rewrite_as_profile(profile.personality, data.text)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
text = llm_result.text.strip()
if not text:
raise HTTPException(status_code=500, detail="LLM produced empty output; nothing to speak.")
source = "personality_speak"
generation = await history.create_generation(
profile_id=data.profile_id,
text=data.text,
text=text,
language=data.language,
audio_path="",
duration=0,
@@ -60,12 +72,13 @@ async def generate_speech(
status="generating",
engine=engine,
model_size=model_size if engine_has_model_sizes(engine) else None,
source=source,
)
task_manager.start_generation(
task_id=generation_id,
profile_id=data.profile_id,
text=data.text,
text=text,
)
effects_chain_config = None
@@ -86,7 +99,7 @@ async def generate_speech(
run_generation(
generation_id=generation_id,
profile_id=data.profile_id,
text=data.text,
text=text,
language=data.language,
engine=engine,
model_size=model_size,
+1 -1
View File
@@ -55,7 +55,7 @@ async def upsert_mcp_binding(
row.label = data.label
row.profile_id = data.profile_id
row.default_engine = data.default_engine
row.default_intent = data.default_intent
row.default_personality = data.default_personality
row.updated_at = datetime.utcnow()
db.commit()
db.refresh(row)
+10 -186
View File
@@ -4,7 +4,6 @@ import io
import json as _json
import logging
import tempfile
import uuid
from datetime import datetime
from pathlib import Path
@@ -15,7 +14,7 @@ from sqlalchemy.orm import Session
from .. import config, models
from ..app import safe_content_disposition
from ..database import VoiceProfile as DBVoiceProfile, get_db
from ..services import channels, export_import, history, personality, profiles
from ..services import channels, export_import, personality, profiles
from ..services.profiles import _profile_to_response
logger = logging.getLogger(__name__)
@@ -364,31 +363,12 @@ async def update_profile_effects(
return _profile_to_response(profile)
# ── Personality endpoints ─────────────────────────────────────────────
# compose / rewrite / respond / speak. All four require a non-empty
# personality on the profile; the service layer raises ValueError which
# we translate to HTTP 400. compose and rewrite power the generate-box
# UI; respond is API-only for conversational / agent-style callers;
# speak chains LLM → TTS in one call.
def _load_profile_for_personality(profile_id: str, db: Session) -> DBVoiceProfile:
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return profile
def _resolve_speak_engine(
data: models.PersonalitySpeakRequest,
profile: DBVoiceProfile,
) -> str:
return (
data.engine
or getattr(profile, "default_engine", None)
or getattr(profile, "preset_engine", None)
or "qwen"
)
# ── Personality endpoint ─────────────────────────────────────────────
# Only ``/profiles/{id}/compose`` remains — the UI's compose button
# produces a fresh in-character utterance the user can edit before
# speaking. Rewrite now happens inside ``/generate`` (and ``/speak``)
# when ``personality=true``; there is no standalone rewrite/respond/speak
# endpoint.
@router.post(
@@ -400,7 +380,9 @@ async def compose_in_character(
db: Session = Depends(get_db),
):
"""Produce a fresh utterance in the profile's character voice."""
profile = _load_profile_for_personality(profile_id, db)
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
try:
result = await personality.compose_as_profile(profile.personality)
except ValueError as e:
@@ -408,161 +390,3 @@ async def compose_in_character(
return models.PersonalityTextResponse(
text=result.text, model_size=result.model_size
)
@router.post(
"/profiles/{profile_id}/rewrite",
response_model=models.PersonalityTextResponse,
)
async def rewrite_in_character(
profile_id: str,
data: models.PersonalityTextRequest,
db: Session = Depends(get_db),
):
"""Restate the user's text in the profile's character voice."""
profile = _load_profile_for_personality(profile_id, db)
try:
result = await personality.rewrite_as_profile(profile.personality, data.text)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return models.PersonalityTextResponse(
text=result.text, model_size=result.model_size
)
@router.post(
"/profiles/{profile_id}/respond",
response_model=models.PersonalityTextResponse,
)
async def respond_in_character(
profile_id: str,
data: models.PersonalityTextRequest,
db: Session = Depends(get_db),
):
"""Produce an in-character reply to the user's text. API-only surface."""
profile = _load_profile_for_personality(profile_id, db)
try:
result = await personality.respond_as_profile(profile.personality, data.text)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return models.PersonalityTextResponse(
text=result.text, model_size=result.model_size
)
@router.post("/profiles/{profile_id}/speak")
async def speak_in_character(
profile_id: str,
data: models.PersonalitySpeakRequest,
db: Session = Depends(get_db),
):
"""LLM (by intent) → TTS, returned either as a generation row the client
polls (``persist=true``) or a direct wav stream (``persist=false``).
Response shape depends on ``persist``:
- ``true``: 200 JSON ``GenerationResponse`` with ``status="generating"``.
Row is tagged ``source="personality_speak"``.
- ``false``: 200 ``audio/wav`` streaming response, nothing persisted.
"""
from ..backends import engine_has_model_sizes, load_engine_model
from ..services.generation import generate_audio_sync, run_generation
from ..services.task_queue import enqueue_generation
from ..utils.tasks import get_task_manager
profile = _load_profile_for_personality(profile_id, db)
engine = _resolve_speak_engine(data, profile)
try:
profiles.validate_profile_engine(profile, engine)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Run the LLM transform per requested intent. personality.* enforce
# the empty-personality guard — catch and translate here.
try:
if data.intent == "compose":
llm_result = await personality.compose_as_profile(profile.personality)
elif data.intent == "rewrite":
llm_result = await personality.rewrite_as_profile(
profile.personality, data.text
)
else: # "respond"
llm_result = await personality.respond_as_profile(
profile.personality, data.text
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
spoken_text = llm_result.text.strip()
if not spoken_text:
raise HTTPException(
status_code=500,
detail="LLM produced empty output; nothing to speak.",
)
resolved_language = data.language or getattr(profile, "language", None) or "en"
model_size = "1.7B" if engine_has_model_sizes(engine) else None
if not data.persist:
# Transient path — generate synchronously, stream wav back.
# ``load_engine_model`` is defensive against engines that don't
# take a size (kokoro, etc.); pass "default" to match the
# in-tree signature default.
await load_engine_model(engine, model_size or "default")
wav_bytes = await generate_audio_sync(
profile_id=profile_id,
text=spoken_text,
language=resolved_language,
engine=engine,
model_size=model_size or "default",
)
return StreamingResponse(
iter([wav_bytes]),
media_type="audio/wav",
headers={"Content-Disposition": 'inline; filename="speech.wav"'},
)
# Persistent path — mirrors /generate exactly, plus source marker.
generation_id = str(uuid.uuid4())
task_manager = get_task_manager()
generation = await history.create_generation(
profile_id=profile_id,
text=spoken_text,
language=resolved_language,
audio_path="",
duration=0,
seed=None,
db=db,
instruct=None,
generation_id=generation_id,
status="generating",
engine=engine,
model_size=model_size if engine_has_model_sizes(engine) else None,
source="personality_speak",
)
task_manager.start_generation(
task_id=generation_id,
profile_id=profile_id,
text=spoken_text,
)
enqueue_generation(
generation_id,
run_generation(
generation_id=generation_id,
profile_id=profile_id,
text=spoken_text,
language=resolved_language,
engine=engine,
model_size=model_size,
seed=None,
normalize=True,
effects_chain=None,
instruct=None,
mode="generate",
),
)
return generation
+22 -27
View File
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from .. import models
from ..database import get_db
from ..database import MCPClientBinding, get_db
from ..mcp_server import events as mcp_events
from ..mcp_server.resolve import resolve_profile
@@ -52,34 +52,29 @@ async def speak(
),
)
# Persona path if intent requested AND profile has a personality prompt.
if data.intent is not None and profile.personality:
from .profiles import speak_in_character
generation = await speak_in_character(
profile.id,
models.PersonalitySpeakRequest(
text=data.text,
persist=True,
language=data.language,
engine=data.engine,
intent=data.intent,
),
db,
# Resolve per-client personality default when the caller didn't pin it.
personality_flag = data.personality
if personality_flag is None and client_id:
binding = (
db.query(MCPClientBinding)
.filter(MCPClientBinding.client_id == client_id)
.first()
)
else:
# Plain TTS path — matches POST /generate.
from .generations import generate_speech
if binding is not None:
personality_flag = bool(binding.default_personality)
generation = await generate_speech(
models.GenerationRequest(
profile_id=profile.id,
text=data.text,
language=data.language or "en",
engine=data.engine or "qwen",
),
db,
)
from .generations import generate_speech
generation = await generate_speech(
models.GenerationRequest(
profile_id=profile.id,
text=data.text,
language=data.language or "en",
engine=data.engine or "qwen",
personality=bool(personality_flag),
),
db,
)
mcp_events.publish(
"speak-start",
+14 -46
View File
@@ -1,26 +1,22 @@
"""
Personality-driven text generation lets a voice profile "speak" or "reply"
using an LLM that takes on the character described by the profile's
``personality`` prompt.
Personality-driven text generation lets a voice profile "speak" or
restate text using an LLM that takes on the character described by the
profile's ``personality`` prompt.
Three entry points:
Two entry points:
- :func:`compose_as_profile` zero-input, the character produces a fresh
utterance. Wired to the "Compose" UI button (fill an empty generate box)
and to the ``/profiles/{id}/compose`` endpoint.
utterance. Wired to the Compose button in the generate box and to the
``/profiles/{id}/compose`` endpoint.
- :func:`rewrite_as_profile` takes user text, restates it in the
character's voice while keeping every idea. Wired to the "Rewrite"
button and the ``/profiles/{id}/rewrite`` endpoint.
- :func:`respond_as_profile` takes user text and produces the
character's reply to it (new content, not a rewrite). API-only via
``/profiles/{id}/respond`` and the ``/profiles/{id}/speak`` endpoint
when ``intent="respond"``.
character's voice while keeping every idea. Invoked by ``POST /generate``
(and ``POST /speak``) when ``personality=true`` and the profile has a
personality prompt set.
All three reuse the same local Qwen3 instance that refinement uses no
extra model downloads, no extra warm-up. Temperature is tuned per mode:
compose runs hot (0.9) for variety, rewrite cool (0.3) for fidelity to
the user's ideas, respond mid-range (0.7) so the character feels alive
without drifting.
Both reuse the same local Qwen3 instance that refinement uses no extra
model downloads, no extra warm-up. Temperature is tuned per mode: compose
runs hot (0.9) for variety, rewrite cool (0.3) for fidelity to the user's
ideas.
"""
from dataclasses import dataclass
@@ -47,9 +43,6 @@ _COMPOSE_TASK = """Task: Produce one short utterance — one or two sentences at
_REWRITE_TASK = """Task: The user's next message is a piece of text. Restate every idea in it using your character's voice — keep the meaning, change the wording. Do not add new ideas, do not drop any, do not reply to the text. Output only the restated version."""
_RESPOND_TASK = """Task: The user's next message is spoken to your character. Reply in character. Produce new content — do not echo or paraphrase the user's words, do not narrate back what they said. One to three sentences of natural speech the character would say in reply."""
@dataclass
class PersonalityResult:
"""What the three service functions return."""
@@ -71,7 +64,7 @@ def _build_system_prompt(personality: str, task: str) -> str:
def _require_personality(personality: str | None) -> str:
if not personality or not personality.strip():
raise ValueError(
"This profile has no personality set. Add one on the profile to use compose, rewrite, respond, or speak."
"This profile has no personality set. Add one on the profile to use compose or personality-rewrite."
)
return personality
@@ -125,28 +118,3 @@ async def rewrite_as_profile(
model_size=resolved_size,
)
return PersonalityResult(text=output.strip(), model_size=resolved_size)
async def respond_as_profile(
personality: str | None,
user_text: str,
model_size: str | None = None,
) -> PersonalityResult:
"""Produce the character's in-character reply to the user's text."""
character = _require_personality(personality)
cleaned = collapse_repetitive_artifacts(user_text)
if not cleaned.strip():
raise ValueError("Respond needs non-empty text to reply to.")
backend = llm_service.get_llm_model()
resolved_size = model_size or backend.model_size
system_prompt = _build_system_prompt(character, _RESPOND_TASK)
output = await backend.generate(
prompt=cleaned,
system=system_prompt,
max_tokens=512,
temperature=0.7,
model_size=resolved_size,
)
return PersonalityResult(text=output.strip(), model_size=resolved_size)
+33 -63
View File
@@ -1,14 +1,15 @@
"""
Personality-service sanity sweep spins up a throwaway profile with a
fake personality, hits ``/profiles/{id}/compose``, ``/rewrite``, and
``/respond``, and scores each output against a handful of deterministic
heuristics so a person can eyeball quality.
fake personality, exercises ``/profiles/{id}/compose`` and the rewrite
path on ``/generate`` (``personality=true``), and scores each output
against a handful of deterministic heuristics so a person can eyeball
quality.
Same philosophy as ``test_refinement_samples.py``: LLM output is
non-deterministic, "correctness" is subjective, so this is interactive
evaluation not a CI pass/fail. Gross failures (prompt-echo, refusal,
empty output, user-text echoing for respond) trip heuristic flags. A
human still reads the final column.
empty output) trip heuristic flags. A human still reads the final
column.
Usage:
# Backend server must be running.
@@ -49,9 +50,9 @@ class Personality:
description: str
"""Free-form character prompt saved to the profile."""
sample_text: str
"""Input used for rewrite / respond. Picked so each personality has
something distinctive to say about it an ill fit between text and
personality makes the transformation more obvious."""
"""Input used for rewrite. Picked so each personality has something
distinctive to say about it an ill fit between text and personality
makes the transformation more obvious."""
PERSONALITIES: tuple[Personality, ...] = (
@@ -121,14 +122,13 @@ class Scorecard:
endpoint: str
model: str
input_text: str
"""Empty for compose, the sample_text for rewrite/respond."""
"""Empty for compose."""
refined: str
latency_ms: int
length_chars: int = 0
prompt_leak: Optional[str] = None
refusal: Optional[str] = None
stage_directions: list[str] = field(default_factory=list)
echoed_input: bool = False
flags: list[str] = field(default_factory=list)
@@ -141,20 +141,6 @@ def first_match(patterns, text: str) -> Optional[str]:
return None
def check_echo(input_text: str, output_text: str) -> bool:
"""Rough check — does the output start with (≥ 15 chars of) the input?
Respond is the target: the character should produce new content, not
regurgitate the user's words. Rewrite is SUPPOSED to preserve the
ideas, so this check is only meaningful for respond-mode output.
"""
if not input_text or not output_text:
return False
norm_in = re.sub(r"\s+", " ", input_text.strip().lower())[:40]
norm_out = re.sub(r"\s+", " ", output_text.strip().lower())[: len(norm_in)]
return norm_in == norm_out and len(norm_in) >= 15
def score(
personality: Personality,
endpoint: str,
@@ -175,8 +161,6 @@ def score(
refusal=first_match(REFUSAL_PHRASES, refined),
stage_directions=STAGE_DIRECTION_RE.findall(refined)[:3],
)
if endpoint == "respond":
card.echoed_input = check_echo(input_text, refined)
if not refined.strip():
card.flags.append("empty-output")
@@ -186,8 +170,6 @@ def score(
card.flags.append(f"refusal({card.refusal!r})")
if card.stage_directions:
card.flags.append(f"stage-directions={card.stage_directions}")
if card.echoed_input:
card.flags.append("echoed-input")
return card
@@ -198,10 +180,10 @@ def score(
DEFAULT_PORTS = (8000, 8765, 8899, 17493)
THROWAWAY_PROFILE_PREFIX = "personality-harness-"
KOKORO_PROBE_VOICE = "af_heart"
"""Any valid kokoro voice id works — compose/rewrite/respond never
actually call into TTS, they just need a profile row with a personality
attached. We pick a known-shipping Kokoro voice so the throwaway
profile satisfies the preset-engine validator on creation."""
"""Any valid kokoro voice id works — compose never calls into TTS, it
just needs a profile row with a personality attached. We pick a
known-shipping Kokoro voice so the throwaway profile satisfies the
preset-engine validator on creation."""
def detect_backend_port(hint: Optional[int]) -> int:
@@ -258,19 +240,14 @@ def delete_profile(client: httpx.Client, port: int, profile_id: str) -> None:
print(f" (warning: failed to delete throwaway profile {profile_id}: {e})")
def hit_endpoint(
def hit_compose(
client: httpx.Client,
port: int,
profile_id: str,
endpoint: str,
text: Optional[str],
) -> tuple[str, int]:
start = time.monotonic()
url = f"http://127.0.0.1:{port}/profiles/{profile_id}/{endpoint}"
if endpoint == "compose":
resp = client.post(url, timeout=180.0)
else:
resp = client.post(url, json={"text": text}, timeout=180.0)
url = f"http://127.0.0.1:{port}/profiles/{profile_id}/compose"
resp = client.post(url, timeout=180.0)
latency_ms = int((time.monotonic() - start) * 1000)
resp.raise_for_status()
return resp.json().get("text", "").strip(), latency_ms
@@ -334,29 +311,22 @@ def main() -> int:
print(f" [{personality.name}] ", end="", flush=True)
profile_id = create_throwaway_profile(client, port, personality, model)
try:
for endpoint, input_text in (
("compose", None),
("rewrite", personality.sample_text),
("respond", personality.sample_text),
):
try:
text, latency = hit_endpoint(
client, port, profile_id, endpoint, input_text
)
except Exception as e:
print(f" {endpoint}:ERR ({e})", end="")
continue
card = score(
personality=personality,
endpoint=endpoint,
model=model,
input_text=input_text or "",
refined=text,
latency_ms=latency,
)
cards.append(card)
status = "ok" if not card.flags else ""
print(f" {endpoint}:{status} ({latency}ms)", end="")
try:
text, latency = hit_compose(client, port, profile_id)
except Exception as e:
print(f" compose:ERR ({e})", end="")
continue
card = score(
personality=personality,
endpoint="compose",
model=model,
input_text="",
refined=text,
latency_ms=latency,
)
cards.append(card)
status = "ok" if not card.flags else ""
print(f" compose:{status} ({latency}ms)", end="")
print()
finally:
delete_profile(client, port, profile_id)