pipeline-app/app/quality_gate.py
yumoqing 9d3f0ff38c feat: 多维度质量评估体系 - 视频5维度/音乐7维度
新增模块:
- app/eval_video.py: 场景视频多维度评估 (视觉质量25%, 运动流畅度20%, 语义一致性30%, 角色一致性15%, 时间连贯性10%)
- app/eval_music.py: 音乐多维度评估 (节奏15%, 和弦10%, 听感20%, 情绪-歌词匹配20%, 指令匹配15%, 伴奏10%, 人声10%)

更新模块:
- app/quality_gate.py: 集成多维度评估函数,支持降级
- app/ktv_adapter.py: 注册6个质量门控 handlers (步骤9-10, 11, 12, 13, 14-15, 17)

质量门控流程:
1. 执行 handler
2. 多维度评估
3. 不达标自动重试(最多3次)
4. 仍不达标创建 human_task 暂停任务等待人工决策
2026-06-25 18:34:26 +08:00

450 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""质量门控:评估-重试-人工介入闭环。
用于 KTV 产线关键步骤的质量保障。
架构:
1. 执行 handler → 2. 评估结果 → 3. 不达标则重试(最多3次) → 4. 仍不达标则暂停等待人工决策
"""
import json
import logging
import os
from typing import Callable, Any, Optional, Dict, Tuple
logger = logging.getLogger("pipeline.quality_gate")
# 最大自动重试次数
MAX_RETRIES = 3
async def with_quality_gate(
task_id: str,
step_name: str,
version: int,
handler: Callable,
evaluator: Callable,
tenant_id: str = "",
input_data: Optional[dict] = None,
config: Optional[dict] = None,
) -> dict:
"""带质量门控的 handler 执行器。
参数:
handler: 原始步骤处理函数
evaluator: 评估函数 async def evaluator(output_data) -> (passed: bool, score: float, reason: str)
流程:
1. 执行 handler
2. 调用 evaluator 评估结果
3. 不通过则重试(最多 MAX_RETRIES 次)
4. 仍不通过则创建 human_task暂停任务
返回:
handler 的输出数据(含 _quality_meta 元信息)
"""
input_data = input_data or {}
config = config or {}
last_output: Optional[dict] = None
last_reason = ""
for attempt in range(1, MAX_RETRIES + 1):
logger.info(f"[QualityGate] {step_name}{attempt}/{MAX_RETRIES} 次执行")
try:
# 执行 handler
output = await handler(tenant_id, task_id, step_name, input_data, config)
last_output = output
# 评估结果
passed, score, reason = await evaluator(output)
logger.info(f"[QualityGate] {step_name} 评估: passed={passed}, score={score:.2f}, reason={reason}")
if passed:
# 通过,附加元信息返回
output["_quality_meta"] = {
"attempts": attempt,
"score": score,
"reason": reason,
"passed": True,
}
return output
last_reason = reason
except Exception as e:
logger.warning(f"[QualityGate] {step_name}{attempt} 次执行异常: {e}")
last_reason = f"执行异常: {e}"
# 超过重试次数,创建人工任务
logger.warning(
f"[QualityGate] {step_name} 经过 {MAX_RETRIES} 次尝试仍不达标,创建人工任务"
)
await _create_quality_review_task(
task_id=task_id,
step_name=step_name,
version=version,
attempts=MAX_RETRIES,
last_output=last_output,
last_reason=last_reason,
)
# 抛出异常让 executor 检测到 WAITING 状态并暂停任务
raise QualityGatePausedError(
f"步骤 {step_name} 质量评估未通过,已暂停等待人工审核。原因: {last_reason}"
)
async def _create_quality_review_task(
task_id: str,
step_name: str,
version: int,
attempts: int,
last_output: dict,
last_reason: str,
):
"""创建质量审核人工任务。"""
from pipeline_service.storage import create_human_task
from pipeline_service.state import STATE_WAITING
from pipeline_service.storage import update_step_state
# 构造审核表单
form_schema = {
"title": f"步骤 {step_name} 质量审核",
"description": f"经过 {attempts} 次自动重试仍未通过质量评估",
"fields": [
{
"name": "last_reason",
"label": "失败原因",
"type": "textarea",
"readonly": True,
"default": last_reason,
},
{
"name": "last_output_summary",
"label": "最后一次输出摘要",
"type": "textarea",
"readonly": True,
"default": json.dumps(last_output, ensure_ascii=False, indent=2)[:2000] if last_output else "",
},
{
"name": "decision",
"label": "决策",
"type": "select",
"options": [
{"value": "retry_manual", "label": "手动重试(调整参数后重新执行)"},
{"value": "accept", "label": "接受当前结果(忽略质量要求)"},
{"value": "abort", "label": "终止任务"},
],
"required": True,
},
{
"name": "notes",
"label": "备注说明",
"type": "textarea",
"required": False,
},
],
}
# 创建人工任务记录
await create_human_task(
task_id=task_id,
step_name=step_name,
version=version,
task_type="quality_review",
assignee_role="admin",
form_schema=form_schema,
timeout_hours=24,
)
# 设置步骤状态为等待
await update_step_state(task_id, step_name, STATE_WAITING)
logger.info(f"[QualityGate] 已创建人工审核任务: {task_id}/{step_name}")
class QualityGatePausedError(Exception):
"""质量门控暂停异常,用于通知 executor 暂停任务。"""
pass
# ─── 评估函数 ──────────────────────────────────────────────────────────────
async def eval_music_quality(output: dict) -> Tuple[bool, float, str]:
"""评估音乐生成结果(步骤 9-10— 多维度评估。
7 个评估维度:
节奏质量 (15%) | 和弦进行 (10%) | 听感质量 (20%)
情绪-歌词匹配 (20%) | 指令匹配度 (15%)
伴奏质量 (10%) | 人声质量 (10%)
"""
music_path = output.get("music_path", "")
if not music_path or not os.path.exists(music_path):
return False, 0.0, f"音乐文件不存在: {music_path}"
file_size = os.path.getsize(music_path)
if file_size < 50 * 1024:
return False, 0.3, f"音乐文件过小 ({file_size/1024:.1f}KB < 50KB)"
# 调用多维度评估
try:
from app.eval_music import evaluate_music_quality
prompt = output.get("prompt", output.get("music_prompt", ""))
lyrics = output.get("lyrics", "")
result = await evaluate_music_quality(
audio_path=music_path,
prompt=prompt,
lyrics=lyrics,
use_demucs=True,
)
overall_score = result["overall_score"]
overall_reason = result["overall_reason"]
passed = overall_score >= 0.6
# 将维度评分存入 output 供后续参考
output["_eval_dimensions"] = result.get("dimensions", {})
return passed, overall_score, overall_reason
except ImportError:
logger.warning("eval_music 模块未安装,降级为基础评估")
# 降级:仅检查时长
import asyncio
proc = await asyncio.create_subprocess_shell(
f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '{music_path}'",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
duration = float(stdout.decode().strip()) if stdout else 0
if duration < 30:
return False, 0.5, f"音乐时长不足 ({duration:.1f}s < 30s)"
score = min(1.0, duration / 180)
return True, score, f"音乐文件正常 ({duration:.1f}s, {file_size/1024:.0f}KB)"
async def eval_character_design(output: dict) -> Tuple[bool, float, str]:
"""评估角色设计结果(步骤 11
评估标准:
1. characters 列表非空
2. 每个角色包含 name, prompt/description, personality
3. prompt 描述长度合理(> 50 字符)
"""
characters = output.get("characters", [])
if not characters or not isinstance(characters, list):
return False, 0.0, "角色列表为空"
issues = []
for i, char in enumerate(characters):
if not isinstance(char, dict):
issues.append(f"角色 {i} 格式错误")
continue
name = char.get("name", "")
prompt = char.get("prompt", char.get("description", ""))
personality = char.get("personality", "")
if not name:
issues.append(f"角色 {i} 缺少 name")
if not prompt or len(prompt) < 50:
issues.append(f"角色 {i} 的 prompt 描述过短 ({len(prompt)} chars)")
if not personality:
issues.append(f"角色 {i} 缺少 personality")
if issues:
return False, 0.3, "; ".join(issues[:3])
return True, 0.9, f"角色设计完整 ({len(characters)} 个角色)"
async def eval_character_image(output: dict) -> Tuple[bool, float, str]:
"""评估角色图生成结果(步骤 12
评估标准:
1. character_images 列表非空
2. 每个图片文件存在且大小 > 10KB
3. 图片数量匹配角色数量
"""
char_images = output.get("character_images", [])
if not char_images or not isinstance(char_images, list):
return False, 0.0, "角色图列表为空"
missing = []
too_small = []
for item in char_images:
path = item.get("image_path", "")
name = item.get("name", "unknown")
if not path or not os.path.exists(path):
missing.append(name)
continue
size = os.path.getsize(path)
if size < 10 * 1024:
too_small.append(f"{name}({size/1024:.1f}KB)")
if missing:
return False, 0.3, f"图片缺失: {', '.join(missing)}"
if too_small:
return False, 0.5, f"图片过小: {', '.join(too_small)}"
return True, 0.9, f"角色图生成正常 ({len(char_images)} 张)"
async def eval_storyboard(output: dict) -> Tuple[bool, float, str]:
"""评估分镜脚本结果(步骤 13
评估标准:
1. storyboard 列表非空
2. 每个分镜包含 scene_id, start_time, end_time, description
3. 时间轴覆盖完整(无大段空白)
4. description 描述充分
"""
storyboard = output.get("storyboard", [])
if not storyboard or not isinstance(storyboard, list):
return False, 0.0, "分镜列表为空"
if len(storyboard) < 3:
return False, 0.3, f"分镜数量过少 ({len(storyboard)} < 3)"
issues = []
prev_end = 0
for i, scene in enumerate(storyboard):
if not isinstance(scene, dict):
issues.append(f"分镜 {i} 格式错误")
continue
# 必填字段检查
for field in ["scene_id", "start_time", "end_time", "description"]:
if field not in scene:
issues.append(f"分镜 {i} 缺少 {field}")
# 时间轴连续性
start = scene.get("start_time", 0)
end = scene.get("end_time", 0)
if start > prev_end + 5: # 允许 5 秒间隔
issues.append(f"分镜 {i} 时间轴有断层 ({prev_end}s → {start}s)")
prev_end = end
# description 质量
desc = scene.get("description", "")
if len(desc) < 20:
issues.append(f"分镜 {i} 描述过短 ({len(desc)} chars)")
if len(issues) > len(storyboard) // 2:
return False, 0.4, "; ".join(issues[:3])
score = 0.9 if not issues else max(0.6, 0.9 - 0.1 * len(issues))
return True, score, f"分镜脚本完整 ({len(storyboard)} 个分镜, {issues and ''+str(len(issues))+'个问题' or '无问题'})"
async def eval_ktv_synthesis(output: dict) -> Tuple[bool, float, str]:
"""评估 KTV 合成结果(步骤 17
评估标准:
1. ktv_path 文件存在且大小 > 1MB
2. 视频时长 > 30 秒
3. 字幕文件存在
4. MTV 版本可选
"""
ktv_path = output.get("ktv_path", "")
subtitle_path = output.get("subtitle_path", "")
# 检查 KTV 视频
if not ktv_path or not os.path.exists(ktv_path):
return False, 0.0, f"KTV 视频不存在: {ktv_path}"
file_size = os.path.getsize(ktv_path)
if file_size < 1024 * 1024:
return False, 0.3, f"KTV 视频过小 ({file_size/1024/1024:.1f}MB < 1MB)"
# 用 ffprobe 检查时长
import asyncio
proc = await asyncio.create_subprocess_shell(
f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '{ktv_path}'",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
duration = float(stdout.decode().strip()) if stdout else 0
if duration < 30:
return False, 0.5, f"KTV 视频时长不足 ({duration:.1f}s < 30s)"
# 检查字幕
if not subtitle_path or not os.path.exists(subtitle_path):
return False, 0.6, f"字幕文件缺失: {subtitle_path}"
score = min(1.0, duration / 180) # 3分钟满分
return True, score, f"KTV 合成正常 ({duration:.1f}s, {file_size/1024/1024:.1f}MB)"
async def eval_scene_video_quality(output: dict) -> Tuple[bool, float, str]:
"""评估场景视频生成结果(步骤 14— 多维度评估。
5 个评估维度:
视觉质量 (25%) | 运动流畅度 (20%) | 语义一致性 (30%)
角色一致性 (15%) | 时间连贯性 (10%)
"""
scene_video_path = output.get("scene_video_path", "")
if not scene_video_path or not os.path.exists(scene_video_path):
return False, 0.0, f"场景视频不存在: {scene_video_path}"
file_size = os.path.getsize(scene_video_path)
if file_size < 1024 * 1024:
return False, 0.3, f"场景视频过小 ({file_size/1024/1024:.1f}MB < 1MB)"
# 调用多维度评估
try:
from app.eval_video import evaluate_scene_video_quality
prompt = output.get("prompt", output.get("scene_prompt", ""))
reference_image = output.get("reference_image", "")
result = await evaluate_scene_video_quality(
video_path=scene_video_path,
prompt=prompt,
reference_image=reference_image,
)
overall_score = result["overall_score"]
overall_reason = result["overall_reason"]
passed = overall_score >= 0.6
# 将维度评分存入 output 供后续参考
output["_eval_dimensions"] = result.get("dimensions", {})
return passed, overall_score, overall_reason
except ImportError:
logger.warning("eval_video 模块未安装,降级为基础评估")
# 降级:仅检查时长
import asyncio
proc = await asyncio.create_subprocess_shell(
f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '{scene_video_path}'",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
duration = float(stdout.decode().strip()) if stdout else 0
if duration < 10:
return False, 0.5, f"场景视频时长不足 ({duration:.1f}s < 10s)"
score = min(1.0, duration / 30)
return True, score, f"场景视频正常 ({duration:.1f}s, {file_size/1024/1024:.1f}MB)"