121 lines
4.7 KiB
Python
121 lines
4.7 KiB
Python
"""
|
|
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.
|
|
|
|
Two entry points:
|
|
|
|
- :func:`compose_as_profile` — zero-input, the character produces a fresh
|
|
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. Invoked by ``POST /generate``
|
|
(and ``POST /speak``) when ``personality=true`` and the profile has a
|
|
personality prompt set.
|
|
|
|
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
|
|
|
|
from . import llm as llm_service
|
|
from .refinement import collapse_repetitive_artifacts
|
|
|
|
|
|
# Shared rules block embedded in every mode-specific system prompt. Kept
|
|
# short because small LLMs (0.6B) degrade when the system prompt is long,
|
|
# and because the per-mode instructions downstream carry the specifics.
|
|
_CHARACTER_FRAMING = """You are roleplaying a specific character described below. Stay fully in character in everything you produce.
|
|
|
|
Rules that apply to every response:
|
|
- Do not break character. Do not explain what you are doing, refuse, apologize, greet the user, or acknowledge being an AI or assistant.
|
|
- Do not narrate action ("*smiles*", "(leans back)") or stage directions. Produce speech only.
|
|
- Do not wrap the output in quotes, code fences, or labels. Output the character's words and nothing else.
|
|
- Match the character's register — if they are curt, be curt; if they ramble, ramble; if they swear, swear."""
|
|
|
|
|
|
_COMPOSE_TASK = """Task: Produce one short utterance — one or two sentences at most — that this character might say right now, unprompted. A remark, an observation, a thought out loud. No greeting, no addressing anyone by name, no "Well, …" or "So, …" opener unless it fits the character naturally. Just a natural line of speech."""
|
|
|
|
|
|
_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."""
|
|
|
|
|
|
@dataclass
|
|
class PersonalityResult:
|
|
"""What the three service functions return."""
|
|
|
|
text: str
|
|
model_size: str
|
|
|
|
|
|
def _build_system_prompt(personality: str, task: str) -> str:
|
|
return (
|
|
_CHARACTER_FRAMING
|
|
+ "\n\nCharacter description:\n"
|
|
+ personality.strip()
|
|
+ "\n\n"
|
|
+ task
|
|
)
|
|
|
|
|
|
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 or personality-rewrite."
|
|
)
|
|
return personality
|
|
|
|
|
|
async def compose_as_profile(
|
|
personality: str | None,
|
|
model_size: str | None = None,
|
|
) -> PersonalityResult:
|
|
"""Produce a fresh utterance in the character's voice.
|
|
|
|
No user input; the system prompt plus a trigger user turn ("Speak.")
|
|
is all the model gets. Temperature is high so successive calls
|
|
produce different outputs — the UI's Compose button is expected to
|
|
be clicked repeatedly for variety.
|
|
"""
|
|
text = _require_personality(personality)
|
|
backend = llm_service.get_llm_model()
|
|
resolved_size = model_size or backend.model_size
|
|
|
|
system_prompt = _build_system_prompt(text, _COMPOSE_TASK)
|
|
output = await backend.generate(
|
|
prompt="Speak.",
|
|
system=system_prompt,
|
|
max_tokens=256,
|
|
temperature=0.9,
|
|
model_size=resolved_size,
|
|
)
|
|
return PersonalityResult(text=output.strip(), model_size=resolved_size)
|
|
|
|
|
|
async def rewrite_as_profile(
|
|
personality: str | None,
|
|
user_text: str,
|
|
model_size: str | None = None,
|
|
) -> PersonalityResult:
|
|
"""Restate the user's text in the character's voice, ideas intact."""
|
|
character = _require_personality(personality)
|
|
cleaned = collapse_repetitive_artifacts(user_text)
|
|
if not cleaned.strip():
|
|
raise ValueError("Rewrite needs non-empty text to restate.")
|
|
|
|
backend = llm_service.get_llm_model()
|
|
resolved_size = model_size or backend.model_size
|
|
|
|
system_prompt = _build_system_prompt(character, _REWRITE_TASK)
|
|
output = await backend.generate(
|
|
prompt=cleaned,
|
|
system=system_prompt,
|
|
max_tokens=1024,
|
|
temperature=0.3,
|
|
model_size=resolved_size,
|
|
)
|
|
return PersonalityResult(text=output.strip(), model_size=resolved_size)
|