feat(audiobook): add gender field to audiobook character model and update related functionality
This commit is contained in:
@@ -291,6 +291,7 @@ async def update_character(
|
||||
char = crud.update_audiobook_character(
|
||||
db, char_id,
|
||||
name=data.name,
|
||||
gender=data.gender,
|
||||
description=data.description,
|
||||
instruct=data.instruct,
|
||||
voice_design_id=data.voice_design_id,
|
||||
|
||||
@@ -195,6 +195,7 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool
|
||||
name = char_data.get("name", "narrator")
|
||||
instruct = char_data.get("instruct", "")
|
||||
description = char_data.get("description", "")
|
||||
gender = char_data.get("gender") or ("未知" if name == "narrator" else None)
|
||||
|
||||
voice_design = crud.create_voice_design(
|
||||
db=db,
|
||||
@@ -209,6 +210,7 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
gender=gender,
|
||||
description=description,
|
||||
instruct=instruct,
|
||||
voice_design_id=voice_design.id,
|
||||
|
||||
@@ -119,6 +119,8 @@ class LLMService:
|
||||
async def extract_characters(self, text_samples: list[str], on_token=None, on_sample=None, turbo: bool = False) -> list[Dict]:
|
||||
system_prompt = (
|
||||
"你是一个专业的小说分析助手兼声音导演。请分析给定的小说文本,提取所有出现的角色(包括旁白narrator)。\n"
|
||||
"gender字段必须明确标注性别,只能取以下三个值之一:\"男\"、\"女\"、\"未知\"。\n"
|
||||
"narrator的gender固定为\"未知\"。\n"
|
||||
"对每个角色,instruct字段必须是详细的声音导演说明,需覆盖以下六个维度,每个维度单独一句,用换行分隔:\n"
|
||||
"1. 音色信息:嗓音质感、音域、音量、气息特征(如:青年男性中低音,音色干净略带沙哑,音量偏小但稳定,情绪激动时呼吸明显)\n"
|
||||
"2. 身份背景:角色身份、职业、出身、所处时代背景对声音的影响\n"
|
||||
@@ -127,7 +129,7 @@ class LLMService:
|
||||
"5. 性格特质:核心性格、情绪模式、表达习惯\n"
|
||||
"6. 叙事风格:语速节奏、停顿习惯、语气色彩、整体叙述感\n\n"
|
||||
"只输出JSON,格式如下,不要有其他文字:\n"
|
||||
'{"characters": [{"name": "narrator", "description": "第三人称叙述者", "instruct": "音色信息:...\\n身份背景:...\\n年龄设定:...\\n外貌特征:...\\n性格特质:...\\n叙事风格:..."}, ...]}'
|
||||
'{"characters": [{"name": "narrator", "gender": "未知", "description": "第三人称叙述者", "instruct": "音色信息:...\\n身份背景:...\\n年龄设定:...\\n外貌特征:...\\n性格特质:...\\n叙事风格:..."}, ...]}'
|
||||
)
|
||||
if turbo and len(text_samples) > 1:
|
||||
logger.info(f"Extracting characters in turbo mode: {len(text_samples)} samples concurrent")
|
||||
@@ -169,11 +171,12 @@ class LLMService:
|
||||
"你是一个专业的小说角色整合助手。你收到的是从同一本书不同段落中提取的角色列表,其中可能存在重复。\n"
|
||||
"请完成以下任务:\n"
|
||||
"1. 识别并合并重复角色:通过名字完全相同或高度相似(全名与简称、不同译写)来判断。\n"
|
||||
"2. 合并时保留最完整、最详细的 description 和 instruct 字段。\n"
|
||||
"3. narrator 角色只保留一个。\n"
|
||||
"2. 合并时保留最完整、最详细的 description 和 instruct 字段,gender 字段以最明确的值为准(优先选\"男\"或\"女\",而非\"未知\")。\n"
|
||||
"3. narrator 角色只保留一个,其 gender 固定为\"未知\"。\n"
|
||||
"4. 去除无意义的占位角色(name 为空或仅含标点)。\n"
|
||||
"gender 字段只能取 \"男\"、\"女\"、\"未知\" 之一。\n"
|
||||
"只输出 JSON,不要有其他文字:\n"
|
||||
'{"characters": [{"name": "...", "description": "...", "instruct": "..."}, ...]}'
|
||||
'{"characters": [{"name": "...", "gender": "男", "description": "...", "instruct": "..."}, ...]}'
|
||||
)
|
||||
user_message = f"请整合以下角色列表:\n\n{json.dumps(raw_characters, ensure_ascii=False, indent=2)}"
|
||||
try:
|
||||
|
||||
@@ -513,6 +513,7 @@ def create_audiobook_character(
|
||||
db: Session,
|
||||
project_id: int,
|
||||
name: str,
|
||||
gender: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
instruct: Optional[str] = None,
|
||||
voice_design_id: Optional[int] = None,
|
||||
@@ -520,6 +521,7 @@ def create_audiobook_character(
|
||||
char = AudiobookCharacter(
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
gender=gender,
|
||||
description=description,
|
||||
instruct=instruct,
|
||||
voice_design_id=voice_design_id,
|
||||
@@ -558,6 +560,7 @@ def update_audiobook_character(
|
||||
db: Session,
|
||||
char_id: int,
|
||||
name: Optional[str] = None,
|
||||
gender: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
instruct: Optional[str] = None,
|
||||
voice_design_id: Optional[int] = None,
|
||||
@@ -567,6 +570,8 @@ def update_audiobook_character(
|
||||
return None
|
||||
if name is not None:
|
||||
char.name = name
|
||||
if gender is not None:
|
||||
char.gender = gender
|
||||
if description is not None:
|
||||
char.description = description
|
||||
if instruct is not None:
|
||||
|
||||
@@ -27,3 +27,12 @@ def get_db():
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
if "sqlite" in str(engine.url):
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
conn.execute(__import__("sqlalchemy").text(
|
||||
"ALTER TABLE audiobook_characters ADD COLUMN gender VARCHAR(20)"
|
||||
))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -169,6 +169,7 @@ class AudiobookCharacter(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("audiobook_projects.id"), nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
gender = Column(String(20), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
instruct = Column(Text, nullable=True)
|
||||
voice_design_id = Column(Integer, ForeignKey("voice_designs.id"), nullable=True)
|
||||
|
||||
@@ -27,6 +27,7 @@ class AudiobookCharacterResponse(BaseModel):
|
||||
id: int
|
||||
project_id: int
|
||||
name: str
|
||||
gender: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
instruct: Optional[str] = None
|
||||
voice_design_id: Optional[int] = None
|
||||
@@ -64,6 +65,7 @@ class AudiobookCharacterUpdate(BaseModel):
|
||||
|
||||
class AudiobookCharacterEdit(BaseModel):
|
||||
name: Optional[str] = None
|
||||
gender: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
instruct: Optional[str] = None
|
||||
voice_design_id: Optional[int] = None
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AudiobookCharacter {
|
||||
id: number
|
||||
project_id: number
|
||||
name: string
|
||||
gender?: string
|
||||
description?: string
|
||||
instruct?: string
|
||||
voice_design_id?: number
|
||||
@@ -92,7 +93,7 @@ export const audiobookApi = {
|
||||
updateCharacter: async (
|
||||
projectId: number,
|
||||
charId: number,
|
||||
data: { name?: string; description?: string; instruct?: string; voice_design_id?: number }
|
||||
data: { name?: string; gender?: string; description?: string; instruct?: string; voice_design_id?: number }
|
||||
): Promise<AudiobookCharacter> => {
|
||||
const response = await apiClient.put<AudiobookCharacter>(
|
||||
`/audiobook/projects/${projectId}/characters/${charId}`,
|
||||
|
||||
@@ -334,7 +334,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
const [loadingAction, setLoadingAction] = useState(false)
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const [editingCharId, setEditingCharId] = useState<number | null>(null)
|
||||
const [editFields, setEditFields] = useState({ name: '', description: '', instruct: '' })
|
||||
const [editFields, setEditFields] = useState({ name: '', gender: '', description: '', instruct: '' })
|
||||
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
|
||||
const [charsCollapsed, setCharsCollapsed] = useState(false)
|
||||
const [chaptersCollapsed, setChaptersCollapsed] = useState(false)
|
||||
@@ -520,13 +520,14 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
|
||||
const startEditChar = (char: AudiobookCharacter) => {
|
||||
setEditingCharId(char.id)
|
||||
setEditFields({ name: char.name, description: char.description || '', instruct: char.instruct || '' })
|
||||
setEditFields({ name: char.name, gender: char.gender || '', description: char.description || '', instruct: char.instruct || '' })
|
||||
}
|
||||
|
||||
const saveEditChar = async (char: AudiobookCharacter) => {
|
||||
try {
|
||||
await audiobookApi.updateCharacter(project.id, char.id, {
|
||||
name: editFields.name || char.name,
|
||||
gender: editFields.gender || undefined,
|
||||
description: editFields.description,
|
||||
instruct: editFields.instruct,
|
||||
})
|
||||
@@ -632,6 +633,16 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
onChange={e => setEditFields(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="角色名"
|
||||
/>
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
value={editFields.gender}
|
||||
onChange={e => setEditFields(f => ({ ...f, gender: e.target.value }))}
|
||||
>
|
||||
<option value="">性别(未设置)</option>
|
||||
<option value="男">男</option>
|
||||
<option value="女">女</option>
|
||||
<option value="未知">未知</option>
|
||||
</select>
|
||||
<Input
|
||||
value={editFields.instruct}
|
||||
onChange={e => setEditFields(f => ({ ...f, instruct: e.target.value }))}
|
||||
@@ -653,7 +664,14 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between text-sm">
|
||||
<span className="font-medium shrink-0 truncate">{char.name}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="font-medium truncate">{char.name}</span>
|
||||
{char.gender && (
|
||||
<Badge variant="outline" className={`text-xs shrink-0 ${char.gender === '男' ? 'border-blue-400/50 text-blue-400' : char.gender === '女' ? 'border-pink-400/50 text-pink-400' : 'border-muted-foreground/40 text-muted-foreground'}`}>
|
||||
{char.gender}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate sm:mx-2 sm:flex-1">{char.instruct}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{char.voice_design_id
|
||||
|
||||
Reference in New Issue
Block a user