from __future__ import annotations import asyncio import json import logging from dataclasses import dataclass from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.chains.agents.script_processing_agents import ( EntityMergeResult, VariantAnalysisResult, ) from app.core.db import async_session_maker from app.core.task_manager import DeliveryMode, SqlAlchemyTaskStore, TaskManager from app.core.task_manager.types import TaskStatus from app.dependencies import get_llm from app.models.task import GenerationTask, GenerationTaskStatus from app.models.task_links import GenerationTaskLink from app.chains.agents import EntityMergerAgent, VariantAnalyzerAgent logger = logging.getLogger(__name__) CHAPTER_DIVISION_RELATION_TYPE = "chapter_division" SCRIPT_EXTRACTION_RELATION_TYPE = "script_extraction" ENTITY_MERGE_RELATION_TYPE = "entity_merge" # 预备能力:当前无真实前端入口 CONSISTENCY_CHECK_RELATION_TYPE = "consistency_check" VARIANT_ANALYSIS_RELATION_TYPE = "variant_analysis" # 预备能力:当前无真实前端入口 CHARACTER_PORTRAIT_ANALYSIS_RELATION_TYPE = "character_portrait_analysis" PROP_INFO_ANALYSIS_RELATION_TYPE = "prop_info_analysis" SCENE_INFO_ANALYSIS_RELATION_TYPE = "scene_info_analysis" COSTUME_INFO_ANALYSIS_RELATION_TYPE = "costume_info_analysis" SCRIPT_OPTIMIZATION_RELATION_TYPE = "script_optimization" SCRIPT_SIMPLIFICATION_RELATION_TYPE = "script_simplification" SCRIPT_DIVIDE_TASK_KIND = "script_divide" SCRIPT_EXTRACT_TASK_KIND = "script_extract" SCRIPT_MERGE_TASK_KIND = "script_merge" SCRIPT_CONSISTENCY_TASK_KIND = "script_consistency" SCRIPT_VARIANT_TASK_KIND = "script_variant" SCRIPT_CHARACTER_PORTRAIT_TASK_KIND = "script_character_portrait" SCRIPT_PROP_INFO_TASK_KIND = "script_prop_info" SCRIPT_SCENE_INFO_TASK_KIND = "script_scene_info" SCRIPT_COSTUME_INFO_TASK_KIND = "script_costume_info" SCRIPT_OPTIMIZE_TASK_KIND = "script_optimize" SCRIPT_SIMPLIFY_TASK_KIND = "script_simplify" _ACTIVE_TASK_STATUSES = ( GenerationTaskStatus.pending, GenerationTaskStatus.running, GenerationTaskStatus.streaming, ) class _CreateOnlyTask: """仅用于 TaskManager.create,避免为异步任务额外声明无意义 task 类。""" async def run(self, *args: object, **kwargs: object): return None async def status(self) -> dict[str, object]: return {} async def is_done(self) -> bool: return False async def get_result(self) -> object: return None @dataclass(slots=True) class AsyncTaskCreateResult: task_id: str status: TaskStatus reused: bool relation_type: str relation_entity_id: str async def _cancel_if_requested( store: SqlAlchemyTaskStore, task_id: str, db: AsyncSession, ) -> bool: """在阶段边界检查取消请求,并统一完成 cancelled 收尾。""" if not await store.is_cancel_requested(task_id): return False await store.mark_cancelled(task_id) await db.commit() return True async def find_active_divide_task( db: AsyncSession, *, chapter_id: str, ) -> GenerationTask | None: return await _find_active_task( db, relation_type=CHAPTER_DIVISION_RELATION_TYPE, relation_entity_id=chapter_id, ) async def _find_active_task( db: AsyncSession, *, relation_type: str, relation_entity_id: str, ) -> GenerationTask | None: """按业务关联查询任意活动任务。 这里的目标只是“是否已有运行中的同类任务”,不需要挑出“最新”那一条。 因此直接从 relation link 侧过滤,再按活动状态做一次连接,并去掉排序, 避免在任务量大时触发 MySQL 的大排序与 sort buffer 压力。 """ stmt = ( select(GenerationTask) .join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id) .where( GenerationTaskLink.relation_type == relation_type, GenerationTaskLink.relation_entity_id == relation_entity_id, GenerationTask.status.in_(_ACTIVE_TASK_STATUSES), ) .limit(1) ) return (await db.execute(stmt)).scalars().first() async def create_divide_task( db: AsyncSession, *, chapter_id: str, script_text: str, write_to_db: bool, ) -> AsyncTaskCreateResult: existing = await find_active_divide_task(db, chapter_id=chapter_id) if existing is not None: status_value = existing.status.value if hasattr(existing.status, "value") else str(existing.status) return AsyncTaskCreateResult( task_id=existing.id, status=TaskStatus(status_value), reused=True, relation_type=CHAPTER_DIVISION_RELATION_TYPE, relation_entity_id=chapter_id, ) store = SqlAlchemyTaskStore(db) tm = TaskManager(store=store, strategies={}) run_args = { "chapter_id": chapter_id, "script_text": script_text, "write_to_db": write_to_db, } task_record = await tm.create( task=_CreateOnlyTask(), mode=DeliveryMode.async_polling, task_kind=SCRIPT_DIVIDE_TASK_KIND, run_args=run_args, ) db.add( GenerationTaskLink( task_id=task_record.id, resource_type="task_link", relation_type=CHAPTER_DIVISION_RELATION_TYPE, relation_entity_id=chapter_id, ) ) await db.flush() return AsyncTaskCreateResult( task_id=task_record.id, status=task_record.status, reused=False, relation_type=CHAPTER_DIVISION_RELATION_TYPE, relation_entity_id=chapter_id, ) def spawn_divide_task(task_id: str) -> None: """统一封装后台启动:第一阶段改为通用 Celery 执行入口。""" from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_id) async def find_active_extract_task( db: AsyncSession, *, chapter_id: str, ) -> GenerationTask | None: return await _find_active_task( db, relation_type=SCRIPT_EXTRACTION_RELATION_TYPE, relation_entity_id=chapter_id, ) async def find_active_merge_task( db: AsyncSession, *, relation_entity_id: str, ) -> GenerationTask | None: return await _find_active_task( db, relation_type=ENTITY_MERGE_RELATION_TYPE, relation_entity_id=relation_entity_id, ) async def find_active_consistency_task( db: AsyncSession, *, relation_entity_id: str, ) -> GenerationTask | None: return await _find_active_task( db, relation_type=CONSISTENCY_CHECK_RELATION_TYPE, relation_entity_id=relation_entity_id, ) async def find_active_variant_task( db: AsyncSession, *, relation_entity_id: str, ) -> GenerationTask | None: return await _find_active_task( db, relation_type=VARIANT_ANALYSIS_RELATION_TYPE, relation_entity_id=relation_entity_id, ) async def _find_active_analysis_task( db: AsyncSession, *, relation_type: str, relation_entity_id: str, ) -> GenerationTask | None: return await _find_active_task( db, relation_type=relation_type, relation_entity_id=relation_entity_id, ) def pick_merge_relation_entity_id(*, chapter_id: str | None, project_id: str | None) -> str: relation_entity_id = (chapter_id or "").strip() or (project_id or "").strip() if not relation_entity_id: raise HTTPException(status_code=400, detail="chapter_id or project_id is required for merge-entities-async") return relation_entity_id def pick_consistency_relation_entity_id(*, chapter_id: str | None, project_id: str | None) -> str: relation_entity_id = (chapter_id or "").strip() or (project_id or "").strip() if not relation_entity_id: raise HTTPException(status_code=400, detail="chapter_id or project_id is required for check-consistency-async") return relation_entity_id def pick_variant_relation_entity_id(*, chapter_id: str | None, project_id: str | None) -> str: relation_entity_id = (chapter_id or "").strip() or (project_id or "").strip() if not relation_entity_id: raise HTTPException(status_code=400, detail="chapter_id or project_id is required for analyze-variants-async") return relation_entity_id def pick_analysis_relation_entity_id( *, relation_entity_id: str | None = None, chapter_id: str | None, project_id: str | None, endpoint: str, ) -> str: relation_entity_id = (relation_entity_id or "").strip() or (chapter_id or "").strip() or (project_id or "").strip() if not relation_entity_id: raise HTTPException(status_code=400, detail=f"relation_entity_id or chapter_id or project_id is required for {endpoint}") return relation_entity_id async def create_extract_task( db: AsyncSession, *, project_id: str, chapter_id: str, script_division: dict, consistency: dict | None, refresh_cache: bool, ) -> AsyncTaskCreateResult: existing = await find_active_extract_task(db, chapter_id=chapter_id) if existing is not None: status_value = existing.status.value if hasattr(existing.status, "value") else str(existing.status) return AsyncTaskCreateResult( task_id=existing.id, status=TaskStatus(status_value), reused=True, relation_type=SCRIPT_EXTRACTION_RELATION_TYPE, relation_entity_id=chapter_id, ) store = SqlAlchemyTaskStore(db) tm = TaskManager(store=store, strategies={}) run_args = { "project_id": project_id, "chapter_id": chapter_id, "script_division": script_division, "consistency": consistency, "refresh_cache": refresh_cache, } task_record = await tm.create( task=_CreateOnlyTask(), mode=DeliveryMode.async_polling, task_kind=SCRIPT_EXTRACT_TASK_KIND, run_args=run_args, ) db.add( GenerationTaskLink( task_id=task_record.id, resource_type="task_link", relation_type=SCRIPT_EXTRACTION_RELATION_TYPE, relation_entity_id=chapter_id, ) ) await db.flush() return AsyncTaskCreateResult( task_id=task_record.id, status=task_record.status, reused=False, relation_type=SCRIPT_EXTRACTION_RELATION_TYPE, relation_entity_id=chapter_id, ) async def create_merge_task( db: AsyncSession, *, relation_entity_id: str, all_shot_extractions: list[dict], historical_library: dict | None, script_division: dict | None, previous_merge: dict | None, conflict_resolutions: list[dict] | None, ) -> AsyncTaskCreateResult: existing = await find_active_merge_task(db, relation_entity_id=relation_entity_id) if existing is not None: status_value = existing.status.value if hasattr(existing.status, "value") else str(existing.status) return AsyncTaskCreateResult( task_id=existing.id, status=TaskStatus(status_value), reused=True, relation_type=ENTITY_MERGE_RELATION_TYPE, relation_entity_id=relation_entity_id, ) store = SqlAlchemyTaskStore(db) tm = TaskManager(store=store, strategies={}) run_args = { "all_shot_extractions": all_shot_extractions, "historical_library": historical_library, "script_division": script_division, "previous_merge": previous_merge, "conflict_resolutions": conflict_resolutions, } task_record = await tm.create( task=_CreateOnlyTask(), mode=DeliveryMode.async_polling, task_kind=SCRIPT_MERGE_TASK_KIND, run_args=run_args, ) db.add( GenerationTaskLink( task_id=task_record.id, resource_type="task_link", relation_type=ENTITY_MERGE_RELATION_TYPE, relation_entity_id=relation_entity_id, ) ) await db.flush() return AsyncTaskCreateResult( task_id=task_record.id, status=task_record.status, reused=False, relation_type=ENTITY_MERGE_RELATION_TYPE, relation_entity_id=relation_entity_id, ) async def create_consistency_task( db: AsyncSession, *, relation_entity_id: str, script_text: str, ) -> AsyncTaskCreateResult: existing = await find_active_consistency_task(db, relation_entity_id=relation_entity_id) if existing is not None: status_value = existing.status.value if hasattr(existing.status, "value") else str(existing.status) return AsyncTaskCreateResult( task_id=existing.id, status=TaskStatus(status_value), reused=True, relation_type=CONSISTENCY_CHECK_RELATION_TYPE, relation_entity_id=relation_entity_id, ) store = SqlAlchemyTaskStore(db) tm = TaskManager(store=store, strategies={}) task_record = await tm.create( task=_CreateOnlyTask(), mode=DeliveryMode.async_polling, task_kind=SCRIPT_CONSISTENCY_TASK_KIND, run_args={"script_text": script_text}, ) db.add( GenerationTaskLink( task_id=task_record.id, resource_type="task_link", relation_type=CONSISTENCY_CHECK_RELATION_TYPE, relation_entity_id=relation_entity_id, ) ) await db.flush() return AsyncTaskCreateResult( task_id=task_record.id, status=task_record.status, reused=False, relation_type=CONSISTENCY_CHECK_RELATION_TYPE, relation_entity_id=relation_entity_id, ) async def create_variant_task( db: AsyncSession, *, relation_entity_id: str, merged_library: dict, all_shot_extractions: list[dict], script_division: dict | None, ) -> AsyncTaskCreateResult: existing = await find_active_variant_task(db, relation_entity_id=relation_entity_id) if existing is not None: status_value = existing.status.value if hasattr(existing.status, "value") else str(existing.status) return AsyncTaskCreateResult( task_id=existing.id, status=TaskStatus(status_value), reused=True, relation_type=VARIANT_ANALYSIS_RELATION_TYPE, relation_entity_id=relation_entity_id, ) store = SqlAlchemyTaskStore(db) tm = TaskManager(store=store, strategies={}) task_record = await tm.create( task=_CreateOnlyTask(), mode=DeliveryMode.async_polling, task_kind=SCRIPT_VARIANT_TASK_KIND, run_args={ "merged_library": merged_library, "all_shot_extractions": all_shot_extractions, "script_division": script_division, }, ) db.add( GenerationTaskLink( task_id=task_record.id, resource_type="task_link", relation_type=VARIANT_ANALYSIS_RELATION_TYPE, relation_entity_id=relation_entity_id, ) ) await db.flush() return AsyncTaskCreateResult( task_id=task_record.id, status=task_record.status, reused=False, relation_type=VARIANT_ANALYSIS_RELATION_TYPE, relation_entity_id=relation_entity_id, ) async def _create_analysis_task( db: AsyncSession, *, task_kind: str, relation_type: str, relation_entity_id: str, run_args: dict, ) -> AsyncTaskCreateResult: existing = await _find_active_analysis_task(db, relation_type=relation_type, relation_entity_id=relation_entity_id) if existing is not None: status_value = existing.status.value if hasattr(existing.status, "value") else str(existing.status) return AsyncTaskCreateResult( task_id=existing.id, status=TaskStatus(status_value), reused=True, relation_type=relation_type, relation_entity_id=relation_entity_id, ) store = SqlAlchemyTaskStore(db) tm = TaskManager(store=store, strategies={}) task_record = await tm.create( task=_CreateOnlyTask(), mode=DeliveryMode.async_polling, task_kind=task_kind, run_args=run_args, ) db.add( GenerationTaskLink( task_id=task_record.id, resource_type="task_link", relation_type=relation_type, relation_entity_id=relation_entity_id, ) ) await db.flush() return AsyncTaskCreateResult( task_id=task_record.id, status=task_record.status, reused=False, relation_type=relation_type, relation_entity_id=relation_entity_id, ) async def create_character_portrait_task( db: AsyncSession, *, relation_entity_id: str, character_context: str | None, character_description: str, ) -> AsyncTaskCreateResult: return await _create_analysis_task( db, task_kind=SCRIPT_CHARACTER_PORTRAIT_TASK_KIND, relation_type=CHARACTER_PORTRAIT_ANALYSIS_RELATION_TYPE, relation_entity_id=relation_entity_id, run_args={ "character_context": character_context, "character_description": character_description, }, ) async def create_prop_info_task( db: AsyncSession, *, relation_entity_id: str, prop_context: str | None, prop_description: str, ) -> AsyncTaskCreateResult: return await _create_analysis_task( db, task_kind=SCRIPT_PROP_INFO_TASK_KIND, relation_type=PROP_INFO_ANALYSIS_RELATION_TYPE, relation_entity_id=relation_entity_id, run_args={ "prop_context": prop_context, "prop_description": prop_description, }, ) async def create_scene_info_task( db: AsyncSession, *, relation_entity_id: str, scene_context: str | None, scene_description: str, ) -> AsyncTaskCreateResult: return await _create_analysis_task( db, task_kind=SCRIPT_SCENE_INFO_TASK_KIND, relation_type=SCENE_INFO_ANALYSIS_RELATION_TYPE, relation_entity_id=relation_entity_id, run_args={ "scene_context": scene_context, "scene_description": scene_description, }, ) async def create_costume_info_task( db: AsyncSession, *, relation_entity_id: str, costume_context: str | None, costume_description: str, ) -> AsyncTaskCreateResult: return await _create_analysis_task( db, task_kind=SCRIPT_COSTUME_INFO_TASK_KIND, relation_type=COSTUME_INFO_ANALYSIS_RELATION_TYPE, relation_entity_id=relation_entity_id, run_args={ "costume_context": costume_context, "costume_description": costume_description, }, ) async def create_script_optimization_task( db: AsyncSession, *, relation_entity_id: str, script_text: str, consistency: dict, ) -> AsyncTaskCreateResult: return await _create_analysis_task( db, task_kind=SCRIPT_OPTIMIZE_TASK_KIND, relation_type=SCRIPT_OPTIMIZATION_RELATION_TYPE, relation_entity_id=relation_entity_id, run_args={ "script_text": script_text, "consistency": consistency, }, ) async def create_script_simplification_task( db: AsyncSession, *, relation_entity_id: str, script_text: str, ) -> AsyncTaskCreateResult: return await _create_analysis_task( db, task_kind=SCRIPT_SIMPLIFY_TASK_KIND, relation_type=SCRIPT_SIMPLIFICATION_RELATION_TYPE, relation_entity_id=relation_entity_id, run_args={ "script_text": script_text, }, ) def spawn_extract_task(task_id: str) -> None: from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_id) async def run_merge_task(task_id: str) -> None: async with async_session_maker() as db: store = SqlAlchemyTaskStore(db) task = await store.get(task_id) if task is None: logger.warning("merge task not found: %s", task_id) return if await _cancel_if_requested(store, task_id, db): return await store.set_status(task_id, TaskStatus.running) await store.set_progress(task_id, 5) await db.commit() run_args = task.payload.get("run_args") or {} try: async with async_session_maker() as db: store = SqlAlchemyTaskStore(db) if await _cancel_if_requested(store, task_id, db): return llm = await get_llm(db) agent = EntityMergerAgent(llm) result: EntityMergeResult = agent.extract( all_extractions_json=json.dumps(run_args.get("all_shot_extractions") or [], ensure_ascii=False), historical_library_json=json.dumps(run_args.get("historical_library") or {}, ensure_ascii=False), script_division_json=json.dumps(run_args.get("script_division") or {}, ensure_ascii=False), previous_merge_json=json.dumps(run_args.get("previous_merge") or {}, ensure_ascii=False), conflict_resolutions_json=json.dumps(run_args.get("conflict_resolutions") or [], ensure_ascii=False), ) await store.set_progress(task_id, 100) await store.set_result(task_id, result.model_dump()) if await _cancel_if_requested(store, task_id, db): return await store.set_status(task_id, TaskStatus.succeeded) await db.commit() except Exception as exc: # noqa: BLE001 logger.exception("merge task failed: %s", task_id) async with async_session_maker() as db: store = SqlAlchemyTaskStore(db) await store.set_error(task_id, str(exc)) await store.set_status(task_id, TaskStatus.failed) await db.commit() def spawn_merge_task(task_id: str) -> None: asyncio.create_task(run_merge_task(task_id)) def spawn_consistency_task(task_id: str) -> None: from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_id) async def run_variant_task(task_id: str) -> None: async with async_session_maker() as db: store = SqlAlchemyTaskStore(db) task = await store.get(task_id) if task is None: logger.warning("variant task not found: %s", task_id) return if await _cancel_if_requested(store, task_id, db): return await store.set_status(task_id, TaskStatus.running) await store.set_progress(task_id, 5) await db.commit() run_args = task.payload.get("run_args") or {} try: async with async_session_maker() as db: store = SqlAlchemyTaskStore(db) if await _cancel_if_requested(store, task_id, db): return llm = await get_llm(db) agent = VariantAnalyzerAgent(llm) result: VariantAnalysisResult = agent.extract( merged_library_json=json.dumps(run_args.get("merged_library") or {}, ensure_ascii=False), all_extractions_json=json.dumps(run_args.get("all_shot_extractions") or [], ensure_ascii=False), script_division_json=json.dumps(run_args.get("script_division") or {}, ensure_ascii=False), ) await store.set_progress(task_id, 100) await store.set_result(task_id, result.model_dump()) if await _cancel_if_requested(store, task_id, db): return await store.set_status(task_id, TaskStatus.succeeded) await db.commit() except Exception as exc: # noqa: BLE001 logger.exception("variant task failed: %s", task_id) async with async_session_maker() as db: store = SqlAlchemyTaskStore(db) await store.set_error(task_id, str(exc)) await store.set_status(task_id, TaskStatus.failed) await db.commit() def spawn_variant_task(task_id: str) -> None: asyncio.create_task(run_variant_task(task_id)) def spawn_character_portrait_task(task_id: str) -> None: from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_id) def spawn_prop_info_task(task_id: str) -> None: from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_id) def spawn_scene_info_task(task_id: str) -> None: from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_id) def spawn_costume_info_task(task_id: str) -> None: from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_id) def spawn_script_optimization_task(task_id: str) -> None: from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_id) def spawn_script_simplification_task(task_id: str) -> None: from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_id)