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
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ def adapt(
kind: Kind,
facing: Facing,
stance: CharacterStance,
on_template: bool = False,
) -> AdaptedPrompt: ...


Expand Down
13 changes: 10 additions & 3 deletions backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,18 @@
)


def with_framing(body: str) -> str:
"""给一段动作正文接上构图与视频保真约束。"""
def with_framing(body: str, detail: str = "") -> str:
"""给一段动作正文接上构图与视频保真约束。

``detail`` 是用户写的那句动作细节。它**插在模板正文之后、构图约束之前** ——
位置由两条约束夹定:接在构图约束后面会破坏下面那条收口不变量,插在模板正文前面
会让它先于运动拓扑被读到。
"""
# SINGLE_SUBJECT_FRAMING 保持最终收口句:提示词适配器的契约会用它确认公共构图约束
# 没被自定义动作分支绕过。新增约束插在它之前,不改变这个既有边界。
return f"{body} {REFERENCE_FIDELITY_LOCK} {SINGLE_SUBJECT_FRAMING}"
clause = detail.strip()
head = f"{body} {clause}" if clause else body
return f"{head} {REFERENCE_FIDELITY_LOCK} {SINGLE_SUBJECT_FRAMING}"

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] Bump the persisted prompt version

with_framing now changes every walk, jump, idle, and attack prompt by inserting the user detail clause, but windup_ai_engine.prompt.PROMPT_VERSION remains v2. CharacterGenerator persists that constant in every GeneratedAction, so outputs created before and after this PR will carry the same version even though their prompts and generation behavior differ; this defeats the documented prompt-version ledger and prevents reliable quality comparisons or migration decisions. Increment PROMPT_VERSION in the same change.



def with_direction_lock(body: str, direction: ActionDirection | None) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,23 @@
__all__ = ["build_idle_prompt", "build_attack_prompt"]


def build_idle_prompt(facing: Facing | str = Facing.SIDE) -> str:
def build_idle_prompt(facing: Facing | str = Facing.SIDE, *, detail: str = "") -> str:
"""待机正文(循环类)。``facing`` 须与母版朝向一致。

"""
return with_framing(load_section("idle.md", Facing(facing).value))
return with_framing(load_section("idle.md", Facing(facing).value), detail)


def build_attack_prompt(
facing: Facing | str = Facing.SIDE,
*,
archetype: AttackArchetype | str = AttackArchetype.THRUST,
detail: str = "",
) -> str:
"""攻击正文(一次性类)。``facing`` 须与母版朝向一致。

默认取 THRUST:四支里只有 SWEEP 要求手里有一件有宽面的长条物,拿它当默认 = 对每个未知角色断言持械(#195)。
"""
# 两个枚举都过一遍构造:非法值要炸,不能静默落到某一节。
section = f"{AttackArchetype(archetype).value}.{Facing(facing).value}"
return with_framing(load_section("attack.md", section))
return with_framing(load_section("attack.md", section), detail)
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,12 @@ def adapt(
kind: Kind = "i2v",
facing: Facing = Facing.SIDE,
stance: CharacterStance | str = CharacterStance.BIPED,
on_template: bool = False,
) -> AdaptedPrompt:
"""Raises ``PromptRejected``:这段描述送进模型必然出坏产物,理由带 code 与机制。"""
"""Raises ``PromptRejected``:这段描述送进模型必然出坏产物,理由带 code 与机制。

``on_template``:这段话是叠在动作模板之上的细节句(#838),见 ``lint``。
"""
stance = CharacterStance(stance) # 非法体型要炸,不静默按双足放行
clause = (user_text or "").strip()
if not clause:
Expand All @@ -97,7 +101,7 @@ def adapt(

clause = rewrite_prompt(clause, kind=kind, stance=stance)

issues = lint(clause, kind=kind)
issues = lint(clause, kind=kind, on_template=on_template)
blockers = [
(_CODE_BY_CATEGORY[i.category], i.message) for i in issues if i.level == "error"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
JUMP_PHASES = ("crouch", "rise", "apex", "fall", "land")


def build_jump_prompt(facing: Facing | str = Facing.SIDE) -> str:
def build_jump_prompt(facing: Facing | str = Facing.SIDE, *, detail: str = "") -> str:
"""按母版朝向生成跳跃正文。

Args:
facing: :class:`Facing` 成员(或其等价字符串),**必须与母版朝向一致**。

"""
return with_framing(load_section(_DOC, Facing(facing).value))
return with_framing(load_section(_DOC, Facing(facing).value), detail)
15 changes: 13 additions & 2 deletions backend/packages/ai_engine/src/windup_ai_engine/prompt/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,19 @@ def _hits(text: str, name: str) -> list[str]:
return list(seen)


def lint(text: str, *, kind: Kind = "i2v") -> list[LintIssue]:
def lint(
text: str, *, kind: Kind = "i2v", on_template: bool = False
) -> list[LintIssue]:
"""按目标模型类型查一段提示词。

``kind`` 只影响 2a / 2b 两条:它们的机制都是帧与帧之间的,静态图没有帧间。

``on_template`` = 这段话是叠在写死动作模板之上的细节句,不是整个动作(#838)。
它关掉 subthreshold 与 unanchored_prop 两条 —— 这两条查的正是模板已经提供的
东西(可见幅度、身体整体怎么动),而它们的机制都要求"这句话是画面里唯一的运动"。
对细节句照查 = 把"走路时手臂轻微摆动"判成弱指令,而模板明明已经给了大幅度的步态。
其余各条与是否叠加无关:没有 negative_prompt、特效名词盖轮廓、断言母版里没有的
装备形状,叠不叠都成立。
"""
issues: list[LintIssue] = []

Expand Down Expand Up @@ -181,6 +190,8 @@ def lint(text: str, *, kind: Kind = "i2v") -> list[LintIssue]:
))

