Files
Jellyfish/backend/app/services/script_processing_tasks.py

776 lines
25 KiB
Python

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)