feat: add admin usage statistics and LLM configuration management
This commit is contained in:
@@ -26,14 +26,18 @@ def cancel_batch(project_id: int) -> None:
|
||||
logger.info(f"cancel_batch: project={project_id} cancellation signalled")
|
||||
|
||||
|
||||
def _get_llm_service(user: User) -> LLMService:
|
||||
def _get_llm_service(db: Session) -> LLMService:
|
||||
from core.security import decrypt_api_key
|
||||
if not user.llm_api_key or not user.llm_base_url or not user.llm_model:
|
||||
raise ValueError("LLM config not set. Please configure LLM API key, base URL, and model.")
|
||||
api_key = decrypt_api_key(user.llm_api_key)
|
||||
from db.crud import get_system_setting
|
||||
api_key_encrypted = get_system_setting(db, "llm_api_key")
|
||||
base_url = get_system_setting(db, "llm_base_url")
|
||||
model = get_system_setting(db, "llm_model")
|
||||
if not api_key_encrypted or not base_url or not model:
|
||||
raise ValueError("LLM config not set. Please configure LLM API key, base URL, and model in admin settings.")
|
||||
api_key = decrypt_api_key(api_key_encrypted)
|
||||
if not api_key:
|
||||
raise ValueError("Failed to decrypt LLM API key.")
|
||||
return LLMService(base_url=user.llm_base_url, api_key=api_key, model=user.llm_model)
|
||||
return LLMService(base_url=base_url, api_key=api_key, model=model)
|
||||
|
||||
|
||||
def _get_gendered_instruct(gender: Optional[str], base_instruct: str) -> str:
|
||||
@@ -167,7 +171,18 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool
|
||||
crud.update_audiobook_project_status(db, project_id, "analyzing")
|
||||
ps.append_line(key, f"[分析] 项目「{project.title}」开始角色分析")
|
||||
|
||||
llm = _get_llm_service(user)
|
||||
llm = _get_llm_service(db)
|
||||
_llm_model = crud.get_system_setting(db, "llm_model")
|
||||
_user_id = user.id
|
||||
|
||||
def _log_analyze_usage(prompt_tokens: int, completion_tokens: int) -> None:
|
||||
from db.database import SessionLocal
|
||||
log_db = SessionLocal()
|
||||
try:
|
||||
crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens,
|
||||
model=_llm_model, context="audiobook_analyze")
|
||||
finally:
|
||||
log_db.close()
|
||||
|
||||
if project.source_type == "epub" and project.source_path:
|
||||
ps.append_line(key, "[解析] 正在提取 EPUB 章节内容...")
|
||||
@@ -219,6 +234,7 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool
|
||||
on_token=on_token,
|
||||
on_sample=on_sample,
|
||||
turbo=turbo,
|
||||
usage_callback=_log_analyze_usage,
|
||||
)
|
||||
|
||||
has_narrator = any(c.get("name") == "narrator" for c in characters_data)
|
||||
@@ -356,7 +372,19 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
|
||||
try:
|
||||
crud.update_audiobook_chapter_status(db, chapter_id, "parsing")
|
||||
|
||||
llm = _get_llm_service(user)
|
||||
llm = _get_llm_service(db)
|
||||
_llm_model = crud.get_system_setting(db, "llm_model")
|
||||
_user_id = user.id
|
||||
|
||||
def _log_parse_usage(prompt_tokens: int, completion_tokens: int) -> None:
|
||||
from db.database import SessionLocal
|
||||
log_db = SessionLocal()
|
||||
try:
|
||||
crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens,
|
||||
model=_llm_model, context="audiobook_parse")
|
||||
finally:
|
||||
log_db.close()
|
||||
|
||||
characters = crud.list_audiobook_characters(db, project_id)
|
||||
if not characters:
|
||||
raise ValueError("No characters found. Please analyze the project first.")
|
||||
@@ -383,7 +411,7 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
|
||||
ps.append_token(key, token)
|
||||
|
||||
try:
|
||||
segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token)
|
||||
segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token, usage_callback=_log_parse_usage)
|
||||
except Exception as e:
|
||||
logger.warning(f"Chapter {chapter_id} chunk {i} failed: {e}")
|
||||
ps.append_line(key, f"\n[回退] {e}")
|
||||
@@ -543,8 +571,11 @@ async def generate_project(project_id: int, user: User, db: Session, chapter_ind
|
||||
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||
|
||||
user_api_key = None
|
||||
if backend_type == "aliyun" and user.aliyun_api_key:
|
||||
user_api_key = decrypt_api_key(user.aliyun_api_key)
|
||||
if backend_type == "aliyun":
|
||||
from db.crud import get_system_setting
|
||||
encrypted = get_system_setting(db, "aliyun_api_key")
|
||||
if encrypted:
|
||||
user_api_key = decrypt_api_key(encrypted)
|
||||
|
||||
backend = await TTSServiceFactory.get_backend(backend_type, user_api_key)
|
||||
|
||||
@@ -572,7 +603,23 @@ async def generate_project(project_id: int, user: User, db: Session, chapter_ind
|
||||
audio_filename = f"ch{seg.chapter_index:03d}_seg{seg.segment_index:04d}.wav"
|
||||
audio_path = output_base / audio_filename
|
||||
|
||||
if backend_type == "aliyun":
|
||||
ref_audio_for_emo = design.ref_audio_path
|
||||
if not ref_audio_for_emo:
|
||||
preview_path = Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id) / "previews" / f"char_{char.id}.wav"
|
||||
if preview_path.exists():
|
||||
ref_audio_for_emo = str(preview_path)
|
||||
|
||||
if seg.emo_text and ref_audio_for_emo and Path(ref_audio_for_emo).exists():
|
||||
from core.tts_service import IndexTTS2Backend
|
||||
indextts2 = IndexTTS2Backend()
|
||||
audio_bytes = await indextts2.generate(
|
||||
text=seg.text,
|
||||
spk_audio_prompt=ref_audio_for_emo,
|
||||
output_path=str(audio_path),
|
||||
emo_text=seg.emo_text,
|
||||
emo_alpha=seg.emo_alpha if seg.emo_alpha is not None else 0.6,
|
||||
)
|
||||
elif backend_type == "aliyun":
|
||||
if design.aliyun_voice_id:
|
||||
audio_bytes, _ = await backend.generate_voice_design(
|
||||
{"text": seg.text, "language": "zh"},
|
||||
@@ -584,16 +631,6 @@ async def generate_project(project_id: int, user: User, db: Session, chapter_ind
|
||||
"language": "zh",
|
||||
"instruct": _get_gendered_instruct(char.gender, design.instruct),
|
||||
})
|
||||
elif char.use_indextts2 and design.ref_audio_path and Path(design.ref_audio_path).exists():
|
||||
from core.tts_service import IndexTTS2Backend
|
||||
indextts2 = IndexTTS2Backend()
|
||||
audio_bytes = await indextts2.generate(
|
||||
text=seg.text,
|
||||
spk_audio_prompt=design.ref_audio_path,
|
||||
output_path=str(audio_path),
|
||||
emo_text=seg.emo_text or None,
|
||||
emo_alpha=seg.emo_alpha if seg.emo_text else 0.5,
|
||||
)
|
||||
else:
|
||||
if design.voice_cache_id:
|
||||
from core.cache_manager import VoiceCacheManager
|
||||
@@ -688,8 +725,11 @@ async def generate_single_segment(segment_id: int, user: User, db: Session) -> N
|
||||
|
||||
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||
user_api_key = None
|
||||
if backend_type == "aliyun" and user.aliyun_api_key:
|
||||
user_api_key = decrypt_api_key(user.aliyun_api_key)
|
||||
if backend_type == "aliyun":
|
||||
from db.crud import get_system_setting
|
||||
encrypted = get_system_setting(db, "aliyun_api_key")
|
||||
if encrypted:
|
||||
user_api_key = decrypt_api_key(encrypted)
|
||||
|
||||
backend = await TTSServiceFactory.get_backend(backend_type, user_api_key)
|
||||
|
||||
@@ -709,7 +749,23 @@ async def generate_single_segment(segment_id: int, user: User, db: Session) -> N
|
||||
audio_filename = f"ch{seg.chapter_index:03d}_seg{seg.segment_index:04d}.wav"
|
||||
audio_path = output_base / audio_filename
|
||||
|
||||
if backend_type == "aliyun":
|
||||
ref_audio_for_emo = design.ref_audio_path
|
||||
if not ref_audio_for_emo:
|
||||
preview_path = Path(settings.OUTPUT_DIR) / "audiobook" / str(seg.project_id) / "previews" / f"char_{char.id}.wav"
|
||||
if preview_path.exists():
|
||||
ref_audio_for_emo = str(preview_path)
|
||||
|
||||
if seg.emo_text and ref_audio_for_emo and Path(ref_audio_for_emo).exists():
|
||||
from core.tts_service import IndexTTS2Backend
|
||||
indextts2 = IndexTTS2Backend()
|
||||
audio_bytes = await indextts2.generate(
|
||||
text=seg.text,
|
||||
spk_audio_prompt=ref_audio_for_emo,
|
||||
output_path=str(audio_path),
|
||||
emo_text=seg.emo_text,
|
||||
emo_alpha=seg.emo_alpha if seg.emo_alpha is not None else 0.6,
|
||||
)
|
||||
elif backend_type == "aliyun":
|
||||
if design.aliyun_voice_id:
|
||||
audio_bytes, _ = await backend.generate_voice_design(
|
||||
{"text": seg.text, "language": "zh"},
|
||||
@@ -721,16 +777,6 @@ async def generate_single_segment(segment_id: int, user: User, db: Session) -> N
|
||||
"language": "zh",
|
||||
"instruct": _get_gendered_instruct(char.gender, design.instruct),
|
||||
})
|
||||
elif char.use_indextts2 and design.ref_audio_path and Path(design.ref_audio_path).exists():
|
||||
from core.tts_service import IndexTTS2Backend
|
||||
indextts2 = IndexTTS2Backend()
|
||||
audio_bytes = await indextts2.generate(
|
||||
text=seg.text,
|
||||
spk_audio_prompt=design.ref_audio_path,
|
||||
output_path=str(audio_path),
|
||||
emo_text=seg.emo_text or None,
|
||||
emo_alpha=seg.emo_alpha if seg.emo_text else 0.5,
|
||||
)
|
||||
else:
|
||||
if design.voice_cache_id:
|
||||
from core.cache_manager import VoiceCacheManager
|
||||
@@ -1070,7 +1116,9 @@ async def generate_character_preview(project_id: int, char_id: int, user: User,
|
||||
|
||||
with open(audio_path, "wb") as f:
|
||||
f.write(audio_bytes)
|
||||
|
||||
|
||||
design.ref_audio_path = str(audio_path)
|
||||
db.commit()
|
||||
logger.info(f"Preview generated for char {char_id}: {audio_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate preview for char {char_id}: {e}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -14,7 +14,7 @@ class LLMService:
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
async def stream_chat(self, system_prompt: str, user_message: str, on_token=None, max_tokens: int = 8192) -> str:
|
||||
async def stream_chat(self, system_prompt: str, user_message: str, on_token=None, max_tokens: int = 8192, usage_callback: Optional[Callable[[int, int], None]] = None) -> str:
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
@@ -29,8 +29,10 @@ class LLMService:
|
||||
"temperature": 0.3,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
}
|
||||
full_text = ""
|
||||
_usage = None
|
||||
timeout = httpx.Timeout(connect=10.0, read=90.0, write=10.0, pool=5.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("POST", url, json=payload, headers=headers) as resp:
|
||||
@@ -46,6 +48,9 @@ class LLMService:
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
if chunk.get("usage"):
|
||||
_usage = chunk["usage"]
|
||||
continue
|
||||
delta = chunk["choices"][0]["delta"].get("content", "")
|
||||
if delta:
|
||||
full_text += delta
|
||||
@@ -53,10 +58,12 @@ class LLMService:
|
||||
on_token(delta)
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
continue
|
||||
if _usage and usage_callback:
|
||||
usage_callback(_usage.get("prompt_tokens", 0), _usage.get("completion_tokens", 0))
|
||||
return full_text
|
||||
|
||||
async def stream_chat_json(self, system_prompt: str, user_message: str, on_token=None, max_tokens: int = 8192):
|
||||
raw = await self.stream_chat(system_prompt, user_message, on_token, max_tokens=max_tokens)
|
||||
async def stream_chat_json(self, system_prompt: str, user_message: str, on_token=None, max_tokens: int = 8192, usage_callback: Optional[Callable[[int, int], None]] = None):
|
||||
raw = await self.stream_chat(system_prompt, user_message, on_token, max_tokens=max_tokens, usage_callback=usage_callback)
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
raise ValueError("LLM returned empty response")
|
||||
@@ -74,7 +81,7 @@ class LLMService:
|
||||
logger.error(f"JSON parse failed. Raw (first 500): {raw[:500]}")
|
||||
raise
|
||||
|
||||
async def chat(self, system_prompt: str, user_message: str) -> str:
|
||||
async def chat(self, system_prompt: str, user_message: str, usage_callback: Optional[Callable[[int, int], None]] = None) -> str:
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
@@ -97,10 +104,13 @@ class LLMService:
|
||||
logger.error(f"LLM API error {resp.status_code}: {resp.text}")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
usage = data.get("usage", {})
|
||||
if usage and usage_callback:
|
||||
usage_callback(usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0))
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
async def chat_json(self, system_prompt: str, user_message: str) -> Any:
|
||||
raw = await self.chat(system_prompt, user_message)
|
||||
async def chat_json(self, system_prompt: str, user_message: str, usage_callback: Optional[Callable[[int, int], None]] = None) -> Any:
|
||||
raw = await self.chat(system_prompt, user_message, usage_callback=usage_callback)
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
raise ValueError("LLM returned empty response")
|
||||
@@ -118,7 +128,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, turbo: bool = False) -> list[Dict]:
|
||||
async def extract_characters(self, text_samples: list[str], on_token=None, on_sample=None, turbo: bool = False, usage_callback: Optional[Callable[[int, int], None]] = None) -> list[Dict]:
|
||||
system_prompt = (
|
||||
"你是一个专业的小说分析助手兼声音导演。请分析给定的小说文本,提取所有出现的角色(包括旁白narrator)。\n"
|
||||
"gender字段必须明确标注性别,只能取以下三个值之一:\"男\"、\"女\"、\"未知\"。\n"
|
||||
@@ -145,7 +155,7 @@ class LLMService:
|
||||
|
||||
async def _extract_one(i: int, sample: str) -> list[Dict]:
|
||||
user_message = f"请分析以下小说文本并提取角色:\n\n{sample}"
|
||||
result = await self.stream_chat_json(system_prompt, user_message, None)
|
||||
result = await self.stream_chat_json(system_prompt, user_message, None, usage_callback=usage_callback)
|
||||
if on_sample:
|
||||
on_sample(i, len(text_samples))
|
||||
return result.get("characters", [])
|
||||
@@ -160,14 +170,14 @@ class LLMService:
|
||||
logger.warning(f"Character extraction failed for sample {i+1}: {r}")
|
||||
else:
|
||||
raw_all.extend(r)
|
||||
return await self.merge_characters(raw_all)
|
||||
return await self.merge_characters(raw_all, usage_callback=usage_callback)
|
||||
|
||||
raw_all: list[Dict] = []
|
||||
for i, sample in enumerate(text_samples):
|
||||
logger.info(f"Extracting characters from sample {i+1}/{len(text_samples)}")
|
||||
user_message = f"请分析以下小说文本并提取角色:\n\n{sample}"
|
||||
try:
|
||||
result = await self.stream_chat_json(system_prompt, user_message, on_token)
|
||||
result = await self.stream_chat_json(system_prompt, user_message, on_token, usage_callback=usage_callback)
|
||||
raw_all.extend(result.get("characters", []))
|
||||
except Exception as e:
|
||||
logger.warning(f"Character extraction failed for sample {i+1}: {e}")
|
||||
@@ -175,9 +185,9 @@ class LLMService:
|
||||
on_sample(i, len(text_samples))
|
||||
if len(text_samples) == 1:
|
||||
return raw_all
|
||||
return await self.merge_characters(raw_all)
|
||||
return await self.merge_characters(raw_all, usage_callback=usage_callback)
|
||||
|
||||
async def merge_characters(self, raw_characters: list[Dict]) -> list[Dict]:
|
||||
async def merge_characters(self, raw_characters: list[Dict], usage_callback: Optional[Callable[[int, int], None]] = None) -> list[Dict]:
|
||||
system_prompt = (
|
||||
"你是一个专业的小说角色整合助手。你收到的是从同一本书不同段落中提取的角色列表,其中可能存在重复。\n"
|
||||
"请完成以下任务:\n"
|
||||
@@ -191,7 +201,7 @@ class LLMService:
|
||||
)
|
||||
user_message = f"请整合以下角色列表:\n\n{json.dumps(raw_characters, ensure_ascii=False, indent=2)}"
|
||||
try:
|
||||
result = await self.chat_json(system_prompt, user_message)
|
||||
result = await self.chat_json(system_prompt, user_message, usage_callback=usage_callback)
|
||||
return result.get("characters", [])
|
||||
except Exception as e:
|
||||
logger.warning(f"Character merge failed, falling back to name-dedup: {e}")
|
||||
@@ -202,7 +212,7 @@ class LLMService:
|
||||
seen[name] = c
|
||||
return list(seen.values())
|
||||
|
||||
async def parse_chapter_segments(self, chapter_text: str, character_names: list[str], on_token=None) -> list[Dict]:
|
||||
async def parse_chapter_segments(self, chapter_text: str, character_names: list[str], on_token=None, usage_callback: Optional[Callable[[int, int], None]] = None) -> list[Dict]:
|
||||
names_str = "、".join(character_names)
|
||||
system_prompt = (
|
||||
"你是一个专业的有声书制作助手。请将给定的章节文本解析为对话片段列表。"
|
||||
@@ -217,7 +227,7 @@ class LLMService:
|
||||
'{"character": "角色名", "text": "对话内容", "emo_text": "开心", "emo_alpha": 0.6}, ...]'
|
||||
)
|
||||
user_message = f"请解析以下章节文本:\n\n{chapter_text}"
|
||||
result = await self.stream_chat_json(system_prompt, user_message, on_token, max_tokens=16384)
|
||||
result = await self.stream_chat_json(system_prompt, user_message, on_token, max_tokens=16384, usage_callback=usage_callback)
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user