Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ AI_API_KEY=your-ai-api-key
# 取值即默认值——不写这两行时跑的就是它们。
AI_IMAGE_MODEL=gemini-2.5-flash-image
AI_VIDEO_MODEL=kling-v2-5-turbo
# 判官(看图问答)。必须填一个**能读图的聊天模型**,不是出图模型:它要回 JSON 不是回图。
AI_JUDGE_MODEL=gemini-2.5-flash

# ── 判官闸口 ──
# 开了才会调判官,每交付一个动作多一次付费调用。
QUALITY_GATE_ENABLED=false
# 判官说有问题就不交付。攒够 shadow 数据、定出判据之前别开:
# 误杀掉的是用户已付过钱的产物,退不回来。
QUALITY_GATE_ENFORCE=false

# ── 积分定价 ──
QUOTA_REGISTER_GIFT_AMOUNT=100
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from enum import Enum
from typing import Protocol, runtime_checkable

from windup_common.models import ActionSpec, CharacterCard
from windup_common.models import ActionSpec, CharacterCard, JudgeVerdict


# ---- server 实现、注入给 ai_engine 的进度回调 port ----
Expand Down Expand Up @@ -124,6 +124,21 @@ class GeneratedAction:
quality: ActionQuality = field(kw_only=True)


# ---- 出门那道闸的仪器:判官(server 注入实现,framework 层有一个)----
# 与 ``ActionQuality`` 分工不同:那三个数由本地像素算出来,零成本、量的是帧**之间**的
# 关系;判官量的是一帧画面**里**有什么,要花一次付费调用,且本地算不出来 —— 像素统计
# 分不出"两个角色"和"一个角色 + 一件道具"。
@runtime_checkable
class JudgePort(Protocol):
"""交付帧 + 母版 → 四个可数读数(:class:`JudgeVerdict`)。

读不出结论必须抛错,不得兜底成"通过":静默放行会让"没判"与"判了没问题"在下游
长得一样。``master`` 必填 —— "有没有母版里没有的物体"离开母版无从回答。
"""

def judge(self, frame: bytes, master: bytes, action: str) -> JudgeVerdict: ...


# ---- ai_engine 暴露给 server(server 调用的唯一入口)----
@runtime_checkable
class CharacterGeneratorPort(Protocol):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
from sqlalchemy.orm import Session

from windup_common.models import ActionSpec, ActionType as EngineActionType, CharacterCard
from windup_framework.config.quality_gate import settings as gate_settings

from windup_app.server.orchestrator import task_repo
from windup_app.server.orchestrator import quality_gate, task_repo
from windup_app.server.orchestrator._fetch import fetch_own_media
from windup_app.server.orchestrator.model import (
CharacterActionInput,
Expand All @@ -32,7 +33,7 @@
)

if TYPE_CHECKING:
from windup_ai_engine.ports import CharacterGeneratorPort, ProgressPort
from windup_ai_engine.ports import CharacterGeneratorPort, JudgePort, ProgressPort
from windup_framework.providers import ImageProvider, MatteProvider

logger = logging.getLogger("windup.generation.executor")
Expand Down Expand Up @@ -183,6 +184,7 @@ def __init__(
self,
*,
generator: CharacterGeneratorPort | None = None,
judge: JudgePort | None = None,
upload: Callable[[bytes], str] | None = None,
fetch_master: Callable[[CharacterActionInput], bytes] | None = None,
fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None,
Expand All @@ -195,6 +197,9 @@ def __init__(
# 各自惰性加载一份 ONNX 会话,按桶各建等于把同一个模型在进程里装多次。
self._matte: MatteProvider | None = None
self._image: ImageProvider | None = None
# 判官同样与视频模型无关,故不分桶。缺省 None 时**不建**实例:建了就意味着每个
# 任务多一次付费调用,那要由 QUALITY_GATE_ENABLED 显式打开,见 _get_judge。
self._judge: JudgePort | None = judge
# 本执行器是进程级单例,而每个请求起一个线程跑 run_action_task,上面几个缓存
# 都是跨线程共用的可变状态。缺锁时并发首请求会各装一套(见 _get_generator)。
self._assembly_lock = threading.Lock()
Expand Down Expand Up @@ -286,13 +291,37 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints)
)

