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
105 changes: 96 additions & 9 deletions backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
from enum import Enum
from typing import Protocol, runtime_checkable

from windup_common.models import ActionSpec, CharacterCard
from windup_common.models import ActionSpec, CharacterCard, CharacterStance, Facing

from windup_ai_engine.prompt.lint import Kind, LintIssue


# ---- server 实现、注入给 ai_engine 的进度回调 port ----
Expand Down Expand Up @@ -41,8 +43,9 @@ class MasterRejected(ValueError):
"""母版不具备可生成性,在**调用付费模型之前**拒绝。

与 ai_engine 其他异常的分工(这条分工是给 server 用的):
- ``MasterRejected`` = **调用方的输入不行**,同一张母版重试多少次都一样。
server 应映射成 4xx、把 ``code`` 翻成"请换一张母版"类文案,**不要重试**。
- ``MasterRejected`` / ``PromptRejected`` = **调用方的输入不行**,同一份输入
重试多少次都一样。server 应映射成 4xx、按 ``code`` 选"换一张母版" /
"改一下这句描述"的文案,**不要重试**。
- ``NotImplementedError`` / 其他 ``ValueError`` = 引擎侧装配或产出出了问题
(路线没注入、strategy 吐空帧、帧数对不上),属于 5xx、要人介入,
让用户换母版是把锅甩错地方。
Expand All @@ -54,6 +57,88 @@ def __init__(self, code: MasterRejectCode, detail: str) -> None:
self.detail = detail


class PromptRejectCode(str, Enum):
"""用户那句动作描述被拒的原因 —— server 据此选文案,别 parse 异常消息做分支。

每个取值对应一条**模型侧的机制**(见 :mod:`windup_ai_engine.prompt.lint`),
不是文风偏好;判定全部本地零成本,发生在付费调用之前。
"""

EMPTY = "empty" # 没写动作:模型照跑,回来一段站着不动的视频
TOO_LONG = "too_long" # 越长越容易夹带外观,而外观由母版承载
NEGATION = "negation" # 无 negative_prompt,"不要 X"把 X 送进画面
HAZARD_NOUN = "hazard_noun" # 特效名词盖住轮廓,抠图留脏边
SHAPE_PRIOR = "shape_prior" # 断言母版里没有的装备形状,焊到角色身上
SUBTHRESHOLD = "subthreshold" # 幅度低于模型可控分辨率 → 逐帧随机抖
UNANCHORED_PROP = "unanchored_prop" # 没交代身体整体怎么动,手里的东西自行漂移
MULTI_STAGE = "multi_stage" # 静态模型没有时间轴,多阶段摊成分解姿势图
STANCE_MISMATCH = "stance_mismatch" # 非双足角色写人体部位 → 凭空长出人的上肢


class PromptRejected(ValueError):
"""这段描述送进模型必然出坏产物,在**调用付费模型之前**拒绝。

形状与 :class:`MasterRejected` 一致、分工同一条:它是**调用方输入不行**那一类,
server 映射 4xx 让用户改那句话,而不是 5xx 报"系统出问题了"——用户改得动的东西
被报成服务器故障,他只会重试同一句话。

多条机制同时命中时 ``code`` 取报告序里的第一条,``detail`` 仍把每条都列出来:
只讲一条会让用户改完再被下一条拦一次。
"""

def __init__(self, code: PromptRejectCode, detail: str) -> None:
super().__init__(f"这段描述跑不出可用产物({code.value}):{detail}")
self.code = code

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.

[P2] 让 PromptRejectCode 穿过异步任务边界

这里虽然保存了结构化 code,但生产调用方 run_action_task 用宽泛的 except Exception 捕获后只持久化 str(exc)GenerationTaskOut 也只有 error_message。因此 server 实际无法按 code 选文案或区分可修改的 4xx 输入错误与引擎 5xx,除非重新解析异常字符串,正好违背这个端口的设计目的。请在任务失败结果中单独序列化拒绝码(或专门捕获 PromptRejected 并完成映射)。

self.detail = detail


# ---- 用户大白话 → 正式提示词(实现在别处,见下)----
@dataclass(frozen=True)
class AdaptedPrompt:
"""一次**成功**适配的结果 —— 拿到它就等于可以往下送。

不可适配走 :class:`PromptRejected`,不在这里留一个"拒了"的字段:同一件事两条返回
路径,调用方得写两套处理,而漏写返回值那条是静默的 —— 空文本照样进付费调用。
"""

text: str
"""正式提示词。

