diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py index fb11abb2..68d28959 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py @@ -3,9 +3,11 @@ **核心规律(三次实测验证,写死为契约):母版姿态决定动作,提示词只能微调。** - walk:母版**朝侧向**才不转身;正面母版配侧走词 → 模型靠转身调和图文矛盾。 - jump:母版**顶部留白**才不被视频画面裁掉。 - - attack:必须给**极限蓄力母版**(出手那只手已拉到身后腰际)。用站立母版时,即使提示词 + - attack:必须给**极限蓄力母版**(发力那一侧已拉到待发位)。用站立母版时,即使提示词 写死"不过头顶 / 不转身 / 只做一次",模型仍会抡过头顶、转到背面、劈两次 —— 强动作 - 先验压不住;换蓄力母版后模型只能"接着往前挥",没有再抡起的空间。 + 先验压不住;换蓄力母版后模型只能"接着往前发力",没有再抡起的空间。 + 蓄力姿态按运动拓扑分四支(见 :data:`ATTACK_MASTER_POSES`):同一张横挥蓄力母版 + 喂给直刺 / 远程 / 前扑,模型会先把收好的那一侧重新抡起来再做。 **姿势描述里不写装备名词(#195)。** 这几段是拿去生成母版的提示词,写"the weapon"等于 断言角色持械 —— 空手角色会被凭空塞一把武器,而母版是整条 i2v 链的身份来源,污染会一路 @@ -27,16 +29,26 @@ from PIL import Image +from windup_common.models import AttackArchetype + from windup_ai_engine._subject import bg_color as _bg_color from windup_ai_engine.prompt._md import load_section -__all__ = ["add_headroom", "prepare_master", "MASTER_POSES"] +__all__ = ["add_headroom", "prepare_master", "MASTER_POSES", "ATTACK_MASTER_POSES"] # 空值 = 该动作用中性站立母版即可。这是唯一允许空提示词的地方,故显式放行 —— # 别处的空串会一路跑到付费调用。 MASTER_POSES = { a: load_section("master_poses.md", a, allow_empty=True) - for a in ("walk", "run", "idle", "jump", "attack") + for a in ("walk", "run", "idle", "jump") +} + +# attack 按运动拓扑取母版姿态:四支的起手姿态互不兼容(横挥蓄力母版跑不出直刺), +# 而"母版姿态决定动作"对 attack 最狠 —— 见本模块开头。这里不放行空值: +# 四支都必须有自己的蓄力姿态,缺一支就该炸,不能退回中性站立。 +ATTACK_MASTER_POSES = { + arch: load_section("master_poses.md", f"attack.{arch.value}") + for arch in AttackArchetype } diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py new file mode 100644 index 00000000..02966988 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py @@ -0,0 +1,21 @@ +"""所有动作共用的构图约束。 + +由代码统一追加而不是抄进每份 md:同一条约束抄 N 份会各自漂移。 + +只写正向计数句 —— 该 i2v 接口没有 negative_prompt,否定句里的名词会被 latch 进画面 +(实测"do not add dust"反而勾出更多灰尘),所以说"恰好一个",不说"不要第二个"。 +""" +from __future__ import annotations + +__all__ = ["SINGLE_SUBJECT_FRAMING", "with_framing"] + +# 攻击的两处留白(母版姿态要求 + 母版补边)让画面空得足以容下第二个主体。 +SINGLE_SUBJECT_FRAMING = ( + "Exactly one character is in the frame, alone against a plain flat solid-color background, " + "and the whole body stays inside the frame." +) + + +def with_framing(body: str) -> str: + """给一段动作正文接上构图约束。""" + return f"{body} {SINGLE_SUBJECT_FRAMING}" diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py index a6b43c56..d615ce23 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py @@ -1,12 +1,13 @@ """待机 / 攻击 i2v 提示词。 提示词正文在 ``prompts/idle.md`` 与 ``prompts/attack.md``(#233)。 -本模块只留加载与按 facing 分流。 +本模块只留加载与按 facing / archetype 分流。 """ from __future__ import annotations -from windup_common.models import Facing +from windup_common.models import AttackArchetype, Facing +from windup_ai_engine.prompt._framing import with_framing from windup_ai_engine.prompt._md import load_section __all__ = ["build_idle_prompt", "build_attack_prompt"] @@ -16,11 +17,18 @@ def build_idle_prompt(facing: Facing | str = Facing.SIDE) -> str: """待机正文(循环类)。``facing`` 须与母版朝向一致。 """ - return load_section("idle.md", Facing(facing).value) + return with_framing(load_section("idle.md", Facing(facing).value)) -def build_attack_prompt(facing: Facing | str = Facing.SIDE) -> str: +def build_attack_prompt( + facing: Facing | str = Facing.SIDE, + *, + archetype: AttackArchetype | str = AttackArchetype.THRUST, +) -> str: """攻击正文(一次性类)。``facing`` 须与母版朝向一致。 + 默认取 THRUST:四支里只有 SWEEP 要求手里有一件有宽面的长条物,拿它当默认 = 对每个未知角色断言持械(#195)。 """ - return load_section("attack.md", Facing(facing).value) + # 两个枚举都过一遍构造:非法值要炸,不能静默落到某一节。 + section = f"{AttackArchetype(archetype).value}.{Facing(facing).value}" + return with_framing(load_section("attack.md", section)) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py index 5e6b937a..33ba36d9 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py @@ -9,6 +9,8 @@ from windup_common.models import Facing +from windup_ai_engine.prompt._framing import with_framing + __all__ = ["build_custom_prompt", "MAX_ACTION_CHARS"] # 不是接口限制,是产品判断:描述越长越容易夹带角色外观,而外观由母版承载,写两遍会打架。 @@ -68,4 +70,4 @@ def build_custom_prompt( lock = _FACING_LOCK[Facing(facing)] # 非法朝向要炸,不静默落到某一支 tail = _CYCLIC_TAIL if cyclic else _ONESHOT_TAIL # 朝向放最前:最强的约束先钉。 - return f"The character {lock}: {text}, {_KEEP_WHAT_IT_HAS}. {tail}" + return with_framing(f"The character {lock}: {text}, {_KEEP_WHAT_IT_HAS}. {tail}") diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py index 4ee3d2f0..6aef07bb 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py @@ -6,6 +6,7 @@ from windup_common.models import Facing +from windup_ai_engine.prompt._framing import with_framing from windup_ai_engine.prompt._md import load_section __all__ = ["JUMP_PHASES", "build_jump_prompt"] @@ -23,4 +24,4 @@ def build_jump_prompt(facing: Facing | str = Facing.SIDE) -> str: facing: :class:`Facing` 成员(或其等价字符串),**必须与母版朝向一致**。 """ - return load_section(_DOC, Facing(facing).value) + return with_framing(load_section(_DOC, Facing(facing).value)) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md index da197a66..d46662ae 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md @@ -1,22 +1,96 @@ # 攻击 i2v 提示词(一次性类) -## side +节名是 `<运动拓扑>.<朝向>`。分支依据是**身体怎么发力**,不是手里拿着什么: +写"宽面""弧线"这类形状词等于断言角色手握一件有宽面的长条物,喂法杖 / 空手 / 四足角色时 +模型会凭空补出那件东西来调和图文矛盾(与 #195 同一个坑,只是从名词层退到形状层)。 + +四支都要写整体位移(whole body / torso / hips):i2v 强跟身体、弱跟持物, +只描述持物的运动会让它自行漂移。 + +## sweep.side + +```text +Seen from the side facing right, the character makes ONE single committed strike, staying in STRICT SIDE VIEW the whole time: +starting coiled with the weight on the back foot, the whole body uncoils and the hips drive forward as the weight surges onto the front foot, +the striking side of the body travelling in one continuous path from far behind the body, down across the front of the torso, out to full extension low in front, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the torso settles back upright into guard and holds that stance. +The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. +``` + +## sweep.front + +```text +Facing the viewer, the character makes ONE single committed strike: starting coiled with the weight on the back foot, +the whole body uncoils forward and the hips turn into the motion as the weight surges onto the front foot, +the striking side of the body travelling in one continuous path across the front of the torso out to full extension, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the torso settles back upright into guard and holds that stance, standing steady and keeping FACING THE VIEWER. +``` + +## thrust.side + +```text +Seen from the side facing right, the character drives ONE single committed strike straight forward, staying in STRICT SIDE VIEW the whole time: +starting coiled low with the weight on the back foot and the striking side pulled in at waist height, +the hips snap forward and the whole body drives straight ahead as the weight lands on the front foot, +the striking side of the body extending in one straight line directly forward to full reach and stopping there, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the torso draws back over the hips and settles into guard and holds that stance. +The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. +``` + +## thrust.front + +```text +Facing the viewer, the character drives ONE single committed strike straight toward the viewer: +starting coiled low with the weight on the back foot and the striking side pulled in at waist height, +the hips snap forward and the whole body drives straight ahead as the weight lands on the front foot, +the striking side of the body extending in one straight line directly toward the viewer to full reach and stopping there, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the torso draws back over the hips and settles into guard and holds that stance, standing steady and keeping FACING THE VIEWER. +``` + +## project.side + +```text +Seen from the side facing right, the character makes ONE single committed ranged release, staying in STRICT SIDE VIEW the whole time: +starting settled with the weight low over both feet, the torso presses forward over the front foot and the hips square up behind the motion, +the releasing side of the body reaching straight out in front of the chest and coming to a firm stop at full extension, +the whole body braced and steady at that moment, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the character keeps that extended pose and stays still. +The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. +``` + +## project.front + +```text +Facing the viewer, the character makes ONE single committed ranged release toward the viewer: +starting settled with the weight low over both feet, the torso presses forward and the hips square up behind the motion, +the releasing side of the body reaching straight out in front of the chest toward the viewer and coming to a firm stop at full extension, +the whole body braced and steady at that moment, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the character keeps that extended pose and stays still, keeping FACING THE VIEWER. +``` + +## lunge.side ```text -Seen from the side facing right, the character makes ONE single committed attack, staying in STRICT SIDE VIEW the whole time: -starting coiled with the weight on the back foot, the body leans forward and the weight surges onto the front foot, -the leading arm sweeping through one smooth downward crescent arc from high behind the shoulder down across the front to full extension low, -anything held in that hand keeping its exact length and grip position and staying clearly in front of the body with its broad side facing the viewer the whole way, -whatever the character already wears swinging with the motion, then the body settles back upright into guard and holds that stance, -standing steady. The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. +Seen from the side facing right, the character makes ONE single committed lunge forward, staying in STRICT SIDE VIEW the whole time: +starting crouched low with the weight loaded onto the rear limbs, the whole body surges forward in one burst with the head and the leading limbs arriving first, +the hips and torso following along that same line and the back stretching out level and low over the ground, +whatever the character already wears or carries keeps its own shape and moves with the body, +then the body gathers back under itself, settles low and holds that crouched stance. +The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. ``` -## front +## lunge.front ```text -Facing the viewer, the character makes ONE single committed attack: starting coiled with the weight on the back foot, -the whole body uncoils forward, the leading arm sweeping through one smooth arc across the front to full extension, -anything held in that hand keeping its exact length and grip position and staying clearly in front of the body with its broad side facing the viewer the whole way, -whatever the character already wears swinging with the motion, then the body settles back upright into guard and holds that stance, -standing steady and keeping FACING THE VIEWER. +Facing the viewer, the character makes ONE single committed lunge toward the viewer: +starting crouched low with the weight loaded onto the rear limbs, the whole body surges forward in one burst with the head and the leading limbs arriving first, +the hips and torso following along that same line and the back stretching out level and low over the ground, +whatever the character already wears or carries keeps its own shape and moves with the body, +then the body gathers back under itself, settles low and holds that crouched stance, keeping FACING THE VIEWER. ``` diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md index d508bca5..88acdb4e 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md @@ -1,5 +1,8 @@ # 各动作所需的母版姿态 +attack 按运动拓扑分四节(`attack.<拓扑>`):母版姿态决定动作、提示词只能微调, +而四支的起手姿态互不兼容 —— 拿横挥蓄力母版跑直刺,模型会先把收在腰际的那侧重新抡起来。 + ## walk ## run @@ -13,10 +16,36 @@ deep crouch coiled to spring straight upward: the knees bent low and the hips su the weight loaded onto both legs at the very moment before springing straight up, anything held in the hands kept in a fixed grip; leave generous empty space above the head ``` -## attack +## attack.sweep ```text extreme wind-up stance for a horizontal strike: the striking hand drawn far BACK behind the body at WAIST height, the torso twisted back and coiled, weight fully loaded on the back leg, both arms low and pulled back, that hand and anything held in it staying BELOW the shoulders; leave generous empty space on the swing side ``` + +## attack.thrust + +```text +low coiled stance ready to drive straight forward: the weight sunk onto the back leg with both knees bent, +the striking side pulled in tight against the body at WAIST height and held there ready to fire, +the torso squared low over the front foot, that side and anything held in it staying BELOW the shoulders; +leave generous empty space in front +``` + +## attack.project + +```text +braced stance ready to send something forward at a distance: both feet planted wide and firmly set, +the hips sunk low and the weight centred between the feet, the torso upright and square, +both hands drawn in close in front of the chest and held there, anything held in them kept in a fixed grip; +leave generous empty space in front +``` + +## attack.lunge + +```text +crouched stance coiled to spring forward: all four limbs folded under the body with the chest lowered close to the ground, +the rear limbs deeply loaded and ready to drive, the head and the leading limbs pointing forward along the line of travel, +anything the character carries kept in place; leave generous empty space in front +``` diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py index 3e70ef6b..5f9a74fe 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py @@ -7,6 +7,7 @@ from windup_common.models import Facing +from windup_ai_engine.prompt._framing import with_framing from windup_ai_engine.prompt._md import load_section __all__ = ["build_walk_prompt"] @@ -22,4 +23,4 @@ def build_walk_prompt(facing: Facing | str = Facing.SIDE) -> str: """ # 过一遍 Facing() 构造:非法值要炸,不能静默落到某个模板。 - return load_section(_DOC, Facing(facing).value) + return with_framing(load_section(_DOC, Facing(facing).value)) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py index 4274ddbd..c7a5b757 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -56,10 +56,15 @@ def _build_prompt(self, action: ActionSpec) -> str: facing=action.facing, cyclic=bool(action.cyclic), ) + # attack 同样进不了那张表:它还要按运动拓扑选提示词分支。archetype 缺省时不在这里 + # 兜一个默认值 —— 缺省只由 build_attack_prompt 定义一次,写两处会各自漂移。 + if action.action is ActionType.ATTACK: + if action.archetype is None: + return build_attack_prompt(facing=action.facing) + return build_attack_prompt(facing=action.facing, archetype=action.archetype) builders = { ActionType.JUMP: build_jump_prompt, ActionType.IDLE: build_idle_prompt, - ActionType.ATTACK: build_attack_prompt, } build = builders.get(action.action, build_walk_prompt) return build(facing=action.facing) diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py index 52434b34..688979b1 100644 --- a/backend/packages/common/src/windup_common/models/__init__.py +++ b/backend/packages/common/src/windup_common/models/__init__.py @@ -2,6 +2,7 @@ DEFAULT_N_FRAMES, ActionSpec, ActionType, + AttackArchetype, CharacterCard, CharacterView, Facing, @@ -11,6 +12,7 @@ __all__ = [ "ActionType", + "AttackArchetype", "GenRoute", "Facing", "CharacterView", diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py index 07cdbf3d..b7ff3856 100644 --- a/backend/packages/common/src/windup_common/models/character.py +++ b/backend/packages/common/src/windup_common/models/character.py @@ -58,6 +58,19 @@ class ActionType(str, Enum): CUSTOM = "custom" +class AttackArchetype(str, Enum): + """攻击的运动拓扑 —— 决定 ``prompts/attack.md`` 取哪一节(取值即节名前缀)。 + + 按"身体怎么发力"分而不按装备形状分:提示词里的形状先验(宽面、弧线)等于断言角色 + 手握一件有宽面的长条物,喂空手 / 法杖 / 四足角色时模型会凭空补出那件东西来调和矛盾。 + """ + + SWEEP = "sweep" # 长条持物:横挥 / 下劈 + THRUST = "thrust" # 短持物或空手:直出 / 戳刺 + PROJECT = "project" # 远程:身体前压、送到位、终态保持 + LUNGE = "lunge" # 非双足:整体前扑,头部 / 前肢领先 + + class GenRoute(str, Enum): """生成路线 —— 实测挣得的分流依据(见 ai_engine.strategy 层 docstring)。 @@ -191,6 +204,19 @@ class ActionSpec(BaseModel): # pick_cycle 还是 pick_oneshot、出参要不要量 loop_seam。 cyclic: bool | None = None + # 攻击走哪一支运动拓扑。``None`` = 不指定,由 ``build_attack_prompt`` 的默认值决定 —— + # 这里不给默认值,否则同一个缺省被两处各写一份,改一处另一处静默不动。 + archetype: AttackArchetype | None = None + + @model_validator(mode="after") + def _archetype_belongs_to_attack_only(self) -> ActionSpec: + """非攻击动作带 archetype 要炸:它只被攻击提示词消费,传了不会生效。""" + if self.action is not ActionType.ATTACK and self.archetype is not None: + raise ValueError( + f"action={self.action.value} 不该带 archetype;它只决定攻击提示词取哪一支,传了不会生效" + ) + return self + @model_validator(mode="after") def _custom_needs_its_own_settings(self) -> ActionSpec: """两个方向都卡:缺了只能猜、而猜错是静默的;多给了调用方以为能覆盖 walk 的 diff --git a/backend/tests/test_attack_archetypes.py b/backend/tests/test_attack_archetypes.py new file mode 100644 index 00000000..4a75dc11 --- /dev/null +++ b/backend/tests/test_attack_archetypes.py @@ -0,0 +1,209 @@ +"""攻击提示词按运动拓扑分支 + 全动作统一构图约束。 + +这一片锁的是 #195 的**形状层**残留:名词清干净了,`crescent arc` / `broad side` 这类 +形状短语仍在断言"手里有一件有宽面的长条物"。喂法杖 / 空手 / 四足角色时,模型调和图文 +矛盾最省力的解法就是凭空补出那件东西 —— 而帧数、时长、成色全部正常,没有一道会红。 +""" +from __future__ import annotations + +import itertools + +import pytest +from pydantic import ValidationError + +from windup_ai_engine.master_prep import ATTACK_MASTER_POSES +from windup_ai_engine.prompt import ( + build_attack_prompt, + build_custom_prompt, + build_idle_prompt, + build_jump_prompt, + build_walk_prompt, +) +from windup_ai_engine.prompt._framing import SINGLE_SUBJECT_FRAMING +from windup_ai_engine.strategy.concrete import VideoFrameStrategy +from windup_common.models import ActionSpec, ActionType, AttackArchetype, Facing + +FACINGS = (Facing.SIDE, Facing.FRONT) +COMBOS = list(itertools.product(AttackArchetype, FACINGS)) + +# 装备名词(#195)+ 形状短语(本片)。后者不点名任何物件,却同样断言了它的几何。 +_SHAPE_PRIORS = ( + "sword", "blade", "staff", "wand", "weapon", "claw", "fist", + "broad side", "crescent", "long edge", "flat of the", +) + +# 远程支不许写接触弧:写"挥过去 / 划过去"就是在逼模型造一个被打的对象。 +_CONTACT_ARC = ("sweep", "swing", "arc", "slash", "across the front", "connects with") + +# 整体位移词。i2v 强跟身体、弱跟持物,只写持物动作会让它自行漂移。 +_WHOLE_BODY = ("whole body", "torso", "hips", "lunge") + + +def _hits(text: str, words) -> list[str]: + low = text.lower() + return [w for w in words if w in low] + + +# ── ① 八种组合都取得到,且互不相同 ──────────────────────────────────────── + + +@pytest.mark.parametrize("archetype,facing", COMBOS) +def test_every_archetype_and_facing_resolves_to_real_text(archetype, facing): + text = build_attack_prompt(facing=facing, archetype=archetype) + assert len(text) > 200, f"{archetype.value}.{facing.value} 短得不像正文:{text!r}" + + +def test_the_eight_combinations_are_all_distinct(): + """任意两支撞车 = 有一支没写、静默复用了别支的运动拓扑。""" + texts = [build_attack_prompt(facing=f, archetype=a) for a, f in COMBOS] + assert len(set(texts)) == len(COMBOS) + + +def test_illegal_archetype_raises_instead_of_falling_back(): + with pytest.raises(ValueError): + build_attack_prompt(facing=Facing.SIDE, archetype="sweeping") + + +# ── ② 四支都不带装备形状先验 ───────────────────────────────────────────── + + +@pytest.mark.parametrize("archetype,facing", COMBOS) +def test_no_branch_asserts_a_held_shape(archetype, facing): + named = _hits(build_attack_prompt(facing=facing, archetype=archetype), _SHAPE_PRIORS) + assert not named, f"{archetype.value}.{facing.value} 断言了持物形状: {named}" + + +@pytest.mark.parametrize("archetype,facing", COMBOS) +def test_the_striking_subject_is_a_body_part_not_an_arm(archetype, facing): + """主语写"手臂"= 断言角色有手臂,四足角色没有。""" + low = build_attack_prompt(facing=facing, archetype=archetype).lower() + assert "arm" not in low.replace("armour", "").replace("armor", ""), \ + f"{archetype.value}.{facing.value} 把发力主语写成了手臂" + + +@pytest.mark.parametrize("archetype,facing", COMBOS) +def test_every_branch_moves_the_whole_body(archetype, facing): + text = build_attack_prompt(facing=facing, archetype=archetype) + assert _hits(text, _WHOLE_BODY), \ + f"{archetype.value}.{facing.value} 只写了肢体动作,没写身体整体位移" + + +@pytest.mark.parametrize("facing", FACINGS) +def test_ranged_branch_describes_no_contact_arc(facing): + """远程支写"挥过去"= 逼模型在画面里造一个被打的对象。""" + text = build_attack_prompt(facing=facing, archetype=AttackArchetype.PROJECT) + assert not _hits(text, _CONTACT_ARC), f"project.{facing.value} 写了接触弧: {_hits(text, _CONTACT_ARC)}" + + +def test_the_contact_arc_check_would_catch_a_real_swing(): + """反向校准:这组词在近战支上确实会命中,否则上一条恒真、什么也没测。""" + swept = build_attack_prompt(facing=Facing.SIDE, archetype=AttackArchetype.SWEEP) + assert _hits(swept, _CONTACT_ARC) + + +# ── ③ 默认支必须是 THRUST ──────────────────────────────────────────────── + + +def test_default_archetype_is_thrust_not_sweep(): + """SWEEP 是唯一要求"手里有一件有宽面长条物"的一支;拿它当默认 = 对每个未知角色断言持械。""" + assert build_attack_prompt() == build_attack_prompt(archetype=AttackArchetype.THRUST) + assert build_attack_prompt() != build_attack_prompt(archetype=AttackArchetype.SWEEP) + + +# ── ④ ActionSpec 契约:archetype 只属于 attack ──────────────────────────── + + +@pytest.mark.parametrize( + "action", [a for a in ActionType if a is not ActionType.ATTACK and a is not ActionType.CUSTOM] +) +def test_non_attack_actions_must_not_carry_an_archetype(action): + with pytest.raises(ValidationError, match="archetype"): + ActionSpec(action=action, archetype=AttackArchetype.SWEEP) + + +def test_custom_action_must_not_carry_an_archetype_either(): + with pytest.raises(ValidationError, match="archetype"): + ActionSpec( + action=ActionType.CUSTOM, custom_action="waves", cyclic=False, + archetype=AttackArchetype.SWEEP, + ) + + +def test_attack_takes_an_archetype_and_defaults_to_unspecified(): + """不指定就是 None:缺省只由 build_attack_prompt 定义一次,契约层不兜第二份。""" + assert ActionSpec(action=ActionType.ATTACK).archetype is None + assert ActionSpec( + action=ActionType.ATTACK, archetype=AttackArchetype.LUNGE + ).archetype is AttackArchetype.LUNGE + + +# ── ⑤ 派生层真的把 archetype 传下去了 ──────────────────────────────────── + + +@pytest.mark.parametrize("archetype", list(AttackArchetype)) +def test_strategy_builds_the_prompt_of_the_requested_archetype(archetype): + """契约字段填了却没人读,是本项目最典型的静默失败(见 ActionSpec.fps 那段)。""" + strat = VideoFrameStrategy(video=None, matte=None) + spec = ActionSpec(action=ActionType.ATTACK, archetype=archetype, facing=Facing.FRONT) + assert strat._build_prompt(spec) == build_attack_prompt( + facing=Facing.FRONT, archetype=archetype + ) + + +def test_strategy_without_an_archetype_falls_back_to_the_builder_default(): + strat = VideoFrameStrategy(video=None, matte=None) + spec = ActionSpec(action=ActionType.ATTACK, facing=Facing.SIDE) + assert strat._build_prompt(spec) == build_attack_prompt(facing=Facing.SIDE) + + +# ── ⑥ 统一构图后缀:五个动作都要带 ─────────────────────────────────────── + + +def _all_prompts() -> dict[str, str]: + out: dict[str, str] = {} + for facing in FACINGS: + out[f"walk.{facing.value}"] = build_walk_prompt(facing=facing) + out[f"jump.{facing.value}"] = build_jump_prompt(facing=facing) + out[f"idle.{facing.value}"] = build_idle_prompt(facing=facing) + out[f"custom.{facing.value}"] = build_custom_prompt( + "waves the right hand", facing=facing, cyclic=False + ) + for archetype in AttackArchetype: + out[f"attack.{archetype.value}.{facing.value}"] = build_attack_prompt( + facing=facing, archetype=archetype + ) + return out + + +def test_every_action_prompt_carries_the_framing_clause(): + """attack 是唯一没有构图约束的动作,而它恰恰有两处留白(母版姿态要留白 + 母版补边)。""" + missing = [k for k, v in _all_prompts().items() if SINGLE_SUBJECT_FRAMING not in v] + assert not missing, f"这些提示词没带构图约束: {missing}" + + +def test_the_framing_clause_is_appended_by_code_not_copied_into_the_markdown(): + """抄进每份 md 会各自漂移;md 正文里出现它就说明有人开始抄了。""" + from windup_ai_engine.prompt._md import load_doc + + for doc in ("walk.md", "jump.md", "idle.md", "attack.md"): + for section, text in load_doc(doc).items(): + assert "exactly one character" not in text.lower(), \ + f"{doc} 的 {section} 把构图句抄进了 md" + + +def test_the_framing_clause_counts_positively_instead_of_forbidding(): + """该接口没有 negative_prompt:否定句里的名词会被 latch 进画面。""" + low = SINGLE_SUBJECT_FRAMING.lower() + assert "exactly one character" in low + hits = [w for w in (" not ", " no ", "n't", "without", "avoid", "never", "只") if w in low] + assert not hits, f"构图句写成了否定式: {hits}" + + +# ── ⑦ 母版姿态的四支同样不带形状先验 ───────────────────────────────────── + + +@pytest.mark.parametrize("archetype", list(AttackArchetype)) +def test_attack_master_poses_carry_no_shape_prior(archetype): + """母版是整条 i2v 链的身份来源,污染会一路带到所有动作。""" + named = _hits(ATTACK_MASTER_POSES[archetype], _SHAPE_PRIORS) + assert not named, f"attack.{archetype.value} 的母版姿态断言了持物形状: {named}" diff --git a/backend/tests/test_character_contract.py b/backend/tests/test_character_contract.py index 7f2cf01d..ad5ef825 100644 --- a/backend/tests/test_character_contract.py +++ b/backend/tests/test_character_contract.py @@ -15,7 +15,7 @@ import pytest from pydantic import ValidationError -from windup_ai_engine.master_prep import MASTER_POSES +from windup_ai_engine.master_prep import ATTACK_MASTER_POSES, MASTER_POSES from windup_ai_engine.prompt import ( build_attack_prompt, build_idle_prompt, @@ -27,6 +27,7 @@ DEFAULT_N_FRAMES, ActionSpec, ActionType, + AttackArchetype, CharacterCard, CharacterView, Facing, @@ -176,9 +177,10 @@ def test_prompt_names_no_equipment(build, facing): def test_master_poses_name_no_equipment(): """母版姿势描述同样不许写装备 —— 母版是整条 i2v 链的身份来源,污染会传到所有动作。""" - for action, pose in MASTER_POSES.items(): + poses = {**MASTER_POSES, **{a.value: t for a, t in ATTACK_MASTER_POSES.items()}} + for action, pose in poses.items(): named = _named_equipment(pose) - assert not named, f"MASTER_POSES[{action!r}] 断言了装备: {named}" + assert not named, f"母版姿态 {action!r} 断言了装备: {named}" @pytest.mark.parametrize( @@ -191,8 +193,21 @@ def test_prompt_builders_expose_facing_only(build): 而调用侧(``strategy.concrete._build_prompt``)只传 facing,默认值就成了全体角色的 实际取值。要按角色定制装备文字,得先有地方存它,那是角色卡契约的事;在这里留一个 没人传的参数,只会让人以为该能力已经存在。 + + attack 多一个 ``archetype``:它选的是运动拓扑(身体怎么发力)、取值是枚举、且真有写入方 + (``ActionSpec.archetype`` → ``_build_prompt``),与"没人传的装备参数"不是一类。 """ - assert list(inspect.signature(build).parameters) == ["facing"] + allowed = ["facing", "archetype"] if build is build_attack_prompt else ["facing"] + assert list(inspect.signature(build).parameters) == allowed + + +def test_attack_archetype_is_an_enum_not_free_text(): + """拼错的拓扑要当场炸,不能静默落到某一支 —— 理由同 facing 用枚举。""" + p = inspect.signature(build_attack_prompt).parameters["archetype"] + assert p.kind is inspect.Parameter.KEYWORD_ONLY, "archetype 必须是关键字参数,免得与 facing 传串位" + assert p.default is AttackArchetype.THRUST + with pytest.raises(ValueError): + build_attack_prompt(facing=Facing.SIDE, archetype="swep") def test_strategy_passes_only_facing_into_prompt_builders(): diff --git a/backend/tests/test_prompt_assets.py b/backend/tests/test_prompt_assets.py index 96674e2d..50b91d3f 100644 --- a/backend/tests/test_prompt_assets.py +++ b/backend/tests/test_prompt_assets.py @@ -13,7 +13,7 @@ import pytest -from windup_ai_engine.master_prep import MASTER_POSES +from windup_ai_engine.master_prep import ATTACK_MASTER_POSES, MASTER_POSES from windup_ai_engine.prompt import ( build_attack_prompt, build_idle_prompt, @@ -21,6 +21,7 @@ build_walk_prompt, ) from windup_ai_engine.prompt._md import PromptAssetError, load_doc, load_section +from windup_common.models import AttackArchetype BUILDERS = { "walk": build_walk_prompt, @@ -29,6 +30,13 @@ "attack": build_attack_prompt, } +# 每份 md 里必须真的有内容的节。attack 的节名是 `<运动拓扑>.<朝向>`。 +SECTIONS = { + doc: ["side", "front"] for doc in ("walk.md", "jump.md", "idle.md") +} | { + "attack.md": [f"{a.value}.{f}" for a in AttackArchetype for f in ("side", "front")], +} + # ── ① 缺失 / 空 必须当场抛错,不返回空串 ────────────────────────────────── @@ -152,14 +160,21 @@ def test_master_poses_keeps_its_intentional_blanks(): assert MASTER_POSES["walk"] == "" and MASTER_POSES["run"] == "" assert MASTER_POSES["idle"] == "" assert "deep crouch" in MASTER_POSES["jump"] - assert "wind-up" in MASTER_POSES["attack"] + + +def test_every_attack_archetype_has_its_own_master_pose(): + """四支的起手姿态互不兼容,缺一支就该炸 —— 母版姿态决定动作,不能退回中性站立。""" + assert set(ATTACK_MASTER_POSES) == set(AttackArchetype) + texts = [t.strip() for t in ATTACK_MASTER_POSES.values()] + assert all(texts), f"有空的攻击母版姿态:{ATTACK_MASTER_POSES}" + assert len(set(texts)) == len(texts), "有两支共用同一段母版姿态" def test_only_master_poses_may_be_empty(): """``allow_empty`` 是给"空本身有含义"的地方开的口子,别的提示词不许走这条。""" - for action in BUILDERS: - for facing in ("side", "front"): - assert load_section(f"{action}.md", facing), f"{action}.{facing} 空了" + for doc, sections in SECTIONS.items(): + for section in sections: + assert load_section(doc, section), f"{doc} 的 {section} 空了" # ── ④ 打包:md 必须真的进 wheel,不能只在源码树里存在 ─────────────────────