Files
SillyTavern_replica/backend/services/fiction_orchestrator_service.py

291 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
爽文阅读流水线编排 — 新版 ensure 滚动补齐:卷纲 → 事件链 → 章纲 → 章节。
"""
from __future__ import annotations
import asyncio
import logging
from typing import Dict, List, Optional
from models.fiction_models import (
FictionPipelineSettings,
FictionPipelineTickResult,
FictionRunState,
FictionStartReadingResult,
)
from services.fiction_chapter_service import (
find_next_unwritten_chapter,
has_written_chapters,
run_chapter,
)
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_planning_service import (
ensure_chapter_plan,
ensure_event_chain,
ensure_volume,
)
from services.fiction_service import fiction_service
logger = logging.getLogger(__name__)
_active_tasks: Dict[str, asyncio.Task] = {}
_lock = asyncio.Lock()
def _get_pipeline_settings(book_id: str) -> FictionPipelineSettings:
settings = fiction_service.get_book_settings(book_id)
return settings.pipeline or FictionPipelineSettings()
def _needs_volume(book_id: str) -> bool:
metadata = fiction_metadata_service.get_metadata(book_id)
return not metadata.volumes
def _needs_event_chain(book_id: str) -> bool:
metadata = fiction_metadata_service.get_metadata(book_id)
if not metadata.volumes:
return False
for volume in metadata.volumes:
if not metadata.eventChains.get(volume.id):
return True
return False
def _needs_chapter_plan(book_id: str) -> bool:
metadata = fiction_metadata_service.get_metadata(book_id)
if not metadata.volumes:
return False
for volume in metadata.volumes:
events = metadata.eventChains.get(volume.id, [])
if not events:
return False
for event in events:
if not metadata.chapterPlans.get(event.id):
return True
return False
def _needs_chapter(book_id: str) -> bool:
if _needs_volume(book_id) or _needs_event_chain(book_id) or _needs_chapter_plan(book_id):
return False
if has_written_chapters(book_id):
return False
metadata = fiction_metadata_service.get_metadata(book_id)
return find_next_unwritten_chapter(book_id, metadata) is not None
def _pipeline_complete(book_id: str) -> bool:
return (
not _needs_volume(book_id)
and not _needs_event_chain(book_id)
and not _needs_chapter_plan(book_id)
and not _needs_chapter(book_id)
)
def get_pending_stages(book_id: str) -> List[str]:
"""返回需手动触发的阶段 id 列表auto 关闭且仍有工作,或上次失败需重试)。"""
pipeline = _get_pipeline_settings(book_id)
pending: List[str] = []
if _needs_volume(book_id) and not pipeline.autoCoarse:
pending.append("volume")
if _needs_event_chain(book_id) and not pipeline.autoEventPlan:
pending.append("event_chain")
if _needs_chapter_plan(book_id) and not pipeline.autoEventPlan:
pending.append("chapter_plan")
if _needs_chapter(book_id) and not pipeline.autoChapter:
pending.append("chapter")
run = fiction_metadata_service.get_run(book_id)
if run.status == "error" and run.pipelineStage:
retry_map = {
"volume": "volume",
"coarse": "volume",
"event_chain": "event_chain",
"event_plan": "chapter_plan",
"chapter_plan": "chapter_plan",
"chapter": "chapter",
}
failed = retry_map.get(run.pipelineStage)
if failed and failed not in pending:
if failed == "volume" and _needs_volume(book_id):
pending.insert(0, failed)
elif failed == "event_chain" and _needs_event_chain(book_id):
pending.insert(0, failed)
elif failed == "chapter_plan" and _needs_chapter_plan(book_id):
pending.insert(0, failed)
elif failed == "chapter" and _needs_chapter(book_id):
pending.insert(0, failed)
return pending
def _next_auto_stage(book_id: str) -> Optional[str]:
pipeline = _get_pipeline_settings(book_id)
if _needs_volume(book_id):
return "volume" if pipeline.autoCoarse else None
if _needs_event_chain(book_id):
return "event_chain" if pipeline.autoEventPlan else None
if _needs_chapter_plan(book_id):
return "chapter_plan" if pipeline.autoEventPlan else None
if _needs_chapter(book_id):
return "chapter" if pipeline.autoChapter else None
return None
async def _run_pipeline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> None:
pipeline = _get_pipeline_settings(book_id)
try:
if _needs_volume(book_id):
if not pipeline.autoCoarse:
return
await ensure_volume(
book_id, profile_id=profile_id, api_config=api_config
)
if _needs_event_chain(book_id):
if not pipeline.autoEventPlan:
return
await ensure_event_chain(
book_id, profile_id=profile_id, api_config=api_config
)
if _needs_chapter_plan(book_id):
if not pipeline.autoEventPlan:
return
await ensure_chapter_plan(
book_id, profile_id=profile_id, api_config=api_config
)
if _needs_chapter(book_id):
if not pipeline.autoChapter:
return
await run_chapter(
book_id, profile_id=profile_id, api_config=api_config
)
if _pipeline_complete(book_id):
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
except Exception:
logger.exception("Fiction pipeline failed for book %s", book_id)
finally:
async with _lock:
_active_tasks.pop(book_id, None)
async def _task_is_active(book_id: str) -> bool:
async with _lock:
task = _active_tasks.get(book_id)
return task is not None and not task.done()
async def _start_pipeline_task(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionPipelineTickResult:
run = fiction_metadata_service.get_run(book_id)
pending = get_pending_stages(book_id)
if run.status == "running":
if await _task_is_active(book_id):
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
logger.warning("Stale running pipeline for book %s, resetting to idle", book_id)
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage=run.pipelineStage
)
if run.status == "error":
if _pipeline_complete(book_id):
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return FictionPipelineTickResult(
run=run, started=False, pendingStages=pending
)
run = fiction_metadata_service.clear_pipeline_error(book_id)
if _pipeline_complete(book_id):
if run.pipelineStage != "ready":
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
next_stage = _next_auto_stage(book_id)
if not next_stage:
if run.pipelineStage not in (
None,
"ready",
"volume_done",
"event_chain_done",
"chapter_plan_done",
"chapter_done",
):
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return FictionPipelineTickResult(
run=run, started=False, pendingStages=pending
)
async with _lock:
existing = _active_tasks.get(book_id)
if existing and not existing.done():
run = fiction_metadata_service.get_run(book_id)
return FictionPipelineTickResult(
run=run, started=False, pendingStages=pending
)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage=next_stage
)
task = asyncio.create_task(
_run_pipeline(
book_id, profile_id=profile_id, api_config=api_config
)
)
_active_tasks[book_id] = task
run = fiction_metadata_service.get_run(book_id)
pending = get_pending_stages(book_id)
return FictionPipelineTickResult(run=run, started=True, pendingStages=pending)
async def tick_reading_pipeline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionPipelineTickResult:
"""检查 metadata + settings按需启动下一自动阶段。"""
return await _start_pipeline_task(
book_id, profile_id=profile_id, api_config=api_config
)
async def start_reading_pipeline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionStartReadingResult:
"""进入阅读时的流水线入口(兼容旧接口)。"""
result = await tick_reading_pipeline(
book_id, profile_id=profile_id, api_config=api_config
)
return FictionStartReadingResult(run=result.run, started=result.started)
def get_pipeline_run(book_id: str) -> FictionRunState:
return fiction_metadata_service.get_run(book_id)