``kind="i2v"`` 时它**不含**循环性尾句:循环与否是请求的属性(``ActionSpec.cyclic``),
适配器的入参里没有,替调用方猜一条会把一次性动作首尾闭环,而帧数 / 时长 / 成色全正常。
调用方按自己声明的循环性追加 ``prompt.custom`` 的两条尾句之一。
"""

issues: tuple[LintIssue, ...] = ()
"""确定性改写做不到、但不足以拦下的问题(warn 级)。error 级都走拒绝,不会到这里。"""


class PromptAdapterPort(Protocol):
"""把用户那句大白话改写进已验证的骨架。

引擎侧只定协议:确定性规则之外的改写要调模型,那属于 provider 那一层。

Args:
user_text: 用户自述的动作,只讲做什么动作。
kind: 目标模型类型 —— 决定哪些规则成立(见 ``prompt.lint`` 的 ``kind``)。
facing: 母版朝向。**必须与母版一致**。
stance: 角色体型(``CharacterCard.stance``)。非双足时"手臂"一类词会让模型
凭空长出人的上肢,故它参与判定,不只是记录。

Raises:
PromptRejected: 这段描述送进模型必然出坏产物 → 4xx,让用户改这句话。
"""

def adapt(
self,
user_text: str,
*,
kind: Kind,
facing: Facing,
stance: CharacterStance,
) -> AdaptedPrompt: ...


# ---- ai_engine 出参(不含存储引用:上传 / 落库在 server 侧)----
@dataclass(frozen=True)
class ActionQuality:
Expand Down Expand Up @@ -132,12 +217,12 @@ class CharacterGeneratorPort(Protocol):
不关心租户 / 配额 / 任务状态 / 存储(那些在 app.server)。

Args:
card: 角色卡。**当前唯一实现的视频路线一个字段都不读**——``git grep 'card\\.'``
在 ai_engine 下零命中(2026-08-08 复核)。这不是遗漏:i2v 的角色身份完全由
``master`` 这张母版图像承载,身份描述再写一遍反而会和母版打架。本参数是给
未实现路线预留的入参:逐帧图生图(#53)要靠 ``name`` / ``desc`` 在每帧提示词里
锁一致性,渲染出帧(#81 #122)要靠 ``master_ref`` / ``version`` 定位 3D 资产。
**调用方不要指望改 card 能影响视频路线的产出。**
card: 角色卡。视频路线**只读 ``stance`` 一个字段**,且它不进提示词、只决定用户那句
描述里的人体部位词(手臂 / 手肘)放不放行 —— 非双足角色放行了,模型会给它接上
一对人的上肢。**角色身份不读 card**:i2v 的一致性完全由 ``master`` 这张母版图像
承载,身份描述再写一遍反而会和母版打架。其余字段是给未实现路线预留的:逐帧图生图
(#53)要靠 ``name`` / ``desc`` 在每帧提示词里锁一致性,渲染出帧(#81 #122)要靠
``master_ref`` / ``version`` 定位 3D 资产。**改 name / desc 影响不了视频路线的产出。**
action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。视频路线的实际入参在这里:
``action``、``n_frames``、``facing``、``stylize`` 等。
master: 定妆母版图 bytes(server 从 reference_image_url 取)。**视频路线的
Expand All @@ -154,6 +239,8 @@ class CharacterGeneratorPort(Protocol):
Raises:
MasterRejected: 母版形态不可生成(见 :class:`MasterRejectCode`)。**在花钱
之前抛**,同一张母版重试无意义 → server 映射 4xx、请用户换母版。
PromptRejected: ``action=custom`` 时用户那句描述必然出坏产物(见
:class:`PromptRejectCode`)。同样在花钱之前抛 → 4xx、请用户改那句话。
NotImplementedError: 该动作分流到的路线没有实现或没注入 strategy。
ValueError: 产出对不上契约(空帧 / 帧数不足)。钱已经花了,但错产物不放行。

Expand Down
119 changes: 119 additions & 0 deletions backend/packages/ai_engine/src/windup_ai_engine/prompt/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""零模型的 :class:`~windup_ai_engine.ports.PromptAdapterPort` 实现。

先做规则版而不是直接上 LLM:它不花钱、确定性、可测,且换成 LLM 版之后它仍然是兜底
(模型不可用时的降级)与对照组(判断 LLM 改写到底有没有比规则更好)。

它只做确定性做得到的三件事:跑门禁并在 error 级上拒掉、把用户那句话嵌进已验证的骨架、
追加统一的单主体与构图后缀。**翻译、改写措辞、把"轻微"换成一个具体幅度,规则做不到**
—— 那些是 LLM 版的活,这里只负责讲清楚拦在哪、为什么。

放在 ai_engine 而不是 framework:分层门禁(``lint-imports`` 的"包分层链")规定
framework 在 ai_engine 之下,framework 里的模块 import 不到本层的门禁与骨架。
将来的 LLM 版同样住这一层,按 ``VideoFrameStrategy`` 与 ``VideoProvider`` 的成例,
把模型调用作为 framework 的 provider 注入进来。
"""
from __future__ import annotations

