feat(audiobook): add turbo mode for project analysis and enhance log streaming with chapter support

This commit is contained in:
2026-03-10 17:01:50 +08:00
parent 11d44fd0be
commit 006aa0c85f
8 changed files with 126 additions and 68 deletions

View File

@@ -125,25 +125,26 @@ def _split_into_chapters(text: str) -> list[str]:
return chapters
async def analyze_project(project_id: int, user: User, db: Session) -> None:
async def analyze_project(project_id: int, user: User, db: Session, turbo: bool = False) -> None:
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
if not project:
return
ps.reset(project_id)
key = str(project_id)
ps.reset(key)
try:
crud.update_audiobook_project_status(db, project_id, "analyzing")
ps.append_line(project_id, f"[分析] 项目「{project.title}」开始角色分析")
ps.append_line(key, f"[分析] 项目「{project.title}」开始角色分析")
llm = _get_llm_service(user)
if project.source_type == "epub" and project.source_path:
ps.append_line(project_id, "[解析] 正在提取 EPUB 章节内容...")
ps.append_line(key, "[解析] 正在提取 EPUB 章节内容...")
epub_chapters = _extract_epub_chapters(project.source_path)
if not epub_chapters:
raise ValueError("No text content extracted from epub.")
text = "\n\n".join(epub_chapters)
ps.append_line(project_id, f"[解析] 提取完成,共 {len(epub_chapters)} 章,{len(text)}")
ps.append_line(key, f"[解析] 提取完成,共 {len(epub_chapters)} 章,{len(text)}")
project.source_text = text
db.commit()
else:
@@ -154,20 +155,26 @@ async def analyze_project(project_id: int, user: User, db: Session) -> None:
samples = _sample_full_text(text)
n = len(samples)
ps.append_line(project_id, f"\n[LLM] 模型:{user.llm_model},共 {n} 个采样段,正在分析角色...\n")
ps.append_line(project_id, "")
mode_label = "极速并发" if turbo else "顺序"
ps.append_line(key, f"\n[LLM] 模型:{user.llm_model},共 {n} 个采样段({mode_label}模式),正在分析角色...\n")
ps.append_line(key, "")
def on_token(token: str) -> None:
ps.append_token(project_id, token)
ps.append_token(key, token)
def on_sample(i: int, total: int) -> None:
if i < total - 1:
ps.append_line(project_id, f"\n[LLM] 采样段 {i + 1}/{total} 完成,继续分析...\n")
ps.append_line(key, f"\n[LLM] 采样段 {i + 1}/{total} 完成,继续分析...\n")
else:
ps.append_line(project_id, f"\n[LLM] 全部 {total} 个采样段完成,正在合并角色列表...\n")
ps.append_line(project_id, "")
ps.append_line(key, f"\n[LLM] 全部 {total} 个采样段完成,正在合并角色列表...\n")
ps.append_line(key, "")
characters_data = await llm.extract_characters(samples, on_token=on_token, on_sample=on_sample)
characters_data = await llm.extract_characters(
samples,
on_token=on_token,
on_sample=on_sample,
turbo=turbo,
)
has_narrator = any(c.get("name") == "narrator" for c in characters_data)
if not has_narrator:
@@ -177,7 +184,7 @@ async def analyze_project(project_id: int, user: User, db: Session) -> None:
"instruct": "中性声音,语速平稳,叙述感强"
})
ps.append_line(project_id, f"\n\n[完成] 发现 {len(characters_data)} 个角色:{', '.join(c.get('name', '') for c in characters_data)}")
ps.append_line(key, f"\n\n[完成] 发现 {len(characters_data)} 个角色:{', '.join(c.get('name', '') for c in characters_data)}")
crud.delete_audiobook_segments(db, project_id)
crud.delete_audiobook_characters(db, project_id)
@@ -208,13 +215,13 @@ async def analyze_project(project_id: int, user: User, db: Session) -> None:
)
crud.update_audiobook_project_status(db, project_id, "characters_ready")
ps.mark_done(project_id)
ps.mark_done(key)
logger.info(f"Project {project_id} character extraction complete: {len(characters_data)} characters")
except Exception as e:
logger.error(f"Analysis failed for project {project_id}: {e}", exc_info=True)
ps.append_line(project_id, f"\n[错误] {e}")
ps.mark_done(project_id)
ps.append_line(key, f"\n[错误] {e}")
ps.mark_done(key)
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
@@ -246,12 +253,12 @@ def identify_chapters(project_id: int, db, project) -> None:
async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) -> None:
from db.models import AudiobookChapter as ChapterModel
chapter = crud.get_audiobook_chapter(db, chapter_id)
if not chapter:
return
ps.reset(project_id)
key = f"ch_{chapter_id}"
ps.reset(key)
try:
crud.update_audiobook_chapter_status(db, chapter_id, "parsing")
@@ -264,26 +271,26 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
character_names = list(char_map.keys())
label = chapter.title or f"{chapter.chapter_index + 1}"
ps.append_line(project_id, f"[{label}] 开始解析 ({len(chapter.source_text)} 字)")
ps.append_line(key, f"[{label}] 开始解析 ({len(chapter.source_text)} 字)")
crud.delete_audiobook_segments_for_chapter(db, project_id, chapter.chapter_index)
chunks = _chunk_chapter(chapter.source_text, max_chars=4000)
ps.append_line(project_id, f"{len(chunks)}\n")
ps.append_line(key, f"{len(chunks)}\n")
seg_counter = 0
for i, chunk in enumerate(chunks):
ps.append_line(project_id, f"{i + 1}/{len(chunks)}")
ps.append_line(project_id, "")
ps.append_line(key, f"{i + 1}/{len(chunks)}")
ps.append_line(key, "")
def on_token(token: str) -> None:
ps.append_token(project_id, token)
ps.append_token(key, token)
try:
segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token)
except Exception as e:
logger.warning(f"Chapter {chapter_id} chunk {i} failed: {e}")
ps.append_line(project_id, f"\n[回退] {e}")
ps.append_line(key, f"\n[回退] {e}")
narrator = char_map.get("narrator")
if narrator:
crud.create_audiobook_segment(
@@ -308,17 +315,17 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
seg_counter += 1
chunk_count += 1
ps.append_line(project_id, f"\n{chunk_count}")
ps.append_line(key, f"\n{chunk_count}")
ps.append_line(project_id, f"\n[完成] 共 {seg_counter}")
ps.append_line(key, f"\n[完成] 共 {seg_counter}")
crud.update_audiobook_chapter_status(db, chapter_id, "ready")
ps.mark_done(project_id)
ps.mark_done(key)
logger.info(f"Chapter {chapter_id} parsed: {seg_counter} segments")
except Exception as e:
logger.error(f"parse_one_chapter {chapter_id} failed: {e}", exc_info=True)
ps.append_line(project_id, f"\n[错误] {e}")
ps.mark_done(project_id)
ps.append_line(key, f"\n[错误] {e}")
ps.mark_done(key)
crud.update_audiobook_chapter_status(db, chapter_id, "error", error_message=str(e))

View File

@@ -1,3 +1,4 @@
import asyncio
import json
import logging
from typing import Any, Dict
@@ -115,7 +116,7 @@ class LLMService:
logger.error(f"JSON parse failed. Raw response (first 500 chars): {raw[:500]}")
raise
async def extract_characters(self, text_samples: list[str], on_token=None, on_sample=None) -> list[Dict]:
async def extract_characters(self, text_samples: list[str], on_token=None, on_sample=None, turbo: bool = False) -> list[Dict]:
system_prompt = (
"你是一个专业的小说分析助手兼声音导演。请分析给定的小说文本提取所有出现的角色包括旁白narrator\n"
"对每个角色instruct字段必须是详细的声音导演说明需覆盖以下六个维度每个维度单独一句用换行分隔\n"
@@ -128,6 +129,26 @@ class LLMService:
"只输出JSON格式如下不要有其他文字\n"
'{"characters": [{"name": "narrator", "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")
async def _extract_one(sample: str) -> list[Dict]:
user_message = f"请分析以下小说文本并提取角色:\n\n{sample}"
result = await self.stream_chat_json(system_prompt, user_message, None)
return result.get("characters", [])
results = await asyncio.gather(
*[_extract_one(s) for s in text_samples],
return_exceptions=True,
)
raw_all: list[Dict] = []
for i, r in enumerate(results):
if isinstance(r, Exception):
logger.warning(f"Character extraction failed for sample {i+1}: {r}")
else:
raw_all.extend(r)
return await self.merge_characters(raw_all)
raw_all: list[Dict] = []
for i, sample in enumerate(text_samples):
logger.info(f"Extracting characters from sample {i+1}/{len(text_samples)}")

View File

@@ -1,38 +1,38 @@
from typing import Dict
_store: Dict[int, dict] = {}
_store: Dict[str, dict] = {}
def _ensure(project_id: int) -> dict:
if project_id not in _store:
_store[project_id] = {"lines": [], "done": False}
return _store[project_id]
def _ensure(key: str) -> dict:
if key not in _store:
_store[key] = {"lines": [], "done": False}
return _store[key]
def reset(project_id: int) -> None:
_store[project_id] = {"lines": [], "done": False}
def reset(key: str) -> None:
_store[key] = {"lines": [], "done": False}
def append_line(project_id: int, text: str) -> None:
s = _ensure(project_id)
def append_line(key: str, text: str) -> None:
s = _ensure(key)
s["lines"].append(text)
def append_token(project_id: int, token: str) -> None:
s = _ensure(project_id)
def append_token(key: str, token: str) -> None:
s = _ensure(key)
if s["lines"]:
s["lines"][-1] += token
else:
s["lines"].append(token)
def mark_done(project_id: int) -> None:
s = _ensure(project_id)
def mark_done(key: str) -> None:
s = _ensure(key)
s["done"] = True
def get_snapshot(project_id: int) -> dict:
s = _store.get(project_id)
def get_snapshot(key: str) -> dict:
s = _store.get(key)
if not s:
return {"lines": [], "done": True}
return {"lines": list(s["lines"]), "done": s["done"]}