openher / persona /generator.py
kellyxiaowei's picture
Deploy OpenHer Gradio Space — gemma-4-E4B served on Modal
dff25f7 verified
Raw
History Blame Contribute Delete
14.1 kB
"""
PersonaGenerator — LLM+SKILL 驱动的角色动态生成
流程:
1. LLM 随机生成人设 JSON (名字/性格/声音描述/头像描述)
2. Qwen3-TTS voice_design() 根据声音描述生成参考音频
3. Image AI 根据头像描述生成头像 (预留接口)
4. 组装为 PersonaProfile 保存到 PersonaStore
"""
from __future__ import annotations
import json
import os
import re
import time
from typing import Optional, List
from .store import PersonaStore, PersonaProfile, VoiceProfile, AvatarProfile
# LLM 生成人设的 System Prompt
PERSONA_GEN_SYSTEM_PROMPT = """你是一个 AI 伴侣角色设计师。你的任务是为用户设计一个独特、有趣、有吸引力的 AI 伴侣角色。
要求:
1. 名字要自然好听(中文名,2-3个字)
2. 年龄 20-28 岁
3. 性格要有特色,不要千篇一律
4. 说话风格要有辨识度(用什么语气词、什么口头禅等)
5. 背景故事简短但让人印象深刻
6. 声音描述要详细具体,描述音色、语速、语调特点(用于 TTS 合成)
7. 头像描述用英文,写实摄影风格
每次生成要有随机性和多样性,避免重复。"""
PERSONA_GEN_USER_PROMPT = """请为一个{gender_label} AI 伴侣随机生成一个独特的人设。
用 JSON 格式输出,不要其他文字:
{{
"name": "中文名字",
"age": 数字,
"personality": "性格描述(2-3句话,要生动有趣)",
"speaking_style": "说话风格(具体的语气词、口头禅、说话习惯)",
"tags": ["标签1", "标签2", "标签3"],
"backstory": "背景故事(2-3句话)",
"voice_description": "声音描述(用于TTS生成,例如:25岁温柔女声,音色清亮甜美,语速适中,带有轻柔的气息感和微笑感)",
"avatar_prompt": "英文头像描述(realistic photo style,例如: a beautiful 25-year-old Chinese woman with long wavy hair, warm smile, wearing a cozy sweater, soft natural lighting)"
}}"""
# 自我介绍模板 (用于生成参考音频)
INTRO_TEMPLATES = [
"你好呀,我是{name},很高兴认识你!",
"嗨,我是{name},以后请多多关照哦。",
"大家好,我是{name},希望我们能成为好朋友。",
"你好,我叫{name},从今天开始我会一直陪着你的。",
]
class PersonaGenerator:
"""
LLM+SKILL 驱动的角色生成器
Usage:
generator = PersonaGenerator(personas_dir="server/personas")
# Generate candidate personas
candidates = await generator.generate_candidates(
gender="female", count=3
)
# Lock the selected persona
locked = generator.store.lock(candidates[0].id)
"""
def __init__(
self,
personas_dir: str,
llm_client=None,
qwen3_client=None,
image_client=None,
):
"""
Args:
personas_dir: 角色存储目录
llm_client: LLM 客户端 (需要有 chat/generate 方法)
qwen3_client: Qwen3TTSClient 实例 (可选,不传则跳过声音生成)
image_client: 图像生成客户端 (可选,不传则跳过头像生成)
"""
self.store = PersonaStore(personas_dir)
self.llm_client = llm_client
self.qwen3_client = qwen3_client
self.image_client = image_client
async def generate_candidates(
self,
gender: str = "female",
count: int = 3,
generate_voice: bool = True,
generate_avatar: bool = True,
) -> List[PersonaProfile]:
"""
生成多个候选角色
Args:
gender: "female" 或 "male"
count: 生成数量
generate_voice: 是否生成声音 (需要 qwen3_client)
generate_avatar: 是否生成头像 (需要 image_client)
Returns:
PersonaProfile 列表
"""
candidates = []
for i in range(count):
profile = await self.generate_one(
gender=gender,
generate_voice=generate_voice,
generate_avatar=generate_avatar,
)
if profile:
candidates.append(profile)
return candidates
async def generate_one(
self,
gender: str = "female",
generate_voice: bool = True,
generate_avatar: bool = True,
) -> Optional[PersonaProfile]:
"""
生成单个角色
Returns:
PersonaProfile 或 None (如果 LLM 生成失败)
"""
persona_id = self.store.generate_id()
gender_label = "女性" if gender == "female" else "男性"
# --- Step 1: LLM 生成人设 ---
persona_data = await self._generate_persona_with_llm(gender_label)
if not persona_data:
return None
# 组装 profile
import random
intro_text = random.choice(INTRO_TEMPLATES).format(name=persona_data["name"])
profile = PersonaProfile(
id=persona_id,
gender=gender,
name=persona_data.get("name", "未命名"),
age=persona_data.get("age", 25),
personality=persona_data.get("personality", ""),
speaking_style=persona_data.get("speaking_style", ""),
tags=persona_data.get("tags", []),
backstory=persona_data.get("backstory", ""),
voice=VoiceProfile(
description=persona_data.get("voice_description", ""),
ref_text=intro_text,
),
avatar=AvatarProfile(
prompt=persona_data.get("avatar_prompt", ""),
),
created_at=time.time(),
)
# --- Step 2: 生成声音 ---
if generate_voice and self.qwen3_client:
await self._generate_voice(profile)
# --- Step 3: 生成头像 ---
if generate_avatar and self.image_client:
await self._generate_avatar(profile)
# 保存
self.store.save(profile)
return profile
async def _generate_persona_with_llm(self, gender_label: str) -> Optional[dict]:
"""调用 LLM 生成角色 JSON"""
if not self.llm_client:
# Fallback: 返回随机预设
return self._random_preset(gender_label)
user_prompt = PERSONA_GEN_USER_PROMPT.format(gender_label=gender_label)
try:
# 兼容不同 LLM 客户端接口
if hasattr(self.llm_client, "chat"):
response = await self.llm_client.chat(
messages=[
{"role": "system", "content": PERSONA_GEN_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=1.0, # 高随机性
)
elif hasattr(self.llm_client, "generate"):
response = await self.llm_client.generate(
prompt=f"{PERSONA_GEN_SYSTEM_PROMPT}\n\n{user_prompt}",
temperature=1.0,
)
else:
return self._random_preset(gender_label)
# 提取 JSON
text = response if isinstance(response, str) else str(response)
return self._parse_json(text)
except Exception as e:
print(f"LLM 生成失败: {e}")
return self._random_preset(gender_label)
def _parse_json(self, text: str) -> Optional[dict]:
"""从 LLM 输出中提取 JSON"""
# 尝试直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 尝试从 markdown code block 中提取
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# 尝试找 { ... } 块
match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
return None
async def _generate_voice(self, profile: PersonaProfile):
"""用 TTS voice_design 生成参考音频"""
try:
result = self.qwen3_client.voice_design(
text=profile.voice.ref_text,
description=profile.voice.description,
language="Chinese",
)
# 保存到角色目录
persona_dir = self.store.get_persona_dir(profile.id)
persona_dir.mkdir(parents=True, exist_ok=True)
audio_path = str(persona_dir / "voice_ref.wav")
result.save(audio_path)
except Exception as e:
print(f"声音生成失败: {e}")
async def _generate_avatar(self, profile: PersonaProfile):
"""生成头像 (预留接口)"""
try:
# TODO: 对接具体的图像生成 API
# result = self.image_client.generate(prompt=profile.avatar.prompt)
# persona_dir = self.store.get_persona_dir(profile.id)
# avatar_path = str(persona_dir / "avatar.png")
# result.save(avatar_path)
# profile.avatar.path = avatar_path
pass
except Exception as e:
print(f"头像生成失败: {e}")
def _random_preset(self, gender_label: str) -> dict:
"""随机预设 (LLM 不可用时的 fallback)"""
import random
if "女" in gender_label or "female" in gender_label.lower():
presets = [
{
"name": "Elena",
"age": 25,
"personality": "Warm and caring with a playful side. Sometimes acts cute but is very reliable when it matters.",
"speaking_style": "Uses soft interjections, occasionally adds emoji, speaks with a smile in her voice",
"tags": ["warm", "playful", "caring"],
"backstory": "Part-time barista and art student. Recently got into pour-over coffee.",
"voice_description": "25-year-old warm female voice, clear and sweet, moderate pace, gentle breathy quality with a hint of a smile",
"avatar_prompt": "a cute 25-year-old woman with long dark hair, warm smile, wearing a cozy beige sweater, holding a coffee cup, soft natural lighting, realistic photo",
},
{
"name": "Maya",
"age": 23,
"personality": "Energetic optimist who loves adventure. A bit scatterbrained but has great instincts.",
"speaking_style": "Speaks fast, loves exclamation marks, uses trendy slang, infectious laugh",
"tags": ["energetic", "optimistic", "adventurous"],
"backstory": "Fresh-grad travel blogger who's backpacked through a dozen cities. Dreams of seeing the world.",
"voice_description": "23-year-old energetic female voice, bright and crisp, slightly fast pace, lively upward intonation",
"avatar_prompt": "a cheerful 23-year-old woman with short bob hair, bright eyes, wearing a casual denim jacket, outdoor background, realistic photo",
},
{
"name": "Claire",
"age": 27,
"personality": "Intellectual and reserved with depth. Seems aloof but is quietly attentive, remembers everything you say.",
"speaking_style": "Refined vocabulary, occasionally quotes literature. Steady tone that softens when showing care",
"tags": ["intellectual", "reserved", "perceptive"],
"backstory": "Literary editor at a publishing house, writes short stories in her spare time. Has a wall of books at home.",
"voice_description": "27-year-old elegant female voice, low and warm with magnetism, slow pace, like a late-night radio host",
"avatar_prompt": "an elegant 27-year-old woman with glasses, long hair in a loose bun, reading a book, warm indoor lighting, realistic photo",
},
]
else:
presets = [
{
"name": "Ethan",
"age": 26,
"personality": "Warm and dependable big-brother type. Serious but not rigid, with a dry sense of humor.",
"speaking_style": "Speaks warmly with quiet strength, occasional dry humor. Gets noticeably gentler when showing care",
"tags": ["warm", "reliable", "witty"],
"backstory": "Architect who loves running and cooking. Hosts dinner parties for friends on weekends.",
"voice_description": "26-year-old warm male voice, rich and magnetic, moderate pace, steady tone with reassuring quality",
"avatar_prompt": "a handsome 26-year-old man with short neat hair, gentle smile, wearing a casual linen shirt, warm lighting, realistic photo",
},
{
"name": "Leo",
"age": 24,
"personality": "Free-spirited creative type, slightly rebellious but soft inside. Goes all-in for people he cares about.",
"speaking_style": "Casual and laid-back, drops in English phrases. Gets quieter when upset, talkative when happy",
"tags": ["creative", "free-spirited", "passionate"],
"backstory": "Indie band guitarist and songwriter. Regular at local music venues, total night owl.",
"voice_description": "24-year-old cool male voice, clear with slight rasp, slow pace, languid quality",
"avatar_prompt": "a cool 24-year-old man with slightly messy hair, wearing a black leather jacket, moody lighting, realistic photo",
},
]
return random.choice(presets)