from windup_common.models import CharacterStance, Facing

from windup_ai_engine.ports import AdaptedPrompt, PromptRejectCode, PromptRejected
from windup_ai_engine.prompt.custom import MAX_ACTION_CHARS, build_custom_body
from windup_ai_engine.prompt.lint import Kind, lint

__all__ = ["RuleBasedPromptAdapter"]

# 统一后缀。全是正向措辞:这条通路没有 negative_prompt,"背景里没有别人"会把别人请进来。
_COMPOSITION = (
"One single character alone in the frame, the whole body inside the frame, "
"on one plain flat background."
)

# 静态模型没有时间轴,一段多阶段描述会被摊平成并排的分解姿势图 —— 一张图里好几个身位,
# 而它对切片来说是废的。故给静态模型的必须是单一瞬间。
_SINGLE_INSTANT = "ONE single frozen instant of that motion, one single pose."

_STAGE_MARKERS = (
"then", "after that", "afterwards", "followed by", "next,", "and finally",
"然后", "接着", "紧接着", "之后", "再", "最后", "先", "收势",
)
_ARM_WORDS = ("arm", "arms", "elbow", "hand", "hands", "手臂", "胳膊", "手肘")

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.

[P2] 识别中文“手”类体型冲突措辞

非双足门禁覆盖了 手臂/胳膊/手肘,却漏掉更常见的 手/左手/双手;例如四足角色描述 举起左手 不会命中 _ARM_WORDS,会直接进入付费 i2v,而英文等价句会被拒绝。请用能覆盖这些中文组合的匹配规则,并避免简单加入单字后对无关词造成误报。


# 每个非双足体型自带一套可替换的部位说法:拒绝理由要给得出改法,"这个词不行"给不了。
# 缺一支就是拒了却说不出改哪儿,故 :class:`CharacterStance` 加成员必须同时加这里。
_STANCE_PARTS = {
CharacterStance.QUADRUPED: "前肢 / 头颈 / 尾",
CharacterStance.SERPENTINE: "躯干起伏 / 尾 / 头颈",
}

# 门禁类别 → 拒绝码。直查不 get:漏配一条是引擎侧的装配缺口(该 5xx 让人介入),
# 兜个通用码会把它伪装成用户的输入问题,而用户按那条文案改多少遍都过不了。
_CODE_BY_CATEGORY = {
"negation": PromptRejectCode.NEGATION,
"hazard_noun": PromptRejectCode.HAZARD_NOUN,
"shape_prior": PromptRejectCode.SHAPE_PRIOR,
"subthreshold": PromptRejectCode.SUBTHRESHOLD,
"unanchored_prop": PromptRejectCode.UNANCHORED_PROP,
}


class RuleBasedPromptAdapter:
"""确定性适配:能判的当场判,判不了的照原样嵌进骨架。"""

def adapt(
self,
user_text: str,
*,
kind: Kind = "i2v",
facing: Facing = Facing.SIDE,
stance: CharacterStance | str = CharacterStance.BIPED,
) -> AdaptedPrompt:
"""Raises ``PromptRejected``:这段描述送进模型必然出坏产物,理由带 code 与机制。"""
stance = CharacterStance(stance) # 非法体型要炸,不静默按双足放行
clause = (user_text or "").strip()
if not clause:
raise PromptRejected(
PromptRejectCode.EMPTY,
"没写动作内容。空描述不会报错,只会拿回一段站着不动的视频,"
"而帧数和时长全对、看不出描述丢了。",
)

if len(clause) > MAX_ACTION_CHARS:
raise PromptRejected(
PromptRejectCode.TOO_LONG,
f"描述有 {len(clause)} 字,超过上限 {MAX_ACTION_CHARS}。描述越长越容易"
f"夹带角色外观,而外观由母版承载,写两遍会打架。只留动作本身。",
)

issues = lint(clause, kind=kind)
blockers = [
(_CODE_BY_CATEGORY[i.category], i.message) for i in issues if i.level == "error"
]
low = clause.lower()

