291 lines
9.5 KiB
Python
291 lines
9.5 KiB
Python
"""
|
||
爽文阅读流水线编排 — 新版 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)
|