feat(audiobook): add gender field to audiobook character model and update related functionality

This commit is contained in:
2026-03-10 20:23:03 +08:00
parent addb152ce1
commit 2e005b0084
9 changed files with 50 additions and 8 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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}`,

View File

@@ -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