upload = self._upload or self._upload_frame
checked = [_require_size(png, cons.sprite_w, cons.sprite_h) for png in generated.frames]
frames = [
{"index": i,
"image_url": upload(_require_size(png, cons.sprite_w, cons.sprite_h)),
"duration_ms": dur}
for i, (png, dur) in enumerate(zip(generated.frames, generated.durations))
{"index": i, "image_url": upload(png), "duration_ms": dur}
for i, (png, dur) in enumerate(zip(checked, generated.durations))
]
return {"type": "character_action", "action_type": input.action_type.value, "frames": frames}
result = {
"type": "character_action",
"action_type": input.action_type.value,
"frames": frames,
}
decision = quality_gate.review(
self._get_judge(), checked, master, input.action_type.value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Pass the custom action description to the judge

When action_type is CUSTOM, the API requires and the generator uses input.custom_prompt (for example, wave hello), but this call passes only the enum value "custom" into the judge prompt. The model therefore cannot evaluate whether the frame matches the requested custom action; with QUALITY_GATE_ENFORCE=true it will commonly report action_matches=false and block otherwise valid custom-action tasks, while shadow metrics for all custom actions are meaningless. Pass the custom prompt for custom actions and retain the enum label for the built-in actions.

)
if decision is not None:
result["judge"] = decision.as_payload()
if decision.blocked:
# 帧已经生成、已经上传,钱早就花完了。拦在这里的意义只剩"不把坏产物当成
# 交付物交出去";这也正是拦截档默认关着的原因。
raise quality_gate.QualityBlocked(decision.problems)
return result

def _get_judge(self) -> JudgePort | None:
"""闸口启用时懒建判官;未启用返回 ``None``,一次调用都不发。"""
if self._judge is not None or not gate_settings.enabled:
return self._judge
with self._assembly_lock:
if self._judge is None:
from windup_framework.providers import SufyJudgeProvider

self._judge = SufyJudgeProvider()
return self._judge

def _get_generator(self, video_model: str | None = None) -> CharacterGeneratorPort:
"""懒装配 CharacterGenerator,按模型名分桶。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ class CharacterActionOutput:
type: str = "character_action"
action_type: str = ""
frames: list[CharacterActionFrame] = field(default_factory=list)
# 判官读数(``quality_gate.GateDecision.as_payload``)。``None`` = **没判**,不是
# "判了没问题" —— 闸口默认不启用,把缺省读成"干净"会让 shadow 期的统计凭空多出一批
# 从未判读过的样本。形状留 dict 而不是拆成字段:shadow 期正是要观察该记哪些东西,
# 每加一个读数就改一次 ORM 反序列化的话,数据还没攒够就先僵住了。
# 字段名不叫 quality:引擎那份本地像素成色(``ports.ActionQuality``)也要落到同一个
# payload 里,两者来源与代价都不同,共用一个键会让先写的那份被后写的悄悄盖掉。
judge: dict | None = None


# -- 任务记录 ------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""出门那道闸:把判官读数翻成"交不交付"。

在 server 而不是 ai_engine,因为判出问题之后的每一种处置(退款、重跑、换母版)都要
再花一次钱,那是产品决策;引擎的分工是如实报数,判决归调用方。
"""

from __future__ import annotations

import logging
from dataclasses import dataclass

from windup_ai_engine.ports import JudgePort, JudgeVerdict
from windup_framework.config.quality_gate import QualityGateSettings, settings

logger = logging.getLogger("windup.generation.quality_gate")

# 稳定的机器可读标签。用槽位名而不是句子,是因为下游要按它分桶统计;
# 给人看的解释在 :func:`_problems` 的判据里,不在这几个字符串里。
PROBLEM_MULTIPLE_SUBJECTS = "multiple_subjects"
PROBLEM_NO_SUBJECT = "no_subject"
PROBLEM_FOREIGN_OBJECTS = "foreign_objects"
PROBLEM_ACTION_MISMATCH = "action_mismatch"
PROBLEM_CLIPPED = "clipped"

