Add generation harness runtime
This commit is contained in:
2
backend/app/services/harness/__init__.py
Normal file
2
backend/app/services/harness/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Generation harness runtime support."""
|
||||
|
||||
37
backend/app/services/harness/artifacts.py
Normal file
37
backend/app/services/harness/artifacts.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Artifact result types for generation harness workflows."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from app.services.story_status import StoryAssetStatus
|
||||
|
||||
AssetCompletionKind = Literal["cover_image", "storybook_images", "audio"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetCompletionResult:
|
||||
"""Service-level result for a generated asset completion attempt."""
|
||||
|
||||
asset: AssetCompletionKind
|
||||
status: StoryAssetStatus
|
||||
value: str | bytes | None = None
|
||||
error: str | None = None
|
||||
blocks_main_result: bool = False
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
"""Whether the asset reached a usable ready state."""
|
||||
|
||||
return self.status == StoryAssetStatus.READY and self.error is None
|
||||
|
||||
|
||||
def asset_result_metadata(result: AssetCompletionResult) -> dict:
|
||||
"""Build JSON-safe metadata for asset workflow events."""
|
||||
|
||||
return {
|
||||
"asset": result.asset,
|
||||
"status": result.status.value,
|
||||
"error": result.error,
|
||||
"blocks_main_result": result.blocks_main_result,
|
||||
}
|
||||
|
||||
468
backend/app/services/harness/asset_workflows.py
Normal file
468
backend/app/services/harness/asset_workflows.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""Artifact completion workflows for the generation harness runtime."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.models import Story
|
||||
from app.services.harness.artifacts import AssetCompletionResult, asset_result_metadata
|
||||
from app.services.harness.control import ExecutionControl
|
||||
from app.services.harness.trace import TraceRecorder
|
||||
from app.services.story_status import StoryAssetStatus, sync_story_status
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
ImageGenerator = Callable[..., Awaitable[str]]
|
||||
TTSGenerator = Callable[..., Awaitable[bytes]]
|
||||
AudioCacheExists = Callable[[str], bool]
|
||||
AudioCacheReader = Callable[[str], bytes]
|
||||
AudioCacheWriter = Callable[[int, bytes], str]
|
||||
|
||||
|
||||
async def complete_cover_image_asset(
|
||||
story: Story,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
generate_image_func: ImageGenerator,
|
||||
raise_on_failure: bool = False,
|
||||
last_error_prefix: str | None = None,
|
||||
log_event: str = "cover_asset_generation_failed",
|
||||
job=None,
|
||||
) -> AssetCompletionResult:
|
||||
"""Generate or retry a text story cover through one asset workflow."""
|
||||
|
||||
if not story.cover_prompt:
|
||||
raise HTTPException(status_code=400, detail="Story has no cover prompt")
|
||||
|
||||
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
|
||||
await db.commit()
|
||||
await ExecutionControl(db).stop_if_cancel_requested(job=job, story=story)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="cover_image_started",
|
||||
status="running",
|
||||
message="Cover image generation started.",
|
||||
metadata={"asset": "image", "cover_prompt_present": True},
|
||||
)
|
||||
|
||||
try:
|
||||
await ExecutionControl(db).stop_if_cancel_requested(job=job, story=story)
|
||||
image_url = await generate_image_func(
|
||||
story.cover_prompt,
|
||||
db=db,
|
||||
user_id=story.user_id,
|
||||
generation_job=job,
|
||||
story_id=story.id,
|
||||
)
|
||||
story.image_url = image_url
|
||||
sync_story_status(story, image_status=StoryAssetStatus.READY)
|
||||
await db.commit()
|
||||
result = AssetCompletionResult(
|
||||
asset="cover_image",
|
||||
status=StoryAssetStatus.READY,
|
||||
value=image_url,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="cover_image_succeeded",
|
||||
status="succeeded",
|
||||
message="Cover image was generated.",
|
||||
metadata=asset_result_metadata(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
provider_error = str(exc)
|
||||
last_error = (
|
||||
f"{last_error_prefix}: {provider_error}"
|
||||
if last_error_prefix
|
||||
else provider_error
|
||||
)
|
||||
sync_story_status(
|
||||
story,
|
||||
image_status=StoryAssetStatus.FAILED,
|
||||
last_error=last_error,
|
||||
)
|
||||
await db.commit()
|
||||
logger.warning(log_event, story_id=story.id, error=provider_error)
|
||||
|
||||
result = AssetCompletionResult(
|
||||
asset="cover_image",
|
||||
status=StoryAssetStatus.FAILED,
|
||||
error=provider_error,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="cover_image_failed",
|
||||
status="failed",
|
||||
message="Cover image generation failed.",
|
||||
metadata=asset_result_metadata(result),
|
||||
)
|
||||
if raise_on_failure:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Image generation failed: {provider_error}",
|
||||
) from exc
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def read_cached_audio_asset(
|
||||
story: Story,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
audio_cache_exists_func: AudioCacheExists,
|
||||
read_audio_cache_func: AudioCacheReader,
|
||||
) -> bytes | None:
|
||||
"""Read cached audio or repair stale audio cache metadata."""
|
||||
|
||||
if story.audio_path and audio_cache_exists_func(story.audio_path):
|
||||
if story.audio_status != StoryAssetStatus.READY.value:
|
||||
sync_story_status(story, audio_status=StoryAssetStatus.READY)
|
||||
await db.commit()
|
||||
return read_audio_cache_func(story.audio_path)
|
||||
|
||||
if story.audio_path and not audio_cache_exists_func(story.audio_path):
|
||||
logger.warning(
|
||||
"story_audio_cache_missing",
|
||||
story_id=story.id,
|
||||
audio_path=story.audio_path,
|
||||
)
|
||||
story.audio_path = None
|
||||
if story.audio_status == StoryAssetStatus.READY.value:
|
||||
sync_story_status(story, audio_status=StoryAssetStatus.NOT_REQUESTED)
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def complete_audio_asset(
|
||||
story: Story,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
text_to_speech_func: TTSGenerator,
|
||||
audio_cache_exists_func: AudioCacheExists,
|
||||
read_audio_cache_func: AudioCacheReader,
|
||||
write_story_audio_cache_func: AudioCacheWriter,
|
||||
raise_on_failure: bool = True,
|
||||
job=None,
|
||||
) -> AssetCompletionResult:
|
||||
"""Complete TTS audio generation through one asset workflow."""
|
||||
|
||||
if not story.story_text:
|
||||
raise HTTPException(status_code=400, detail="Story has no text")
|
||||
|
||||
cached_audio = await read_cached_audio_asset(
|
||||
story,
|
||||
db,
|
||||
audio_cache_exists_func=audio_cache_exists_func,
|
||||
read_audio_cache_func=read_audio_cache_func,
|
||||
)
|
||||
if cached_audio is not None:
|
||||
result = AssetCompletionResult(
|
||||
asset="audio",
|
||||
status=StoryAssetStatus.READY,
|
||||
value=cached_audio,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="audio_cache_hit",
|
||||
status="succeeded",
|
||||
message="Cached story audio was reused.",
|
||||
metadata=asset_result_metadata(result),
|
||||
)
|
||||
return result
|
||||
|
||||
sync_story_status(story, audio_status=StoryAssetStatus.GENERATING)
|
||||
await db.commit()
|
||||
await ExecutionControl(db).stop_if_cancel_requested(job=job, story=story)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="audio_started",
|
||||
status="running",
|
||||
message="Story audio generation started.",
|
||||
metadata={"asset": "audio"},
|
||||
)
|
||||
|
||||
try:
|
||||
await ExecutionControl(db).stop_if_cancel_requested(job=job, story=story)
|
||||
audio_data = await text_to_speech_func(
|
||||
story.story_text,
|
||||
db=db,
|
||||
user_id=story.user_id,
|
||||
generation_job=job,
|
||||
story_id=story.id,
|
||||
)
|
||||
story.audio_path = write_story_audio_cache_func(story.id, audio_data)
|
||||
sync_story_status(
|
||||
story,
|
||||
audio_status=StoryAssetStatus.READY,
|
||||
)
|
||||
await db.commit()
|
||||
result = AssetCompletionResult(
|
||||
asset="audio",
|
||||
status=StoryAssetStatus.READY,
|
||||
value=audio_data,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="audio_succeeded",
|
||||
status="succeeded",
|
||||
message="Story audio was generated and cached.",
|
||||
metadata=asset_result_metadata(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
provider_error = str(exc)
|
||||
story.audio_path = None
|
||||
sync_story_status(
|
||||
story,
|
||||
audio_status=StoryAssetStatus.FAILED,
|
||||
last_error=provider_error,
|
||||
)
|
||||
await db.commit()
|
||||
logger.error("audio_generation_failed", story_id=story.id, error=provider_error)
|
||||
|
||||
result = AssetCompletionResult(
|
||||
asset="audio",
|
||||
status=StoryAssetStatus.FAILED,
|
||||
error=provider_error,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="audio_failed",
|
||||
status="failed",
|
||||
message="Story audio generation failed.",
|
||||
metadata=asset_result_metadata(result),
|
||||
)
|
||||
if raise_on_failure:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Audio generation failed: {provider_error}",
|
||||
) from exc
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_storybook_pages_data(story: Story) -> list[dict]:
|
||||
"""Return mutable storybook page data from the persisted JSON field."""
|
||||
|
||||
return [dict(page) for page in story.pages or [] if isinstance(page, dict)]
|
||||
|
||||
|
||||
def build_storybook_error_message(
|
||||
*,
|
||||
cover_failed: bool,
|
||||
failed_pages: list[int],
|
||||
) -> str | None:
|
||||
"""Summarize storybook image generation errors for the latest attempt."""
|
||||
|
||||
parts: list[str] = []
|
||||
if cover_failed:
|
||||
parts.append("封面生成失败")
|
||||
if failed_pages:
|
||||
pages = "、".join(str(page) for page in sorted(failed_pages))
|
||||
parts.append(f"第 {pages} 页插图生成失败")
|
||||
return ";".join(parts) if parts else None
|
||||
|
||||
|
||||
def resolve_storybook_image_status(
|
||||
*,
|
||||
generate_images: bool,
|
||||
cover_prompt: str | None,
|
||||
cover_url: str | None,
|
||||
pages_data: list[dict],
|
||||
) -> StoryAssetStatus:
|
||||
"""Resolve the persisted image status for a storybook."""
|
||||
|
||||
if not generate_images:
|
||||
return StoryAssetStatus.NOT_REQUESTED
|
||||
|
||||
expected_assets = 0
|
||||
ready_assets = 0
|
||||
|
||||
if cover_prompt or cover_url:
|
||||
expected_assets += 1
|
||||
if cover_url:
|
||||
ready_assets += 1
|
||||
|
||||
for page in pages_data:
|
||||
if not page.get("image_prompt") and not page.get("image_url"):
|
||||
continue
|
||||
expected_assets += 1
|
||||
if page.get("image_url"):
|
||||
ready_assets += 1
|
||||
|
||||
if expected_assets == 0:
|
||||
return StoryAssetStatus.NOT_REQUESTED
|
||||
|
||||
if ready_assets == expected_assets:
|
||||
return StoryAssetStatus.READY
|
||||
|
||||
return StoryAssetStatus.FAILED
|
||||
|
||||
|
||||
async def complete_storybook_image_assets(
|
||||
story: Story,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
generate_image_func: ImageGenerator,
|
||||
job=None,
|
||||
) -> AssetCompletionResult:
|
||||
"""Complete missing cover/page images for a persisted storybook."""
|
||||
|
||||
pages_data = get_storybook_pages_data(story)
|
||||
has_image_prompt = bool(story.cover_prompt) or any(
|
||||
page.get("image_prompt") for page in pages_data
|
||||
)
|
||||
if not has_image_prompt:
|
||||
raise HTTPException(status_code=400, detail="Storybook has no image prompts")
|
||||
|
||||
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
|
||||
await db.commit()
|
||||
await ExecutionControl(db).stop_if_cancel_requested(job=job, story=story)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_images_started",
|
||||
status="running",
|
||||
message="Storybook missing image completion started.",
|
||||
metadata={"asset": "image"},
|
||||
)
|
||||
|
||||
cover_failed = False
|
||||
failed_pages: list[int] = []
|
||||
completed_pages: list[int] = []
|
||||
|
||||
if story.cover_prompt and not story.image_url:
|
||||
await ExecutionControl(db).stop_if_cancel_requested(job=job, story=story)
|
||||
try:
|
||||
story.image_url = await generate_image_func(
|
||||
story.cover_prompt,
|
||||
db=db,
|
||||
user_id=story.user_id,
|
||||
generation_job=job,
|
||||
story_id=story.id,
|
||||
)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_cover_image_succeeded",
|
||||
status="succeeded",
|
||||
message="Storybook cover image was generated.",
|
||||
metadata={"asset": "image", "scope": "cover"},
|
||||
)
|
||||
except Exception as exc:
|
||||
cover_failed = True
|
||||
logger.warning(
|
||||
"storybook_cover_asset_completion_failed",
|
||||
story_id=story.id,
|
||||
error=str(exc),
|
||||
)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_cover_image_failed",
|
||||
status="failed",
|
||||
message="Storybook cover image generation failed.",
|
||||
metadata={"asset": "image", "scope": "cover", "error": str(exc)},
|
||||
)
|
||||
|
||||
for page in pages_data:
|
||||
if not page.get("image_prompt") or page.get("image_url"):
|
||||
continue
|
||||
|
||||
await ExecutionControl(db).stop_if_cancel_requested(job=job, story=story)
|
||||
try:
|
||||
page["image_url"] = await generate_image_func(
|
||||
page["image_prompt"],
|
||||
db=db,
|
||||
user_id=story.user_id,
|
||||
generation_job=job,
|
||||
story_id=story.id,
|
||||
)
|
||||
page_number = page.get("page_number")
|
||||
if isinstance(page_number, int):
|
||||
completed_pages.append(page_number)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_page_image_succeeded",
|
||||
status="succeeded",
|
||||
message="Storybook page image was generated.",
|
||||
metadata={"asset": "image", "scope": "page", "page_number": page_number},
|
||||
)
|
||||
except Exception as exc:
|
||||
page_number = page.get("page_number")
|
||||
if isinstance(page_number, int):
|
||||
failed_pages.append(page_number)
|
||||
logger.warning(
|
||||
"storybook_page_asset_completion_failed",
|
||||
story_id=story.id,
|
||||
page=page_number,
|
||||
error=str(exc),
|
||||
)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_page_image_failed",
|
||||
status="failed",
|
||||
message="Storybook page image generation failed.",
|
||||
metadata={
|
||||
"asset": "image",
|
||||
"scope": "page",
|
||||
"page_number": page_number,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
story.pages = pages_data
|
||||
error_message = build_storybook_error_message(
|
||||
cover_failed=cover_failed,
|
||||
failed_pages=failed_pages,
|
||||
)
|
||||
image_status = resolve_storybook_image_status(
|
||||
generate_images=True,
|
||||
cover_prompt=story.cover_prompt,
|
||||
cover_url=story.image_url,
|
||||
pages_data=pages_data,
|
||||
)
|
||||
sync_story_status(
|
||||
story,
|
||||
image_status=image_status,
|
||||
last_error=error_message,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
result = AssetCompletionResult(
|
||||
asset="storybook_images",
|
||||
status=image_status,
|
||||
value=story.image_url,
|
||||
error=error_message,
|
||||
)
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_images_completed",
|
||||
status="failed" if error_message else "succeeded",
|
||||
message="Storybook image completion finished.",
|
||||
metadata={
|
||||
**asset_result_metadata(result),
|
||||
"completed_pages": sorted(completed_pages),
|
||||
"failed_pages": sorted(failed_pages),
|
||||
},
|
||||
)
|
||||
return result
|
||||
48
backend/app/services/harness/control.py
Normal file
48
backend/app/services/harness/control.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Execution control helpers for generation harness workflows."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.generation_jobs import finish_generation_job
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.db.models import GenerationJob, Story
|
||||
|
||||
|
||||
class GenerationJobCanceledError(Exception):
|
||||
"""Raised when a running worker job has been canceled by the user."""
|
||||
|
||||
|
||||
class ExecutionControl:
|
||||
"""Runtime control surface for cancelable generation workflows."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def stop_if_cancel_requested(
|
||||
self,
|
||||
*,
|
||||
job: "GenerationJob | None",
|
||||
story: "Story | None" = None,
|
||||
) -> None:
|
||||
"""Stop a worker-owned job at the next safe checkpoint after cancellation."""
|
||||
|
||||
if job is None:
|
||||
return
|
||||
|
||||
await self.db.refresh(job)
|
||||
if job.current_step != "cancel_requested":
|
||||
return
|
||||
|
||||
await finish_generation_job(
|
||||
self.db,
|
||||
job=job,
|
||||
story=story,
|
||||
status="canceled",
|
||||
current_step="generation_canceled",
|
||||
error_message="Generation canceled by user.",
|
||||
message="Generation job was canceled after a user request.",
|
||||
)
|
||||
raise GenerationJobCanceledError()
|
||||
|
||||
237
backend/app/services/harness/plans.py
Normal file
237
backend/app/services/harness/plans.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Workflow plan builders for generation harness workflows."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from app.services.harness.types import ArtifactKind, WorkflowStep
|
||||
|
||||
|
||||
class WorkflowMode(StrEnum):
|
||||
"""Supported executable workflow modes."""
|
||||
|
||||
STORY = "story"
|
||||
STORY_WITH_ASSETS = "story_with_assets"
|
||||
STORYBOOK = "storybook"
|
||||
ASSET_GENERATION = "asset_generation"
|
||||
ASSET_RETRY = "asset_retry"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowTask:
|
||||
"""One planned step in a generation workflow."""
|
||||
|
||||
key: str
|
||||
step: WorkflowStep
|
||||
artifact: ArtifactKind
|
||||
required: bool = True
|
||||
recoverable: bool = False
|
||||
|
||||
def to_snapshot(self) -> dict[str, Any]:
|
||||
"""Return a JSON-safe snapshot for tests and trace metadata."""
|
||||
|
||||
return {
|
||||
"key": self.key,
|
||||
"step": self.step.value,
|
||||
"artifact": self.artifact.value,
|
||||
"required": self.required,
|
||||
"recoverable": self.recoverable,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowPlan:
|
||||
"""Declarative shape of a generation workflow before execution."""
|
||||
|
||||
mode: WorkflowMode
|
||||
tasks: tuple[WorkflowTask, ...]
|
||||
|
||||
def to_snapshot(self) -> dict[str, Any]:
|
||||
"""Return a JSON-safe snapshot for tests and trace metadata."""
|
||||
|
||||
return {
|
||||
"mode": self.mode.value,
|
||||
"tasks": [task.to_snapshot() for task in self.tasks],
|
||||
}
|
||||
|
||||
|
||||
def build_story_plan(*, generate_images: bool) -> WorkflowPlan:
|
||||
"""Build a plan for a text story generation request."""
|
||||
|
||||
tasks = [
|
||||
WorkflowTask(
|
||||
key="prepare_context",
|
||||
step=WorkflowStep.CONTEXT_PREPARATION,
|
||||
artifact=ArtifactKind.NONE,
|
||||
),
|
||||
WorkflowTask(
|
||||
key="generate_narrative",
|
||||
step=WorkflowStep.NARRATIVE_GENERATION,
|
||||
artifact=ArtifactKind.STORY_TEXT,
|
||||
),
|
||||
WorkflowTask(
|
||||
key="persist_story",
|
||||
step=WorkflowStep.STORY_PERSISTENCE,
|
||||
artifact=ArtifactKind.STORY_TEXT,
|
||||
),
|
||||
]
|
||||
|
||||
if generate_images:
|
||||
tasks.append(
|
||||
WorkflowTask(
|
||||
key="generate_cover_image",
|
||||
step=WorkflowStep.IMAGE_GENERATION,
|
||||
artifact=ArtifactKind.COVER_IMAGE,
|
||||
required=False,
|
||||
recoverable=True,
|
||||
)
|
||||
)
|
||||
|
||||
tasks.extend(
|
||||
[
|
||||
WorkflowTask(
|
||||
key="queue_postprocessing",
|
||||
step=WorkflowStep.POSTPROCESSING,
|
||||
artifact=ArtifactKind.ACHIEVEMENT_MEMORY,
|
||||
required=False,
|
||||
recoverable=True,
|
||||
),
|
||||
WorkflowTask(
|
||||
key="complete_generation",
|
||||
step=WorkflowStep.COMPLETION,
|
||||
artifact=ArtifactKind.NONE,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
return WorkflowPlan(
|
||||
mode=WorkflowMode.STORY_WITH_ASSETS if generate_images else WorkflowMode.STORY,
|
||||
tasks=tuple(tasks),
|
||||
)
|
||||
|
||||
|
||||
def build_storybook_plan(*, generate_images: bool) -> WorkflowPlan:
|
||||
"""Build a plan for a storybook generation request."""
|
||||
|
||||
tasks = [
|
||||
WorkflowTask(
|
||||
key="prepare_context",
|
||||
step=WorkflowStep.CONTEXT_PREPARATION,
|
||||
artifact=ArtifactKind.NONE,
|
||||
),
|
||||
WorkflowTask(
|
||||
key="generate_storybook_pages",
|
||||
step=WorkflowStep.NARRATIVE_GENERATION,
|
||||
artifact=ArtifactKind.STORYBOOK_PAGES,
|
||||
),
|
||||
]
|
||||
|
||||
if generate_images:
|
||||
tasks.append(
|
||||
WorkflowTask(
|
||||
key="generate_storybook_images",
|
||||
step=WorkflowStep.IMAGE_GENERATION,
|
||||
artifact=ArtifactKind.IMAGE,
|
||||
required=False,
|
||||
recoverable=True,
|
||||
)
|
||||
)
|
||||
|
||||
tasks.extend(
|
||||
[
|
||||
WorkflowTask(
|
||||
key="persist_storybook",
|
||||
step=WorkflowStep.STORY_PERSISTENCE,
|
||||
artifact=ArtifactKind.STORYBOOK_PAGES,
|
||||
),
|
||||
WorkflowTask(
|
||||
key="queue_postprocessing",
|
||||
step=WorkflowStep.POSTPROCESSING,
|
||||
artifact=ArtifactKind.ACHIEVEMENT_MEMORY,
|
||||
required=False,
|
||||
recoverable=True,
|
||||
),
|
||||
WorkflowTask(
|
||||
key="complete_generation",
|
||||
step=WorkflowStep.COMPLETION,
|
||||
artifact=ArtifactKind.NONE,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
return WorkflowPlan(mode=WorkflowMode.STORYBOOK, tasks=tuple(tasks))
|
||||
|
||||
|
||||
def build_asset_plan(*, output_mode: str, assets: list[str]) -> WorkflowPlan:
|
||||
"""Build a plan for asset generation or retry jobs."""
|
||||
|
||||
mode = (
|
||||
WorkflowMode.ASSET_RETRY
|
||||
if output_mode == WorkflowMode.ASSET_RETRY.value
|
||||
else WorkflowMode.ASSET_GENERATION
|
||||
)
|
||||
initial_step = (
|
||||
WorkflowStep.ASSET_RETRY
|
||||
if mode == WorkflowMode.ASSET_RETRY
|
||||
else WorkflowStep.ASSET_GENERATION
|
||||
)
|
||||
initial_key = (
|
||||
"start_asset_retry"
|
||||
if mode == WorkflowMode.ASSET_RETRY
|
||||
else "start_asset_generation"
|
||||
)
|
||||
completion_key = (
|
||||
"complete_asset_retry"
|
||||
if mode == WorkflowMode.ASSET_RETRY
|
||||
else "complete_asset_generation"
|
||||
)
|
||||
|
||||
tasks = [
|
||||
WorkflowTask(
|
||||
key=initial_key,
|
||||
step=initial_step,
|
||||
artifact=ArtifactKind.NONE,
|
||||
)
|
||||
]
|
||||
|
||||
for asset in dict.fromkeys(assets):
|
||||
if asset == "image":
|
||||
tasks.append(
|
||||
WorkflowTask(
|
||||
key="complete_image_asset",
|
||||
step=WorkflowStep.IMAGE_GENERATION,
|
||||
artifact=ArtifactKind.IMAGE,
|
||||
required=False,
|
||||
recoverable=True,
|
||||
)
|
||||
)
|
||||
elif asset == "audio":
|
||||
tasks.append(
|
||||
WorkflowTask(
|
||||
key="complete_audio_asset",
|
||||
step=WorkflowStep.AUDIO_GENERATION,
|
||||
artifact=ArtifactKind.AUDIO,
|
||||
required=False,
|
||||
recoverable=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
tasks.append(
|
||||
WorkflowTask(
|
||||
key=f"complete_{asset}_asset",
|
||||
step=WorkflowStep.UNKNOWN,
|
||||
artifact=ArtifactKind.UNKNOWN,
|
||||
required=False,
|
||||
recoverable=True,
|
||||
)
|
||||
)
|
||||
|
||||
tasks.append(
|
||||
WorkflowTask(
|
||||
key=completion_key,
|
||||
step=initial_step,
|
||||
artifact=ArtifactKind.NONE,
|
||||
)
|
||||
)
|
||||
|
||||
return WorkflowPlan(mode=mode, tasks=tuple(tasks))
|
||||
191
backend/app/services/harness/quality_gates.py
Normal file
191
backend/app/services/harness/quality_gates.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Deterministic quality gates for generated child-facing content."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from app.services.adapters.storybook.primary import Storybook
|
||||
from app.services.adapters.text.models import StoryOutput
|
||||
from app.services.harness.types import FailureCategory
|
||||
|
||||
|
||||
class QualityGateCode(StrEnum):
|
||||
"""Stable issue codes emitted by deterministic quality gates."""
|
||||
|
||||
MISSING_TITLE = "missing_title"
|
||||
MISSING_STORY_TEXT = "missing_story_text"
|
||||
MISSING_COVER_PROMPT = "missing_cover_prompt"
|
||||
MISSING_STORYBOOK_PAGE = "missing_storybook_page"
|
||||
INVALID_STORYBOOK_PAGE_NUMBER = "invalid_storybook_page_number"
|
||||
MISSING_STORYBOOK_PAGE_TEXT = "missing_storybook_page_text"
|
||||
UNSAFE_CHILD_CONTENT = "unsafe_child_content"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QualityGateIssue:
|
||||
"""One deterministic quality gate issue."""
|
||||
|
||||
code: QualityGateCode
|
||||
message: str
|
||||
failure_category: FailureCategory = FailureCategory.SCHEMA_ERROR
|
||||
field: str | None = None
|
||||
|
||||
def to_metadata(self) -> dict:
|
||||
"""Return a JSON-safe metadata payload."""
|
||||
|
||||
return {
|
||||
"code": self.code.value,
|
||||
"message": self.message,
|
||||
"failure_category": self.failure_category.value,
|
||||
"field": self.field,
|
||||
}
|
||||
|
||||
|
||||
class QualityGateError(ValueError):
|
||||
"""Raised when generated content fails deterministic quality gates."""
|
||||
|
||||
def __init__(self, issues: list[QualityGateIssue]):
|
||||
self.issues = issues
|
||||
message = ";".join(issue.message for issue in issues)
|
||||
super().__init__(message)
|
||||
|
||||
def to_metadata(self) -> dict:
|
||||
"""Return a JSON-safe metadata payload."""
|
||||
|
||||
return {"issues": [issue.to_metadata() for issue in self.issues]}
|
||||
|
||||
|
||||
UNSAFE_CHILD_TERMS = (
|
||||
"自杀",
|
||||
"自残",
|
||||
"血腥",
|
||||
"虐待",
|
||||
"毒品",
|
||||
"色情",
|
||||
)
|
||||
|
||||
|
||||
def _is_blank(value: str | None) -> bool:
|
||||
return not value or not value.strip()
|
||||
|
||||
|
||||
def _unsafe_issue_if_present(text: str, *, field: str) -> QualityGateIssue | None:
|
||||
for term in UNSAFE_CHILD_TERMS:
|
||||
if term in text:
|
||||
return QualityGateIssue(
|
||||
code=QualityGateCode.UNSAFE_CHILD_CONTENT,
|
||||
message="生成内容包含不适合 3-8 岁儿童的明显风险词。",
|
||||
failure_category=FailureCategory.SAFETY_ERROR,
|
||||
field=field,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def validate_story_output(output: StoryOutput) -> None:
|
||||
"""Validate generated text story output before persistence."""
|
||||
|
||||
issues: list[QualityGateIssue] = []
|
||||
|
||||
if _is_blank(output.title):
|
||||
issues.append(
|
||||
QualityGateIssue(
|
||||
code=QualityGateCode.MISSING_TITLE,
|
||||
message="故事标题为空。",
|
||||
field="title",
|
||||
)
|
||||
)
|
||||
|
||||
if _is_blank(output.story_text):
|
||||
issues.append(
|
||||
QualityGateIssue(
|
||||
code=QualityGateCode.MISSING_STORY_TEXT,
|
||||
message="故事正文为空。",
|
||||
field="story_text",
|
||||
)
|
||||
)
|
||||
|
||||
if _is_blank(output.cover_prompt_suggestion):
|
||||
issues.append(
|
||||
QualityGateIssue(
|
||||
code=QualityGateCode.MISSING_COVER_PROMPT,
|
||||
message="封面提示词为空。",
|
||||
field="cover_prompt_suggestion",
|
||||
)
|
||||
)
|
||||
|
||||
unsafe_issue = _unsafe_issue_if_present(
|
||||
" ".join([output.title or "", output.story_text or ""]),
|
||||
field="story_text",
|
||||
)
|
||||
if unsafe_issue is not None:
|
||||
issues.append(unsafe_issue)
|
||||
|
||||
if issues:
|
||||
raise QualityGateError(issues)
|
||||
|
||||
|
||||
def validate_storybook_output(output: Storybook) -> None:
|
||||
"""Validate generated storybook output before persistence."""
|
||||
|
||||
issues: list[QualityGateIssue] = []
|
||||
|
||||
if _is_blank(output.title):
|
||||
issues.append(
|
||||
QualityGateIssue(
|
||||
code=QualityGateCode.MISSING_TITLE,
|
||||
message="绘本标题为空。",
|
||||
field="title",
|
||||
)
|
||||
)
|
||||
|
||||
if not output.pages:
|
||||
issues.append(
|
||||
QualityGateIssue(
|
||||
code=QualityGateCode.MISSING_STORYBOOK_PAGE,
|
||||
message="绘本至少需要一页内容。",
|
||||
field="pages",
|
||||
)
|
||||
)
|
||||
|
||||
seen_page_numbers: set[int] = set()
|
||||
page_texts: list[str] = []
|
||||
for index, page in enumerate(output.pages, start=1):
|
||||
if not isinstance(page.page_number, int) or page.page_number <= 0:
|
||||
issues.append(
|
||||
QualityGateIssue(
|
||||
code=QualityGateCode.INVALID_STORYBOOK_PAGE_NUMBER,
|
||||
message=f"绘本第 {index} 个页面页码无效。",
|
||||
field=f"pages[{index - 1}].page_number",
|
||||
)
|
||||
)
|
||||
elif page.page_number in seen_page_numbers:
|
||||
issues.append(
|
||||
QualityGateIssue(
|
||||
code=QualityGateCode.INVALID_STORYBOOK_PAGE_NUMBER,
|
||||
message=f"绘本页码 {page.page_number} 重复。",
|
||||
field=f"pages[{index - 1}].page_number",
|
||||
)
|
||||
)
|
||||
else:
|
||||
seen_page_numbers.add(page.page_number)
|
||||
|
||||
if _is_blank(page.text):
|
||||
issues.append(
|
||||
QualityGateIssue(
|
||||
code=QualityGateCode.MISSING_STORYBOOK_PAGE_TEXT,
|
||||
message=f"绘本第 {index} 页正文为空。",
|
||||
field=f"pages[{index - 1}].text",
|
||||
)
|
||||
)
|
||||
else:
|
||||
page_texts.append(page.text)
|
||||
|
||||
unsafe_issue = _unsafe_issue_if_present(
|
||||
" ".join([output.title or "", *page_texts]),
|
||||
field="pages",
|
||||
)
|
||||
if unsafe_issue is not None:
|
||||
issues.append(unsafe_issue)
|
||||
|
||||
if issues:
|
||||
raise QualityGateError(issues)
|
||||
|
||||
64
backend/app/services/harness/trace.py
Normal file
64
backend/app/services/harness/trace.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Trace recording helpers for generation harness workflows."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.generation_jobs import record_generation_event
|
||||
from app.services.harness.types import (
|
||||
ArtifactKind,
|
||||
FailureCategory,
|
||||
WorkflowStep,
|
||||
normalize_trace_metadata,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.db.models import GenerationJob
|
||||
|
||||
|
||||
class TraceRecorder:
|
||||
"""Append workflow events with standard harness trace metadata."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def record_step(
|
||||
self,
|
||||
*,
|
||||
job: "GenerationJob | None",
|
||||
event_type: str,
|
||||
status: str,
|
||||
story_id: int | None = None,
|
||||
message: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
step: WorkflowStep | str | None = None,
|
||||
artifact: ArtifactKind | str | None = None,
|
||||
failure_category: FailureCategory | str | None = None,
|
||||
retryable: bool | None = None,
|
||||
blocks_main_result: bool | None = None,
|
||||
commit: bool = True,
|
||||
):
|
||||
"""Append a workflow event when the caller is running under a tracked job."""
|
||||
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
return await record_generation_event(
|
||||
self.db,
|
||||
job=job,
|
||||
story_id=story_id,
|
||||
event_type=event_type,
|
||||
status=status,
|
||||
message=message,
|
||||
metadata=normalize_trace_metadata(
|
||||
event_type,
|
||||
metadata,
|
||||
step=step,
|
||||
artifact=artifact,
|
||||
failure_category=failure_category,
|
||||
retryable=retryable,
|
||||
blocks_main_result=blocks_main_result,
|
||||
),
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
169
backend/app/services/harness/types.py
Normal file
169
backend/app/services/harness/types.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Shared types for the generation harness runtime."""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class WorkflowStep(StrEnum):
|
||||
"""Standard product-level steps for generation workflows."""
|
||||
|
||||
REQUEST_ACCEPTANCE = "request_acceptance"
|
||||
WORKER_START = "worker_start"
|
||||
CONTEXT_PREPARATION = "context_preparation"
|
||||
NARRATIVE_GENERATION = "narrative_generation"
|
||||
STORY_PERSISTENCE = "story_persistence"
|
||||
PROVIDER_INVOCATION = "provider_invocation"
|
||||
IMAGE_GENERATION = "image_generation"
|
||||
AUDIO_GENERATION = "audio_generation"
|
||||
ASSET_RETRY = "asset_retry"
|
||||
ASSET_GENERATION = "asset_generation"
|
||||
POSTPROCESSING = "postprocessing"
|
||||
COMPLETION = "completion"
|
||||
CANCELLATION = "cancellation"
|
||||
STALE_RECOVERY = "stale_recovery"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ArtifactKind(StrEnum):
|
||||
"""Artifacts produced or completed by generation workflows."""
|
||||
|
||||
STORY_TEXT = "story_text"
|
||||
STORYBOOK_PAGES = "storybook_pages"
|
||||
COVER_IMAGE = "cover_image"
|
||||
PAGE_IMAGE = "page_image"
|
||||
IMAGE = "image"
|
||||
AUDIO = "audio"
|
||||
ACHIEVEMENT_MEMORY = "achievement_memory"
|
||||
NONE = "none"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class FailureCategory(StrEnum):
|
||||
"""Coarse failure categories for trace and analytics metadata."""
|
||||
|
||||
PROVIDER_ERROR = "provider_error"
|
||||
SCHEMA_ERROR = "schema_error"
|
||||
SAFETY_ERROR = "safety_error"
|
||||
TIMEOUT = "timeout"
|
||||
CANCELED = "canceled"
|
||||
STALE_JOB = "stale_job"
|
||||
STORAGE_ERROR = "storage_error"
|
||||
VALIDATION_ERROR = "validation_error"
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
|
||||
class StepStatus(StrEnum):
|
||||
"""Standard status values for a workflow step."""
|
||||
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELED = "canceled"
|
||||
|
||||
|
||||
EVENT_STEP_MAP: dict[str, WorkflowStep] = {
|
||||
"request_accepted": WorkflowStep.REQUEST_ACCEPTANCE,
|
||||
"retry_queued": WorkflowStep.REQUEST_ACCEPTANCE,
|
||||
"worker_started": WorkflowStep.WORKER_START,
|
||||
"context_prepared": WorkflowStep.CONTEXT_PREPARATION,
|
||||
"narrative_generated": WorkflowStep.NARRATIVE_GENERATION,
|
||||
"story_saved": WorkflowStep.STORY_PERSISTENCE,
|
||||
"provider_call_started": WorkflowStep.PROVIDER_INVOCATION,
|
||||
"provider_call_succeeded": WorkflowStep.PROVIDER_INVOCATION,
|
||||
"provider_call_failed": WorkflowStep.PROVIDER_INVOCATION,
|
||||
"quality_gate_failed": WorkflowStep.NARRATIVE_GENERATION,
|
||||
"cover_image_started": WorkflowStep.IMAGE_GENERATION,
|
||||
"cover_image_succeeded": WorkflowStep.IMAGE_GENERATION,
|
||||
"cover_image_failed": WorkflowStep.IMAGE_GENERATION,
|
||||
"storybook_images_started": WorkflowStep.IMAGE_GENERATION,
|
||||
"storybook_cover_image_succeeded": WorkflowStep.IMAGE_GENERATION,
|
||||
"storybook_cover_image_failed": WorkflowStep.IMAGE_GENERATION,
|
||||
"storybook_page_image_succeeded": WorkflowStep.IMAGE_GENERATION,
|
||||
"storybook_page_image_failed": WorkflowStep.IMAGE_GENERATION,
|
||||
"storybook_images_completed": WorkflowStep.IMAGE_GENERATION,
|
||||
"audio_started": WorkflowStep.AUDIO_GENERATION,
|
||||
"audio_cache_hit": WorkflowStep.AUDIO_GENERATION,
|
||||
"audio_succeeded": WorkflowStep.AUDIO_GENERATION,
|
||||
"audio_failed": WorkflowStep.AUDIO_GENERATION,
|
||||
"asset_retry_started": WorkflowStep.ASSET_RETRY,
|
||||
"asset_retry_completed": WorkflowStep.ASSET_RETRY,
|
||||
"asset_generation_completed": WorkflowStep.ASSET_GENERATION,
|
||||
"postprocessing_queued": WorkflowStep.POSTPROCESSING,
|
||||
"generation_completed": WorkflowStep.COMPLETION,
|
||||
"generation_failed": WorkflowStep.COMPLETION,
|
||||
"generation_canceled": WorkflowStep.CANCELLATION,
|
||||
"cancel_requested": WorkflowStep.CANCELLATION,
|
||||
"generation_stale_failed": WorkflowStep.STALE_RECOVERY,
|
||||
}
|
||||
|
||||
EVENT_ARTIFACT_MAP: dict[str, ArtifactKind] = {
|
||||
"narrative_generated": ArtifactKind.STORY_TEXT,
|
||||
"quality_gate_failed": ArtifactKind.STORY_TEXT,
|
||||
"cover_image_started": ArtifactKind.COVER_IMAGE,
|
||||
"cover_image_succeeded": ArtifactKind.COVER_IMAGE,
|
||||
"cover_image_failed": ArtifactKind.COVER_IMAGE,
|
||||
"storybook_images_started": ArtifactKind.IMAGE,
|
||||
"storybook_cover_image_succeeded": ArtifactKind.COVER_IMAGE,
|
||||
"storybook_cover_image_failed": ArtifactKind.COVER_IMAGE,
|
||||
"storybook_page_image_succeeded": ArtifactKind.PAGE_IMAGE,
|
||||
"storybook_page_image_failed": ArtifactKind.PAGE_IMAGE,
|
||||
"storybook_images_completed": ArtifactKind.IMAGE,
|
||||
"audio_started": ArtifactKind.AUDIO,
|
||||
"audio_cache_hit": ArtifactKind.AUDIO,
|
||||
"audio_succeeded": ArtifactKind.AUDIO,
|
||||
"audio_failed": ArtifactKind.AUDIO,
|
||||
"postprocessing_queued": ArtifactKind.ACHIEVEMENT_MEMORY,
|
||||
}
|
||||
|
||||
|
||||
def step_for_event(event_type: str) -> WorkflowStep:
|
||||
"""Return the standard workflow step for a persisted event type."""
|
||||
|
||||
return EVENT_STEP_MAP.get(event_type, WorkflowStep.UNKNOWN)
|
||||
|
||||
|
||||
def artifact_for_event(event_type: str) -> ArtifactKind:
|
||||
"""Return the standard artifact for a persisted event type."""
|
||||
|
||||
return EVENT_ARTIFACT_MAP.get(event_type, ArtifactKind.NONE)
|
||||
|
||||
|
||||
def normalize_trace_metadata(
|
||||
event_type: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
step: WorkflowStep | str | None = None,
|
||||
artifact: ArtifactKind | str | None = None,
|
||||
failure_category: FailureCategory | str | None = None,
|
||||
retryable: bool | None = None,
|
||||
blocks_main_result: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge legacy metadata with standard harness trace fields."""
|
||||
|
||||
normalized: dict[str, Any] = dict(metadata or {})
|
||||
|
||||
resolved_step = str(step or normalized.get("step") or step_for_event(event_type))
|
||||
resolved_artifact = str(
|
||||
artifact or normalized.get("artifact") or artifact_for_event(event_type)
|
||||
)
|
||||
|
||||
normalized["step"] = resolved_step
|
||||
normalized["artifact"] = resolved_artifact
|
||||
|
||||
if failure_category is not None:
|
||||
normalized["failure_category"] = str(failure_category)
|
||||
elif "failure_category" not in normalized:
|
||||
normalized["failure_category"] = None
|
||||
|
||||
if retryable is not None:
|
||||
normalized["retryable"] = retryable
|
||||
elif "retryable" not in normalized:
|
||||
normalized["retryable"] = False
|
||||
|
||||
if blocks_main_result is not None:
|
||||
normalized["blocks_main_result"] = blocks_main_result
|
||||
elif "blocks_main_result" not in normalized:
|
||||
normalized["blocks_main_result"] = False
|
||||
|
||||
return normalized
|
||||
@@ -10,7 +10,7 @@ from app.core.logging import get_logger
|
||||
from app.services.adapters import AdapterConfig, AdapterRegistry
|
||||
from app.services.adapters.text.models import StoryOutput
|
||||
from app.services.cost_tracker import cost_tracker
|
||||
from app.services.generation_jobs import record_generation_event
|
||||
from app.services.harness.trace import TraceRecorder
|
||||
from app.services.provider_cache import get_providers
|
||||
from app.services.provider_metrics import health_checker, metrics_collector
|
||||
from app.services.provider_policy import (
|
||||
@@ -67,8 +67,7 @@ async def _record_provider_event_if_present(
|
||||
if db is None or job is None:
|
||||
return
|
||||
|
||||
await record_generation_event(
|
||||
db,
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story_id,
|
||||
event_type=event_type,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Story business logic service."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import desc, select
|
||||
@@ -42,6 +40,29 @@ from app.services.generation_jobs import (
|
||||
get_generation_job_for_user,
|
||||
record_generation_event,
|
||||
)
|
||||
from app.services.harness.artifacts import (
|
||||
AssetCompletionResult,
|
||||
asset_result_metadata,
|
||||
)
|
||||
from app.services.harness.asset_workflows import (
|
||||
build_storybook_error_message,
|
||||
complete_audio_asset,
|
||||
complete_cover_image_asset,
|
||||
complete_storybook_image_assets,
|
||||
get_storybook_pages_data,
|
||||
read_cached_audio_asset,
|
||||
resolve_storybook_image_status,
|
||||
)
|
||||
from app.services.harness.control import (
|
||||
ExecutionControl,
|
||||
GenerationJobCanceledError,
|
||||
)
|
||||
from app.services.harness.quality_gates import (
|
||||
QualityGateError,
|
||||
validate_story_output,
|
||||
validate_storybook_output,
|
||||
)
|
||||
from app.services.harness.trace import TraceRecorder
|
||||
from app.services.memory_service import build_enhanced_memory_context
|
||||
from app.services.provider_router import (
|
||||
generate_image,
|
||||
@@ -56,29 +77,6 @@ from app.tasks.achievements import extract_story_achievements
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
AssetCompletionKind = Literal["cover_image", "storybook_images", "audio"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetCompletionResult:
|
||||
"""Service-level result for a generated asset completion attempt."""
|
||||
|
||||
asset: AssetCompletionKind
|
||||
status: StoryAssetStatus
|
||||
value: str | bytes | None = None
|
||||
error: str | None = None
|
||||
blocks_main_result: bool = False
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
"""Whether the asset reached a usable ready state."""
|
||||
|
||||
return self.status == StoryAssetStatus.READY and self.error is None
|
||||
|
||||
|
||||
class GenerationJobCanceledError(Exception):
|
||||
"""Raised when a running worker job has been canceled by the user."""
|
||||
|
||||
|
||||
async def _record_job_event_if_present(
|
||||
db: AsyncSession,
|
||||
@@ -92,11 +90,7 @@ async def _record_job_event_if_present(
|
||||
) -> None:
|
||||
"""Append a workflow event when the caller is running under a tracked job."""
|
||||
|
||||
if job is None:
|
||||
return
|
||||
|
||||
await record_generation_event(
|
||||
db,
|
||||
await TraceRecorder(db).record_step(
|
||||
job=job,
|
||||
story_id=story_id,
|
||||
event_type=event_type,
|
||||
@@ -114,34 +108,31 @@ async def _stop_if_job_cancel_requested(
|
||||
) -> None:
|
||||
"""Stop a worker-owned job at the next safe checkpoint after cancellation."""
|
||||
|
||||
if job is None:
|
||||
return
|
||||
await ExecutionControl(db).stop_if_cancel_requested(job=job, story=story)
|
||||
|
||||
await db.refresh(job)
|
||||
if job.current_step != "cancel_requested":
|
||||
return
|
||||
|
||||
await finish_generation_job(
|
||||
async def _record_quality_gate_failure_if_present(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
job,
|
||||
error: QualityGateError,
|
||||
) -> None:
|
||||
"""Append a quality gate failure event for tracked worker jobs."""
|
||||
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story=story,
|
||||
status="canceled",
|
||||
current_step="generation_canceled",
|
||||
error_message="Generation canceled by user.",
|
||||
message="Generation job was canceled after a user request.",
|
||||
event_type="quality_gate_failed",
|
||||
status="failed",
|
||||
message="Generated content failed deterministic quality gates.",
|
||||
metadata=error.to_metadata(),
|
||||
)
|
||||
raise GenerationJobCanceledError()
|
||||
|
||||
|
||||
def _asset_result_metadata(result: AssetCompletionResult) -> dict:
|
||||
"""Build JSON-safe metadata for asset workflow events."""
|
||||
|
||||
return {
|
||||
"asset": result.asset,
|
||||
"status": result.status.value,
|
||||
"error": result.error,
|
||||
"blocks_main_result": result.blocks_main_result,
|
||||
}
|
||||
return asset_result_metadata(result)
|
||||
|
||||
|
||||
def _build_storybook_error_message(
|
||||
@@ -151,13 +142,10 @@ def _build_storybook_error_message(
|
||||
) -> str | None:
|
||||
"""Summarize storybook image generation errors for the latest attempt."""
|
||||
|
||||
parts: list[str] = []
|
||||
if cover_failed:
|
||||
parts.append("封面生成失败")
|
||||
if failed_pages:
|
||||
pages = "、".join(str(page) for page in sorted(failed_pages))
|
||||
parts.append(f"第 {pages} 页插图生成失败")
|
||||
return ";".join(parts) if parts else None
|
||||
return build_storybook_error_message(
|
||||
cover_failed=cover_failed,
|
||||
failed_pages=failed_pages,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_storybook_image_status(
|
||||
@@ -169,31 +157,12 @@ def _resolve_storybook_image_status(
|
||||
) -> StoryAssetStatus:
|
||||
"""Resolve the persisted image status for a storybook."""
|
||||
|
||||
if not generate_images:
|
||||
return StoryAssetStatus.NOT_REQUESTED
|
||||
|
||||
expected_assets = 0
|
||||
ready_assets = 0
|
||||
|
||||
if cover_prompt or cover_url:
|
||||
expected_assets += 1
|
||||
if cover_url:
|
||||
ready_assets += 1
|
||||
|
||||
for page in pages_data:
|
||||
if not page.get("image_prompt") and not page.get("image_url"):
|
||||
continue
|
||||
expected_assets += 1
|
||||
if page.get("image_url"):
|
||||
ready_assets += 1
|
||||
|
||||
if expected_assets == 0:
|
||||
return StoryAssetStatus.NOT_REQUESTED
|
||||
|
||||
if ready_assets == expected_assets:
|
||||
return StoryAssetStatus.READY
|
||||
|
||||
return StoryAssetStatus.FAILED
|
||||
return resolve_storybook_image_status(
|
||||
generate_images=generate_images,
|
||||
cover_prompt=cover_prompt,
|
||||
cover_url=cover_url,
|
||||
pages_data=pages_data,
|
||||
)
|
||||
|
||||
|
||||
async def _prepare_generation_context(
|
||||
@@ -539,93 +508,21 @@ async def _complete_cover_image_asset(
|
||||
) -> AssetCompletionResult:
|
||||
"""Generate or retry a text story cover through one asset workflow."""
|
||||
|
||||
if not story.cover_prompt:
|
||||
raise HTTPException(status_code=400, detail="Story has no cover prompt")
|
||||
|
||||
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
|
||||
await db.commit()
|
||||
await _stop_if_job_cancel_requested(db, job=job, story=story)
|
||||
await _record_job_event_if_present(
|
||||
return await complete_cover_image_asset(
|
||||
story,
|
||||
db,
|
||||
generate_image_func=generate_image,
|
||||
raise_on_failure=raise_on_failure,
|
||||
last_error_prefix=last_error_prefix,
|
||||
log_event=log_event,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="cover_image_started",
|
||||
status="running",
|
||||
message="Cover image generation started.",
|
||||
metadata={"asset": "image", "cover_prompt_present": True},
|
||||
)
|
||||
|
||||
try:
|
||||
await _stop_if_job_cancel_requested(db, job=job, story=story)
|
||||
image_url = await generate_image(
|
||||
story.cover_prompt,
|
||||
db=db,
|
||||
user_id=story.user_id,
|
||||
generation_job=job,
|
||||
story_id=story.id,
|
||||
)
|
||||
story.image_url = image_url
|
||||
sync_story_status(story, image_status=StoryAssetStatus.READY)
|
||||
await db.commit()
|
||||
result = AssetCompletionResult(
|
||||
asset="cover_image",
|
||||
status=StoryAssetStatus.READY,
|
||||
value=image_url,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="cover_image_succeeded",
|
||||
status="succeeded",
|
||||
message="Cover image was generated.",
|
||||
metadata=_asset_result_metadata(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
provider_error = str(exc)
|
||||
last_error = (
|
||||
f"{last_error_prefix}: {provider_error}"
|
||||
if last_error_prefix
|
||||
else provider_error
|
||||
)
|
||||
sync_story_status(
|
||||
story,
|
||||
image_status=StoryAssetStatus.FAILED,
|
||||
last_error=last_error,
|
||||
)
|
||||
await db.commit()
|
||||
logger.warning(log_event, story_id=story.id, error=provider_error)
|
||||
|
||||
result = AssetCompletionResult(
|
||||
asset="cover_image",
|
||||
status=StoryAssetStatus.FAILED,
|
||||
error=provider_error,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="cover_image_failed",
|
||||
status="failed",
|
||||
message="Cover image generation failed.",
|
||||
metadata=_asset_result_metadata(result),
|
||||
)
|
||||
if raise_on_failure:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Image generation failed: {provider_error}",
|
||||
) from exc
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_storybook_pages_data(story: Story) -> list[dict]:
|
||||
"""Return mutable storybook page data from the persisted JSON field."""
|
||||
|
||||
return [dict(page) for page in story.pages or [] if isinstance(page, dict)]
|
||||
return get_storybook_pages_data(story)
|
||||
|
||||
|
||||
async def _complete_storybook_image_assets(
|
||||
@@ -636,176 +533,23 @@ async def _complete_storybook_image_assets(
|
||||
) -> AssetCompletionResult:
|
||||
"""Complete missing cover/page images for a persisted storybook."""
|
||||
|
||||
pages_data = _get_storybook_pages_data(story)
|
||||
has_image_prompt = bool(story.cover_prompt) or any(
|
||||
page.get("image_prompt") for page in pages_data
|
||||
)
|
||||
if not has_image_prompt:
|
||||
raise HTTPException(status_code=400, detail="Storybook has no image prompts")
|
||||
|
||||
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
|
||||
await db.commit()
|
||||
await _stop_if_job_cancel_requested(db, job=job, story=story)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_images_started",
|
||||
status="running",
|
||||
message="Storybook missing image completion started.",
|
||||
metadata={"asset": "image"},
|
||||
)
|
||||
|
||||
cover_failed = False
|
||||
failed_pages: list[int] = []
|
||||
completed_pages: list[int] = []
|
||||
|
||||
if story.cover_prompt and not story.image_url:
|
||||
await _stop_if_job_cancel_requested(db, job=job, story=story)
|
||||
try:
|
||||
story.image_url = await generate_image(
|
||||
story.cover_prompt,
|
||||
db=db,
|
||||
user_id=story.user_id,
|
||||
generation_job=job,
|
||||
story_id=story.id,
|
||||
)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_cover_image_succeeded",
|
||||
status="succeeded",
|
||||
message="Storybook cover image was generated.",
|
||||
metadata={"asset": "image", "scope": "cover"},
|
||||
)
|
||||
except Exception as exc:
|
||||
cover_failed = True
|
||||
logger.warning(
|
||||
"storybook_cover_asset_completion_failed",
|
||||
story_id=story.id,
|
||||
error=str(exc),
|
||||
)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_cover_image_failed",
|
||||
status="failed",
|
||||
message="Storybook cover image generation failed.",
|
||||
metadata={"asset": "image", "scope": "cover", "error": str(exc)},
|
||||
)
|
||||
|
||||
for page in pages_data:
|
||||
if not page.get("image_prompt") or page.get("image_url"):
|
||||
continue
|
||||
|
||||
await _stop_if_job_cancel_requested(db, job=job, story=story)
|
||||
try:
|
||||
page["image_url"] = await generate_image(
|
||||
page["image_prompt"],
|
||||
db=db,
|
||||
user_id=story.user_id,
|
||||
generation_job=job,
|
||||
story_id=story.id,
|
||||
)
|
||||
page_number = page.get("page_number")
|
||||
if isinstance(page_number, int):
|
||||
completed_pages.append(page_number)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_page_image_succeeded",
|
||||
status="succeeded",
|
||||
message="Storybook page image was generated.",
|
||||
metadata={"asset": "image", "scope": "page", "page_number": page_number},
|
||||
)
|
||||
except Exception as exc:
|
||||
page_number = page.get("page_number")
|
||||
if isinstance(page_number, int):
|
||||
failed_pages.append(page_number)
|
||||
logger.warning(
|
||||
"storybook_page_asset_completion_failed",
|
||||
story_id=story.id,
|
||||
page=page_number,
|
||||
error=str(exc),
|
||||
)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_page_image_failed",
|
||||
status="failed",
|
||||
message="Storybook page image generation failed.",
|
||||
metadata={
|
||||
"asset": "image",
|
||||
"scope": "page",
|
||||
"page_number": page_number,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
story.pages = pages_data
|
||||
error_message = _build_storybook_error_message(
|
||||
cover_failed=cover_failed,
|
||||
failed_pages=failed_pages,
|
||||
)
|
||||
image_status = _resolve_storybook_image_status(
|
||||
generate_images=True,
|
||||
cover_prompt=story.cover_prompt,
|
||||
cover_url=story.image_url,
|
||||
pages_data=pages_data,
|
||||
)
|
||||
sync_story_status(
|
||||
return await complete_storybook_image_assets(
|
||||
story,
|
||||
image_status=image_status,
|
||||
last_error=error_message,
|
||||
)
|
||||
await db.commit()
|
||||
result = AssetCompletionResult(
|
||||
asset="storybook_images",
|
||||
status=image_status,
|
||||
value=story.image_url,
|
||||
error=error_message,
|
||||
)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
generate_image_func=generate_image,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="storybook_images_completed",
|
||||
status="failed" if error_message else "succeeded",
|
||||
message="Storybook image completion finished.",
|
||||
metadata={
|
||||
**_asset_result_metadata(result),
|
||||
"completed_pages": sorted(completed_pages),
|
||||
"failed_pages": sorted(failed_pages),
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _read_cached_audio_asset(story: Story, db: AsyncSession) -> bytes | None:
|
||||
"""Read cached audio or repair stale audio cache metadata."""
|
||||
|
||||
if story.audio_path and audio_cache_exists(story.audio_path):
|
||||
if story.audio_status != StoryAssetStatus.READY.value:
|
||||
sync_story_status(story, audio_status=StoryAssetStatus.READY)
|
||||
await db.commit()
|
||||
return read_audio_cache(story.audio_path)
|
||||
|
||||
if story.audio_path and not audio_cache_exists(story.audio_path):
|
||||
logger.warning(
|
||||
"story_audio_cache_missing",
|
||||
story_id=story.id,
|
||||
audio_path=story.audio_path,
|
||||
)
|
||||
story.audio_path = None
|
||||
if story.audio_status == StoryAssetStatus.READY.value:
|
||||
sync_story_status(story, audio_status=StoryAssetStatus.NOT_REQUESTED)
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
return await read_cached_audio_asset(
|
||||
story,
|
||||
db,
|
||||
audio_cache_exists_func=audio_cache_exists,
|
||||
read_audio_cache_func=read_audio_cache,
|
||||
)
|
||||
|
||||
|
||||
async def _complete_audio_asset(
|
||||
@@ -817,107 +561,18 @@ async def _complete_audio_asset(
|
||||
) -> AssetCompletionResult:
|
||||
"""Complete TTS audio generation through one asset workflow."""
|
||||
|
||||
if not story.story_text:
|
||||
raise HTTPException(status_code=400, detail="Story has no text")
|
||||
|
||||
cached_audio = await _read_cached_audio_asset(story, db)
|
||||
if cached_audio is not None:
|
||||
result = AssetCompletionResult(
|
||||
asset="audio",
|
||||
status=StoryAssetStatus.READY,
|
||||
value=cached_audio,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="audio_cache_hit",
|
||||
status="succeeded",
|
||||
message="Cached story audio was reused.",
|
||||
metadata=_asset_result_metadata(result),
|
||||
)
|
||||
return result
|
||||
|
||||
from app.services.provider_router import text_to_speech
|
||||
|
||||
sync_story_status(story, audio_status=StoryAssetStatus.GENERATING)
|
||||
await db.commit()
|
||||
await _stop_if_job_cancel_requested(db, job=job, story=story)
|
||||
await _record_job_event_if_present(
|
||||
return await complete_audio_asset(
|
||||
story,
|
||||
db,
|
||||
text_to_speech_func=text_to_speech,
|
||||
audio_cache_exists_func=audio_cache_exists,
|
||||
read_audio_cache_func=read_audio_cache,
|
||||
write_story_audio_cache_func=write_story_audio_cache,
|
||||
raise_on_failure=raise_on_failure,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="audio_started",
|
||||
status="running",
|
||||
message="Story audio generation started.",
|
||||
metadata={"asset": "audio"},
|
||||
)
|
||||
|
||||
try:
|
||||
await _stop_if_job_cancel_requested(db, job=job, story=story)
|
||||
audio_data = await text_to_speech(
|
||||
story.story_text,
|
||||
db=db,
|
||||
user_id=story.user_id,
|
||||
generation_job=job,
|
||||
story_id=story.id,
|
||||
)
|
||||
story.audio_path = write_story_audio_cache(story.id, audio_data)
|
||||
sync_story_status(
|
||||
story,
|
||||
audio_status=StoryAssetStatus.READY,
|
||||
)
|
||||
await db.commit()
|
||||
result = AssetCompletionResult(
|
||||
asset="audio",
|
||||
status=StoryAssetStatus.READY,
|
||||
value=audio_data,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="audio_succeeded",
|
||||
status="succeeded",
|
||||
message="Story audio was generated and cached.",
|
||||
metadata=_asset_result_metadata(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
provider_error = str(exc)
|
||||
story.audio_path = None
|
||||
sync_story_status(
|
||||
story,
|
||||
audio_status=StoryAssetStatus.FAILED,
|
||||
last_error=provider_error,
|
||||
)
|
||||
await db.commit()
|
||||
logger.error("audio_generation_failed", story_id=story.id, error=provider_error)
|
||||
|
||||
result = AssetCompletionResult(
|
||||
asset="audio",
|
||||
status=StoryAssetStatus.FAILED,
|
||||
error=provider_error,
|
||||
blocks_main_result=raise_on_failure,
|
||||
)
|
||||
await _record_job_event_if_present(
|
||||
db,
|
||||
job=job,
|
||||
story_id=story.id,
|
||||
event_type="audio_failed",
|
||||
status="failed",
|
||||
message="Story audio generation failed.",
|
||||
metadata=_asset_result_metadata(result),
|
||||
)
|
||||
if raise_on_failure:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Audio generation failed: {provider_error}",
|
||||
) from exc
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def validate_profile_and_universe(
|
||||
@@ -988,6 +643,13 @@ async def generate_and_save_story(
|
||||
user_id=user_id,
|
||||
generation_job=job,
|
||||
)
|
||||
validate_story_output(result)
|
||||
except QualityGateError as exc:
|
||||
await _record_quality_gate_failure_if_present(db, job=job, error=exc)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Story generation failed quality checks, please try again.",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
@@ -1096,6 +758,10 @@ async def generate_storybook_service(
|
||||
user_id=user_id,
|
||||
generation_job=job,
|
||||
)
|
||||
validate_storybook_output(storybook)
|
||||
except QualityGateError as exc:
|
||||
await _record_quality_gate_failure_if_present(db, job=job, error=exc)
|
||||
raise HTTPException(status_code=500, detail=f"故事书质量检查失败: {exc}") from exc
|
||||
except Exception as e:
|
||||
logger.error("storybook_generation_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"故事书生成失败: {e}")
|
||||
|
||||
@@ -165,6 +165,70 @@ async def test_unified_generation_is_queued_then_worker_persists_story_and_event
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_generation_worker_records_quality_gate_failure_without_persisting_story(
|
||||
db_session,
|
||||
test_user,
|
||||
):
|
||||
invalid_output = StoryOutput(
|
||||
mode="generated",
|
||||
title="空白故事",
|
||||
story_text="",
|
||||
cover_prompt_suggestion="A blank cover",
|
||||
)
|
||||
job = await create_generation_job(
|
||||
db_session,
|
||||
user_id=test_user.id,
|
||||
output_mode="story",
|
||||
input_type="keywords",
|
||||
request_payload={
|
||||
"output_mode": "story",
|
||||
"type": "keywords",
|
||||
"data": "小兔子",
|
||||
"generate_images": False,
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.story_service.generate_story_content",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_generate_story_content:
|
||||
mock_generate_story_content.return_value = invalid_output
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await run_generation_job_service(job.id, db_session)
|
||||
|
||||
refreshed_job = (
|
||||
await db_session.execute(select(GenerationJob).where(GenerationJob.id == job.id))
|
||||
).scalar_one()
|
||||
assert refreshed_job.status == "failed"
|
||||
assert refreshed_job.story_id is None
|
||||
assert refreshed_job.current_step == "generation_failed"
|
||||
assert "quality checks" in refreshed_job.error_message
|
||||
|
||||
stories = (
|
||||
await db_session.execute(select(Story).where(Story.user_id == test_user.id))
|
||||
).scalars().all()
|
||||
assert stories == []
|
||||
|
||||
events = (
|
||||
await db_session.execute(
|
||||
select(GenerationJobEvent)
|
||||
.where(GenerationJobEvent.job_id == job.id)
|
||||
.order_by(GenerationJobEvent.id)
|
||||
)
|
||||
).scalars().all()
|
||||
assert [event.event_type for event in events] == [
|
||||
"request_accepted",
|
||||
"worker_started",
|
||||
"context_prepared",
|
||||
"quality_gate_failed",
|
||||
"generation_failed",
|
||||
]
|
||||
quality_event = events[3]
|
||||
assert quality_event.event_metadata["step"] == "narrative_generation"
|
||||
assert quality_event.event_metadata["issues"][0]["code"] == "missing_story_text"
|
||||
|
||||
|
||||
async def test_asset_retry_records_job_events_and_updates_retryable_assets(
|
||||
db_session,
|
||||
test_user,
|
||||
|
||||
374
backend/tests/test_harness_runtime.py
Normal file
374
backend/tests/test_harness_runtime.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""Tests for generation harness runtime support."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import GenerationJob, GenerationJobEvent
|
||||
from app.services.adapters.storybook.primary import Storybook, StorybookPage
|
||||
from app.services.adapters.text.models import StoryOutput
|
||||
from app.services.generation_jobs import create_generation_job, record_generation_event
|
||||
from app.services.harness.control import ExecutionControl, GenerationJobCanceledError
|
||||
from app.services.harness.plans import (
|
||||
build_asset_plan,
|
||||
build_story_plan,
|
||||
build_storybook_plan,
|
||||
)
|
||||
from app.services.harness.quality_gates import (
|
||||
QualityGateError,
|
||||
validate_story_output,
|
||||
validate_storybook_output,
|
||||
)
|
||||
from app.services.harness.trace import TraceRecorder
|
||||
from app.services.harness.types import (
|
||||
ArtifactKind,
|
||||
FailureCategory,
|
||||
WorkflowStep,
|
||||
artifact_for_event,
|
||||
normalize_trace_metadata,
|
||||
step_for_event,
|
||||
)
|
||||
|
||||
|
||||
def test_event_type_maps_to_standard_workflow_step():
|
||||
assert step_for_event("request_accepted") == WorkflowStep.REQUEST_ACCEPTANCE
|
||||
assert step_for_event("context_prepared") == WorkflowStep.CONTEXT_PREPARATION
|
||||
assert step_for_event("narrative_generated") == WorkflowStep.NARRATIVE_GENERATION
|
||||
assert step_for_event("story_saved") == WorkflowStep.STORY_PERSISTENCE
|
||||
assert step_for_event("provider_call_succeeded") == WorkflowStep.PROVIDER_INVOCATION
|
||||
assert step_for_event("quality_gate_failed") == WorkflowStep.NARRATIVE_GENERATION
|
||||
assert step_for_event("cover_image_failed") == WorkflowStep.IMAGE_GENERATION
|
||||
assert step_for_event("audio_succeeded") == WorkflowStep.AUDIO_GENERATION
|
||||
assert step_for_event("generation_canceled") == WorkflowStep.CANCELLATION
|
||||
assert step_for_event("generation_stale_failed") == WorkflowStep.STALE_RECOVERY
|
||||
assert step_for_event("future_event") == WorkflowStep.UNKNOWN
|
||||
|
||||
|
||||
def test_event_type_maps_to_standard_artifact():
|
||||
assert artifact_for_event("narrative_generated") == ArtifactKind.STORY_TEXT
|
||||
assert artifact_for_event("quality_gate_failed") == ArtifactKind.STORY_TEXT
|
||||
assert artifact_for_event("cover_image_succeeded") == ArtifactKind.COVER_IMAGE
|
||||
assert artifact_for_event("storybook_page_image_failed") == ArtifactKind.PAGE_IMAGE
|
||||
assert artifact_for_event("audio_cache_hit") == ArtifactKind.AUDIO
|
||||
assert artifact_for_event("postprocessing_queued") == ArtifactKind.ACHIEVEMENT_MEMORY
|
||||
assert artifact_for_event("request_accepted") == ArtifactKind.NONE
|
||||
|
||||
|
||||
def test_trace_metadata_adds_standard_fields_without_dropping_legacy_values():
|
||||
metadata = normalize_trace_metadata(
|
||||
"provider_call_failed",
|
||||
{
|
||||
"capability": "text",
|
||||
"adapter": "demo",
|
||||
"error": "timeout",
|
||||
},
|
||||
failure_category=FailureCategory.TIMEOUT,
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
assert metadata["capability"] == "text"
|
||||
assert metadata["adapter"] == "demo"
|
||||
assert metadata["error"] == "timeout"
|
||||
assert metadata["step"] == "provider_invocation"
|
||||
assert metadata["artifact"] == "none"
|
||||
assert metadata["failure_category"] == "timeout"
|
||||
assert metadata["retryable"] is True
|
||||
assert metadata["blocks_main_result"] is False
|
||||
|
||||
|
||||
def test_trace_metadata_respects_explicit_step_and_artifact():
|
||||
metadata = normalize_trace_metadata(
|
||||
"narrative_generated",
|
||||
{"title": "小兔子的冒险"},
|
||||
step=WorkflowStep.NARRATIVE_GENERATION,
|
||||
artifact=ArtifactKind.STORYBOOK_PAGES,
|
||||
blocks_main_result=True,
|
||||
)
|
||||
|
||||
assert metadata["title"] == "小兔子的冒险"
|
||||
assert metadata["step"] == "narrative_generation"
|
||||
assert metadata["artifact"] == "storybook_pages"
|
||||
assert metadata["blocks_main_result"] is True
|
||||
|
||||
|
||||
def test_story_plan_without_assets_snapshot():
|
||||
assert build_story_plan(generate_images=False).to_snapshot() == {
|
||||
"mode": "story",
|
||||
"tasks": [
|
||||
{
|
||||
"key": "prepare_context",
|
||||
"step": "context_preparation",
|
||||
"artifact": "none",
|
||||
"required": True,
|
||||
"recoverable": False,
|
||||
},
|
||||
{
|
||||
"key": "generate_narrative",
|
||||
"step": "narrative_generation",
|
||||
"artifact": "story_text",
|
||||
"required": True,
|
||||
"recoverable": False,
|
||||
},
|
||||
{
|
||||
"key": "persist_story",
|
||||
"step": "story_persistence",
|
||||
"artifact": "story_text",
|
||||
"required": True,
|
||||
"recoverable": False,
|
||||
},
|
||||
{
|
||||
"key": "queue_postprocessing",
|
||||
"step": "postprocessing",
|
||||
"artifact": "achievement_memory",
|
||||
"required": False,
|
||||
"recoverable": True,
|
||||
},
|
||||
{
|
||||
"key": "complete_generation",
|
||||
"step": "completion",
|
||||
"artifact": "none",
|
||||
"required": True,
|
||||
"recoverable": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_story_plan_with_assets_marks_cover_recoverable():
|
||||
plan = build_story_plan(generate_images=True).to_snapshot()
|
||||
|
||||
assert plan["mode"] == "story_with_assets"
|
||||
assert plan["tasks"][3] == {
|
||||
"key": "generate_cover_image",
|
||||
"step": "image_generation",
|
||||
"artifact": "cover_image",
|
||||
"required": False,
|
||||
"recoverable": True,
|
||||
}
|
||||
|
||||
|
||||
def test_storybook_plan_with_images_marks_storybook_images_recoverable():
|
||||
plan = build_storybook_plan(generate_images=True).to_snapshot()
|
||||
|
||||
assert plan["mode"] == "storybook"
|
||||
assert [task["key"] for task in plan["tasks"]] == [
|
||||
"prepare_context",
|
||||
"generate_storybook_pages",
|
||||
"generate_storybook_images",
|
||||
"persist_storybook",
|
||||
"queue_postprocessing",
|
||||
"complete_generation",
|
||||
]
|
||||
assert plan["tasks"][2]["artifact"] == "image"
|
||||
assert plan["tasks"][2]["recoverable"] is True
|
||||
|
||||
|
||||
def test_asset_retry_plan_deduplicates_assets():
|
||||
plan = build_asset_plan(output_mode="asset_retry", assets=["image", "audio", "image"])
|
||||
|
||||
assert plan.to_snapshot() == {
|
||||
"mode": "asset_retry",
|
||||
"tasks": [
|
||||
{
|
||||
"key": "start_asset_retry",
|
||||
"step": "asset_retry",
|
||||
"artifact": "none",
|
||||
"required": True,
|
||||
"recoverable": False,
|
||||
},
|
||||
{
|
||||
"key": "complete_image_asset",
|
||||
"step": "image_generation",
|
||||
"artifact": "image",
|
||||
"required": False,
|
||||
"recoverable": True,
|
||||
},
|
||||
{
|
||||
"key": "complete_audio_asset",
|
||||
"step": "audio_generation",
|
||||
"artifact": "audio",
|
||||
"required": False,
|
||||
"recoverable": True,
|
||||
},
|
||||
{
|
||||
"key": "complete_asset_retry",
|
||||
"step": "asset_retry",
|
||||
"artifact": "none",
|
||||
"required": True,
|
||||
"recoverable": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_story_quality_gate_accepts_complete_child_safe_story():
|
||||
validate_story_output(
|
||||
StoryOutput(
|
||||
mode="generated",
|
||||
title="小兔子的月光花园",
|
||||
story_text="小兔子在花园里学会了和朋友轮流分享水壶。",
|
||||
cover_prompt_suggestion="A gentle moonlit garden with a rabbit",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_story_quality_gate_rejects_missing_story_text():
|
||||
output = StoryOutput(
|
||||
mode="generated",
|
||||
title="空白故事",
|
||||
story_text="",
|
||||
cover_prompt_suggestion="A cover",
|
||||
)
|
||||
|
||||
try:
|
||||
validate_story_output(output)
|
||||
except QualityGateError as exc:
|
||||
assert [issue.code.value for issue in exc.issues] == ["missing_story_text"]
|
||||
assert exc.to_metadata()["issues"][0]["field"] == "story_text"
|
||||
else:
|
||||
raise AssertionError("Expected QualityGateError")
|
||||
|
||||
|
||||
def test_story_quality_gate_rejects_obviously_unsafe_child_content():
|
||||
output = StoryOutput(
|
||||
mode="generated",
|
||||
title="危险词测试",
|
||||
story_text="这个故事包含血腥场景。",
|
||||
cover_prompt_suggestion="A cover",
|
||||
)
|
||||
|
||||
try:
|
||||
validate_story_output(output)
|
||||
except QualityGateError as exc:
|
||||
assert [issue.code.value for issue in exc.issues] == ["unsafe_child_content"]
|
||||
assert exc.to_metadata()["issues"][0]["failure_category"] == "safety_error"
|
||||
else:
|
||||
raise AssertionError("Expected QualityGateError")
|
||||
|
||||
|
||||
def test_storybook_quality_gate_rejects_duplicate_page_number():
|
||||
storybook = Storybook(
|
||||
title="森林绘本",
|
||||
main_character="小兔子",
|
||||
art_style="水彩",
|
||||
cover_prompt="A forest cover",
|
||||
pages=[
|
||||
StorybookPage(page_number=1, text="第一页。", image_prompt="page 1"),
|
||||
StorybookPage(page_number=1, text="第二页。", image_prompt="page 2"),
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
validate_storybook_output(storybook)
|
||||
except QualityGateError as exc:
|
||||
assert [issue.code.value for issue in exc.issues] == [
|
||||
"invalid_storybook_page_number"
|
||||
]
|
||||
assert exc.to_metadata()["issues"][0]["field"] == "pages[1].page_number"
|
||||
else:
|
||||
raise AssertionError("Expected QualityGateError")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_recorder_persists_standard_metadata(db_session, test_user):
|
||||
job = await create_generation_job(
|
||||
db_session,
|
||||
user_id=test_user.id,
|
||||
output_mode="story",
|
||||
input_type="keywords",
|
||||
request_payload={"data": "小兔子"},
|
||||
)
|
||||
|
||||
event = await TraceRecorder(db_session).record_step(
|
||||
job=job,
|
||||
event_type="provider_call_failed",
|
||||
status="failed",
|
||||
metadata={
|
||||
"capability": "text",
|
||||
"adapter": "demo",
|
||||
"error": "timeout",
|
||||
},
|
||||
failure_category=FailureCategory.TIMEOUT,
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
assert event is not None
|
||||
events = (
|
||||
await db_session.execute(
|
||||
select(GenerationJobEvent)
|
||||
.where(GenerationJobEvent.job_id == job.id)
|
||||
.order_by(GenerationJobEvent.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
assert [item.event_type for item in events] == [
|
||||
"request_accepted",
|
||||
"provider_call_failed",
|
||||
]
|
||||
metadata = events[1].event_metadata
|
||||
assert metadata["capability"] == "text"
|
||||
assert metadata["adapter"] == "demo"
|
||||
assert metadata["step"] == "provider_invocation"
|
||||
assert metadata["artifact"] == "none"
|
||||
assert metadata["failure_category"] == "timeout"
|
||||
assert metadata["retryable"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_recorder_ignores_missing_job(db_session):
|
||||
event = await TraceRecorder(db_session).record_step(
|
||||
job=None,
|
||||
event_type="context_prepared",
|
||||
status="succeeded",
|
||||
)
|
||||
|
||||
assert event is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_control_cancels_job_at_safe_checkpoint(
|
||||
db_session,
|
||||
test_user,
|
||||
test_story,
|
||||
):
|
||||
job = await create_generation_job(
|
||||
db_session,
|
||||
user_id=test_user.id,
|
||||
output_mode="story",
|
||||
input_type="keywords",
|
||||
request_payload={"data": "小兔子"},
|
||||
story_id=test_story.id,
|
||||
)
|
||||
await record_generation_event(
|
||||
db_session,
|
||||
job=job,
|
||||
story_id=test_story.id,
|
||||
event_type="cancel_requested",
|
||||
status="running",
|
||||
message="Cancellation requested.",
|
||||
)
|
||||
|
||||
with pytest.raises(GenerationJobCanceledError):
|
||||
await ExecutionControl(db_session).stop_if_cancel_requested(
|
||||
job=job,
|
||||
story=test_story,
|
||||
)
|
||||
|
||||
refreshed_job = (
|
||||
await db_session.execute(select(GenerationJob).where(GenerationJob.id == job.id))
|
||||
).scalar_one()
|
||||
assert refreshed_job.status == "canceled"
|
||||
assert refreshed_job.current_step == "generation_canceled"
|
||||
assert refreshed_job.error_message == "Generation canceled by user."
|
||||
|
||||
events = (
|
||||
await db_session.execute(
|
||||
select(GenerationJobEvent)
|
||||
.where(GenerationJobEvent.job_id == job.id)
|
||||
.order_by(GenerationJobEvent.id)
|
||||
)
|
||||
).scalars().all()
|
||||
assert [item.event_type for item in events] == [
|
||||
"request_accepted",
|
||||
"cancel_requested",
|
||||
"generation_canceled",
|
||||
]
|
||||
Reference in New Issue
Block a user