if kind == "still":
marker = next((m for m in _STAGE_MARKERS if m in low), None)
if marker:
blockers.append((
PromptRejectCode.MULTI_STAGE,
f"「{marker}」把这段描述分成了好几个阶段,而静态模型没有时间轴:"
f"它会把各阶段并排画成一张分解姿势图,一张图里好几个身位。"
f"只描述其中一个瞬间。",
))

if stance is not CharacterStance.BIPED:
hit = next((w for w in _ARM_WORDS if w in low), None)
if hit:
blockers.append((
PromptRejectCode.STANCE_MISMATCH,
f"这个角色的体型是 {stance.value},不是双足,而「{hit}」会让模型给它凭空"
f"接上人的上肢。改写成发力的那个部位({_STANCE_PARTS[stance]})。",
))

if blockers:
raise PromptRejected(
blockers[0][0], "\n".join(f"· {m}" for _, m in blockers)
)

body = build_custom_body(clause, facing=facing)
parts = [body, _SINGLE_INSTANT, _COMPOSITION] if kind == "still" else [body, _COMPOSITION]
return AdaptedPrompt(text=" ".join(parts), issues=tuple(issues))
41 changes: 29 additions & 12 deletions backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@

from windup_common.models import Facing

__all__ = ["build_custom_prompt", "MAX_ACTION_CHARS"]
__all__ = [
"build_custom_prompt",
"build_custom_body",
"CYCLIC_TAIL",
"ONESHOT_TAIL",
"MAX_ACTION_CHARS",
]

# 不是接口限制,是产品判断:描述越长越容易夹带角色外观,而外观由母版承载,写两遍会打架。
MAX_ACTION_CHARS = 200
Expand All @@ -33,16 +39,34 @@

# 两条尾句都刻意不写"在地面上 / 双脚可见 / 回到直立站姿"——那些是着地直立类动作的前提,
# 游泳、飞行、攀爬、倒地都不成立,而文字与动作矛盾时模型会自己找辙调和。
_CYCLIC_TAIL = (
# 两条尾句公开:循环性是**请求**的属性(ActionSpec.cyclic),不是文本的属性,所以任何
# 只拿到文本的组件(如 prompt.adapter)都不该替调用方选一条 —— 猜错是静默的。
CYCLIC_TAIL = (
"The motion is one smooth repeating cycle that returns to its starting pose, "
"and the character stays centered in the same spot in frame."
)
_ONESHOT_TAIL = (
ONESHOT_TAIL = (
"The character performs this ONCE as one single committed motion, "
"then holds the final pose and stays still."
)


def build_custom_body(action: str, *, facing: Facing | str = Facing.SIDE) -> str:
"""朝向锁 + 用户那句话 + 装备存在无关句,**不含**循环性尾句。

Raises:
ValueError: 理由同 :func:`build_custom_prompt`。
"""
text = (action or "").strip()
if not text:
raise ValueError("自定义动作的描述不能为空")
if len(text) > MAX_ACTION_CHARS:
raise ValueError(f"自定义动作描述 {len(text)} 字,超过上限 {MAX_ACTION_CHARS}")
lock = _FACING_LOCK[Facing(facing)] # 非法朝向要炸,不静默落到某一支
# 朝向放最前:最强的约束先钉。
return f"The character {lock}: {text}, {_KEEP_WHAT_IT_HAS}."


def build_custom_prompt(
action: str,
*,
Expand All @@ -60,12 +84,5 @@ def build_custom_prompt(
ValueError: 描述为空或超长。空描述不兜底默认动作——那会付一次 i2v 的钱拿到一段
站着不动的视频,而帧数时长全对、看不出描述丢了。
"""
text = (action or "").strip()
if not text:
raise ValueError("自定义动作的描述不能为空")
if len(text) > MAX_ACTION_CHARS:
raise ValueError(f"自定义动作描述 {len(text)} 字,超过上限 {MAX_ACTION_CHARS}")
lock = _FACING_LOCK[Facing(facing)] # 非法朝向要炸,不静默落到某一支
tail = _CYCLIC_TAIL if cyclic else _ONESHOT_TAIL
# 朝向放最前:最强的约束先钉。
return f"The character {lock}: {text}, {_KEEP_WHAT_IT_HAS}. {tail}"
tail = CYCLIC_TAIL if cyclic else ONESHOT_TAIL
return f"{build_custom_body(action, facing=facing)} {tail}"
Loading
Loading