_EXPECTED_SUBJECTS = 1


@dataclass(frozen=True)
class GateDecision:
"""一次判读的结论。

``verdict`` 有值 = 判过了,``problems`` 可能为空;``error`` 有值 = 判官坏了、
什么都没判出来,那不是"通过"。压成一个布尔的话这两种状态就没法分开了。
"""

frame_index: int
problems: tuple[str, ...] = ()
blocked: bool = False
verdict: JudgeVerdict | None = None
error: str | None = None

def as_payload(self) -> dict:
"""写进任务结果的形状 —— 复核要用的东西一样不少(含模型原话)。"""
data: dict = {
"frame_index": self.frame_index,
"problems": list(self.problems),
"blocked": self.blocked,
}
if self.error is not None:
data["error"] = self.error
if self.verdict is not None:
data["subject_count"] = self.verdict.subject_count
data["foreign_objects"] = list(self.verdict.foreign_objects)
data["action_matches"] = self.verdict.action_matches
data["clipped"] = self.verdict.clipped
data["raw"] = self.verdict.raw
return data


class QualityBlocked(ValueError):
"""判官判出问题且闸口处于拦截档 —— 不交付。"""

def __init__(self, problems: tuple[str, ...]) -> None:
super().__init__(f"交付被判官拦下:{', '.join(problems)}")
self.problems = problems


def pick_frame(count: int) -> int:
"""只判一帧 —— 判满一段等于把成本乘上帧数。

取中间那帧而不是首帧:首帧最接近母版,正是"动作对不对"最看不出来的一帧。
"""
return count // 2


def review(
judge: JudgePort | None,
frames: list[bytes],
master: bytes,
action: str,
*,
config: QualityGateSettings = settings,
) -> GateDecision | None:
"""判一段交付物;``None`` = 没判(没注入判官或闸口未启用)。

"没判"要与"判了没问题"分得开:只有后者能支持"这批产物是干净的"这句话。
"""
if judge is None or not config.enabled or not frames:
return None

index = pick_frame(len(frames))
try:
verdict = judge.judge(frames[index], master, action)
except Exception as exc: # noqa: BLE001 —— 判官的任何故障都归"仪器坏了"
# 仪器故障绝不拦截:拦下去等于因为我们自己的判官挂了,把用户已付费的产物扣住。
logger.warning("判官判读失败(第 %d 帧,动作 %s):%s", index, action, exc)
return GateDecision(frame_index=index, error=str(exc))

problems = _problems(verdict)
# shadow 档(``enforce`` 默认 false)只记不拦:阈值要拿 shadow 数据定,反过来先开
# 拦截就是拍脑袋定判据,而误杀掉的是用户已付费、退不回来的产物。
blocked = bool(problems) and config.enforce
if problems:
logger.info("判官在第 %d 帧读出 %s(拦截=%s)", index, problems, blocked)
return GateDecision(
frame_index=index, problems=problems, blocked=blocked, verdict=verdict,
)


def _problems(verdict: JudgeVerdict) -> tuple[str, ...]:
"""四问 → 问题标签。每一条都有唯一答案,不含任何阈值。"""
found: list[str] = []
if verdict.subject_count > _EXPECTED_SUBJECTS:
found.append(PROBLEM_MULTIPLE_SUBJECTS)
elif verdict.subject_count < _EXPECTED_SUBJECTS:
found.append(PROBLEM_NO_SUBJECT)
if verdict.foreign_objects:
found.append(PROBLEM_FOREIGN_OBJECTS)
if not verdict.action_matches:
found.append(PROBLEM_ACTION_MISMATCH)
if verdict.clipped:
found.append(PROBLEM_CLIPPED)
return tuple(found)
Original file line number Diff line number Diff line change
Expand Up @@ -227,5 +227,6 @@ def _deserialize_result(
type=raw.get("type", "character_action"),
action_type=raw.get("action_type", ""),
frames=frames,
judge=raw.get("judge"),
)
return None
2 changes: 2 additions & 0 deletions backend/packages/common/src/windup_common/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
GenRoute,
Stylize,
)
from windup_common.models.quality import JudgeVerdict