for term in _hits(text, "subthreshold"):
if on_template:
continue # 幅度由模板给,见本函数 docstring
# 静态图没有帧间,抖不起来;但"轻微"对单张图同样给不出可执行的幅度,故仍报。
level: Level = "error" if kind == "i2v" else "warn"
issues.append(LintIssue(
Expand All @@ -191,7 +202,7 @@ def lint(text: str, *, kind: Kind = "i2v") -> list[LintIssue]:
+ "给一个看得见的幅度(动到哪儿、动多远)。",
))

if kind == "i2v":
if kind == "i2v" and not on_template:
props = _hits(text, "prop")
if props and not _hits(text, "body"):
issues.append(LintIssue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

_DOC = "walk.md"

def build_walk_prompt(facing: Facing | str = Facing.SIDE) -> str:
def build_walk_prompt(facing: Facing | str = Facing.SIDE, *, detail: str = "") -> str:
"""按母版朝向生成走路正文。

Args:
Expand All @@ -23,4 +23,4 @@ def build_walk_prompt(facing: Facing | str = Facing.SIDE) -> str:

"""
# 过一遍 Facing() 构造:非法值要炸,不能静默落到某个模板。
return with_framing(load_section(_DOC, Facing(facing).value))
return with_framing(load_section(_DOC, Facing(facing).value), detail)
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,60 @@ def _build_prompt(self, action: ActionSpec, stance: CharacterStance) -> str:
# attack 同样进不了那张表:它还要按运动拓扑选提示词分支。archetype 缺省时不在这里
# 兜一个默认值 —— 缺省只由 build_attack_prompt 定义一次,写两处会各自漂移。
if action.action is ActionType.ATTACK:
detail = self._detail_clause(action, stance)
if action.archetype is None:
body = build_attack_prompt(facing=action.facing)
body = build_attack_prompt(facing=action.facing, detail=detail)
else:
body = build_attack_prompt(
facing=action.facing, archetype=action.archetype
facing=action.facing, archetype=action.archetype, detail=detail
)
return with_direction_lock(body, action.direction)
builders = {
ActionType.JUMP: build_jump_prompt,
ActionType.IDLE: build_idle_prompt,
}
build = builders.get(action.action, build_walk_prompt)
return with_direction_lock(build(facing=action.facing), action.direction)
body = build(
facing=action.facing, detail=self._detail_clause(action, stance)
)
return with_direction_lock(body, action.direction)

def _detail_clause(self, action: ActionSpec, stance: CharacterStance) -> str:
"""用户写的那句动作细节,过一遍适配器后交给模板。

前端把用户的一句自由文本拆成两半发过来:``action_type`` 选哪条已调好的管线
(走路要腿交替、跳跃要腾空 —— 这些运动拓扑是模板挣来的,也正是分类到这个类型
的理由),``custom_prompt`` 说这次具体要什么。本层原先只读前一半,后一半连派生
入口都没进,而任务照常成功、照常扣费、帧数时长成色全对(生产 124/124 条非 custom
任务全中,见 #838)。

叠加而不是二选一:替换模板会丢掉运动拓扑,丢掉它就等于把这次生成降级成 custom;
丢掉细节则是本 issue 要修的那个静默丢弃。

Raises:
PromptRejected: 这段描述送进模型必然出坏产物(如给无肢角色写"手臂")
→ server 映射 4xx 让用户改。下一步就是付费调用,不能带着它往下走。
"""
clause = (action.detail or "").strip()
if not clause:
return ""
try:
adapted = self._adapter.adapt(
clause,
kind="i2v",
facing=action.facing,
stance=stance,
on_template=True,
)
except PromptRejected:
# 与下面那条分得很清:这不是组件不可用,是这段描述本身跑不出可用产物。
# 顺序也是约束:它是 ValueError 的子类,放到宽兜底后面就永远轮不上。
raise
except Exception:
# 适配器坏掉只该丢掉那层改写,不该把用户这句话一起丢掉 ——
# 丢掉就退化回本 issue 要修的那个静默丢弃。
return clause
return adapted.text

def _custom_prompt(self, action: ActionSpec, stance: CharacterStance) -> str:
"""用户那句话先过适配器,再按声明的循环性收尾。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,11 @@ def _produce_action(
"cyclic": cyclic,
"ground_contact": grounded,
}
elif (input.custom_prompt or "").strip():
# 写死的那几个动作也带着用户写的那句细节:前端把一句自由文本拆成
# action_type(选管线)+ custom_prompt(说这次具体要什么)两半发过来,
# 只读前一半就是把用户的输入静默丢了(#838)。
extra = {"detail": input.custom_prompt}
action = ActionSpec(
action=engine_action,
poses=[""] * input.num_frames,
Expand Down Expand Up @@ -743,6 +748,9 @@ def _action_spec(
if engine_action is EngineActionType.CUSTOM:
cyclic = False if input.loop is None else bool(input.loop)
extra = {"custom_action": input.custom_prompt or "", "cyclic": cyclic}
elif (input.custom_prompt or "").strip():
# 同上(#838):这条路径也要把用户那句细节带下去,否则两处行为不一致。
extra = {"detail": input.custom_prompt}
action = ActionSpec(
action=engine_action,
poses=[""] * input.num_frames,
Expand Down
11 changes: 11 additions & 0 deletions backend/packages/common/src/windup_common/models/character.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ class ActionSpec(BaseModel):
# 身份描述再写一遍会和母版打架(见 ports.CharacterGeneratorPort)。
custom_action: str | None = None

# 用户写的那句动作细节,叠在写死动作的模板之上(#838)。
# 与 ``custom_action`` 的分工:那个是**整个**动作的内容(action=custom 时必填,
# 此时没有模板);这个是对模板动作的补充说明,模板仍然定运动拓扑。
# 前端两半一起发:action_type 选管线、custom_prompt 说这次具体要什么。
detail: str | None = None

# 必须显式给,不按描述关键词猜:猜错会把一次性动作强行首尾闭环,末帧接回首帧抽搐,
# 而帧数、时长、成色全正常。名字不叫 loop 是因为它有真实消费方——决定 slicing 走
# pick_cycle 还是 pick_oneshot、出参要不要量 loop_seam。
Expand Down Expand Up @@ -271,6 +277,11 @@ def _custom_needs_its_own_settings(self) -> ActionSpec:
"action=custom 必须显式给 cyclic(是否循环播放)。不猜 —— "
"猜错会把一次性动作强行首尾闭环,而帧数/时长/成色全部正常、没有任何一道会红"
)
if self.detail is not None:
raise ValueError(
"action=custom 不该带 detail;custom 没有模板可叠,动作内容整条走 "
"custom_action。两个字段都填会让同一段描述进两次提示词"
)
else:
if self.custom_action is not None:
raise ValueError(
Expand Down
Loading
Loading