mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 06:40:38 -07:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user