__all__ = [
"ActionType",
Expand All @@ -18,4 +19,5 @@
"DEFAULT_N_FRAMES",
"CharacterCard",
"ActionSpec",
"JudgeVerdict",
]
33 changes: 33 additions & 0 deletions backend/packages/common/src/windup_common/models/quality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""判官读数的数据契约。

落在 common 而不是 ai_engine.ports,因为构造它的是 framework 层的 provider,而分层
门禁禁止 framework 依赖 ai_engine;调用方仍从 ``windup_ai_engine.ports`` 取。
"""

from __future__ import annotations

from dataclasses import dataclass


# 刻意**没有** score 字段:主观评分的噪声大到能盖过真实差异,而出参里一旦有个分数,迟早
# 有人拿它卡阈值 —— 那时卡掉的是噪声,每一次误杀都是用户已付费、不可退的产物。下面四项
# 各有唯一答案,人眼复核一遍就能确认对错。"好看"由输入端(母版规格 + 提示词骨架)保证。
@dataclass(frozen=True)
class JudgeVerdict:
"""一帧交付物的四个可数、可复核读数 —— 不含"好不好看"的判断。"""

subject_count: int
"""画面里出现了几个角色主体。期望 1;≥2 通常是 i2v 把角色分裂成了两个。"""

foreign_objects: tuple[str, ...]
"""母版里没有、生成帧里却出现的物体名。空元组 = 没有多出来的东西。"""

action_matches: bool
"""这一帧的姿态是否属于所要求的动作类别 —— 判类别,不判动作做得好不好。"""

clipped: bool
"""角色是否被画面边缘裁到。"""

raw: str
"""模型原话。留着是为了让人能复核判读本身对不对 —— 四个读数都是模型给的,
判官自己出错时,没有原话就无从分辨"产物真有问题"和"判官读错了"。"""
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ class AIProviderSettings(BaseSettings):
# 而费用可能已经产生(2026-07-29 实测)。
video_model: str = "kling-v2-5-turbo"
image_model: str = "gemini-2.5-flash-image"
# 判官是**看图的聊天模型**,不是图像生成模型:它要读一张图然后回一段 JSON,而
# ``image_model`` 那个型号只会回图;共用一个字段的话,换判官会连带把出图换掉。
# 本默认值未在本仓实测过 —— 网关目录里没有它时,``_post`` 的 400/404 分支会指到
# ``GET /models`` 去核对。
judge_model: str = "gemini-2.5-flash"

@property
def normalized_base_url(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""判官闸口配置。"""

from pydantic_settings import BaseSettings, SettingsConfigDict


class QualityGateSettings(BaseSettings):
"""判官闸口开关。环境变量前缀 ``QUALITY_GATE_``。

两个开关分开,是因为它们各自的代价不同,不该被一个 flag 绑在一起。
"""

model_config = SettingsConfigDict(
env_prefix="QUALITY_GATE_",
env_file=("../.env", ".env"),
env_file_encoding="utf-8",
extra="ignore",
)

# 每交付一个动作多打一次付费模型调用,所以默认不开:开它是一次花钱的决定。
enabled: bool = False

# 判官说有问题就不交付。默认关,而且在积够 shadow 数据、定出判据之前不该开:
# 误杀掉的是用户**已经付过钱**的产物,退不回来;而漏放一个坏产物,用户可以重试。
enforce: bool = False


settings = QualityGateSettings()
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from windup_framework.config.provider import AIProviderSettings
from windup_framework.providers.chat import create_chat_model
from windup_framework.providers.image import create_image_client
from windup_framework.providers.judge import JudgeResponseError, SufyJudgeProvider
from windup_framework.providers.interfaces import (
ImageProvider,
MatteProvider,
Expand All @@ -29,4 +30,7 @@
# FAL 队列面的 i2v(现役接口形态);首帧要公网 URL,故与 uploader 成对出现
"SufyImageProvider",
"OnnxU2NetMatteProvider",
# 判官:出参是结构化读数而不是 bytes,故不在 interfaces 的三个 Protocol 之列
"SufyJudgeProvider",
"JudgeResponseError",
]
Loading
Loading