diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py index b08371846..8e050d6d3 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -162,6 +162,7 @@ def adapt( kind: Kind, facing: Facing, stance: CharacterStance, + on_template: bool = False, ) -> AdaptedPrompt: ... 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 index 6173c868b..c7d7bc3fd 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py @@ -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}" def with_direction_lock(body: str, direction: ActionDirection | None) -> str: 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 d615ce231..4c1743376 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 @@ -13,17 +13,18 @@ __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`` 须与母版朝向一致。 @@ -31,4 +32,4 @@ def build_attack_prompt( """ # 两个枚举都过一遍构造:非法值要炸,不能静默落到某一节。 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) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/adapter.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/adapter.py index d4cdda437..8406716b7 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/adapter.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/adapter.py @@ -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: @@ -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" ] 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 6aef07bb6..918f64668 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 @@ -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) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/lint.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/lint.py index 9cc869720..384df5310 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/lint.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/lint.py @@ -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] = [] @@ -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( @@ -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( 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 5f9a74fea..9f60adfe0 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 @@ -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: @@ -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) 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 6cf15a051..516941a0a 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 @@ -85,11 +85,12 @@ 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 = { @@ -97,7 +98,47 @@ def _build_prompt(self, action: ActionSpec, stance: CharacterStance) -> str: 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: """用户那句话先过适配器,再按声明的循环性收尾。 diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py index ea8657844..4e83b6bb0 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -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, @@ -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, diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py index 3c3ea8946..b2540215d 100644 --- a/backend/packages/common/src/windup_common/models/character.py +++ b/backend/packages/common/src/windup_common/models/character.py @@ -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。 @@ -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( diff --git a/backend/tests/test_action_detail_reaches_prompt.py b/backend/tests/test_action_detail_reaches_prompt.py new file mode 100644 index 000000000..65762d13d --- /dev/null +++ b/backend/tests/test_action_detail_reaches_prompt.py @@ -0,0 +1,156 @@ +"""写死的动作不许静默丢掉用户写的那句细节(#838)。 + +前端把用户的一句自由文本拆成两半发过来:``action_type`` 选哪条已调好的管线, +``custom_prompt`` 说这次具体要什么(quick-start planner 的提示词原文:"让生成复用 +已有优化管线"+"必须把动作单独写入 actionPrompt")。后端原先只读前一半 —— 后一半 +进了 ``CharacterCard.desc``,而视频路线**不读** desc(那条注释自己写着"角色身份由母版 +图像承载"),于是用户写的东西一个字都没进提示词。 + +而任务照常成功、照常扣费、帧数时长成色全对,唯一能察觉的方式是看产物。生产实测 +**124/124 条非 custom 任务全中**: + + #564 attack "炸开,分裂成非常多只小型蓝色史莱姆" → 出片是个金发人形武者 + #391 attack "施法:角色保持原地站立" → 给的是弓步突刺模板 + walk "奔跑" / "原地行走循环,不向前位移" → 都出了同一套通用走路 + idle "一只小狗低头在饭碗里吃饭" → 出了通用呼吸待机 + jump "螃蟹原地快速左右摆动,挥舞双钳" → 出了通用腾空跳 + +下面每条对应其中一个真实坏例。 +""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from windup_ai_engine.prompt import ( + build_attack_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, + CharacterStance, + Facing, +) + +# #564 的原文。中文样本才走得到真实链路(适配器按中文写的)。 +SLIME_BURST = "炸开,分裂成非常多只小型蓝色史莱姆,许多小史莱姆漂浮在空中并发光。" +DOG_EATING = "一只小狗低头在饭碗里吃饭,前爪支撑身体,持续舔食或咀嚼。" + +TEMPLATED = [ + (ActionType.WALK, build_walk_prompt), + (ActionType.IDLE, build_idle_prompt), + (ActionType.JUMP, build_jump_prompt), + (ActionType.ATTACK, build_attack_prompt), +] + + +def _strat() -> VideoFrameStrategy: + return VideoFrameStrategy(video=None, matte=None) + + +def _prompt(action: ActionType, *, detail: str | None = None) -> str: + spec = ActionSpec(action=action, facing=Facing.SIDE, detail=detail) + return _strat()._build_prompt(spec, CharacterStance.BIPED) + + +# ── ① 用户那句话必须进提示词 ───────────────────────────────────────────── + + +@pytest.mark.parametrize("action,builder", TEMPLATED, ids=lambda v: getattr(v, "value", "")) +def test_the_users_clause_changes_the_prompt(action, builder): + """判据是"和不带细节时不一样"。 + + 只断言"包含某个关键词"会被"把中文原样贴在末尾"骗过,而那样贴会破坏构图约束的 + 收口位置(见下一条);只断言"不等于模板"才说明它真的进去了。 + """ + assert _prompt(action, detail=DOG_EATING) != _prompt(action) + + +@pytest.mark.parametrize("action,_b", TEMPLATED, ids=lambda v: getattr(v, "value", "")) +def test_the_clause_sits_before_the_framing_constraints(action, _b): + """构图约束必须仍是最后一句。 + + ``_framing`` 那份注释把它定成收口句,提示词适配器的契约靠它确认公共约束没被绕过。 + 细节贴在它后面 = 悄悄废掉那道契约检查。 + """ + text = _prompt(action, detail=DOG_EATING) + assert text.rstrip().endswith(SINGLE_SUBJECT_FRAMING), text[-160:] + + +def test_the_template_survives_alongside_the_clause(): + """叠加,不是二选一。 + + 模板定运动拓扑(走路要腿交替)—— 那正是前端把这句话分类到 walk 的理由。 + 拿细节替换模板等于把这次生成降级成 custom,腿不交替的老毛病会回来(#221)。 + """ + text = _prompt(ActionType.WALK, detail=DOG_EATING) + plain = _prompt(ActionType.WALK) + # 模板正文整段仍在(去掉方向锁后 plain 的正文是 text 的前缀)。 + core = plain.split(SINGLE_SUBJECT_FRAMING)[0].strip() + assert core.split("Preserve the reference")[0].strip() in text + + +def test_attack_keeps_its_archetype_and_still_takes_the_clause(): + """#564:攻击这一支要按运动拓扑选模板,不能因为加了细节就绕过 archetype。""" + spec = ActionSpec( + action=ActionType.ATTACK, + facing=Facing.SIDE, + archetype=AttackArchetype.SWEEP, + detail=SLIME_BURST, + ) + got = _strat()._build_prompt(spec, CharacterStance.BIPED) + assert got != build_attack_prompt(facing=Facing.SIDE, archetype=AttackArchetype.SWEEP) + assert got != _prompt(ActionType.ATTACK, detail=SLIME_BURST) # 缺省支是 THRUST + + +# ── ② 空白不算填写 ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("blank", [None, "", " ", "\n\t "]) +@pytest.mark.parametrize("action,builder", TEMPLATED, ids=lambda v: getattr(v, "value", "")) +def test_a_blank_clause_leaves_the_prompt_byte_identical(action, builder, blank): + """绝大多数存量任务这里是空的,行为必须一个字节不变。""" + assert _prompt(action, detail=blank) == _prompt(action) + + +# ── ③ 契约层:custom 不该同时有两份描述 ─────────────────────────────────── + + +def test_custom_may_not_also_carry_a_detail_clause(): + """custom 没有模板可叠,动作内容整条走 custom_action。 + + 两个字段都填会让同一段描述进两次提示词。 + """ + with pytest.raises(ValidationError): + ActionSpec( + action=ActionType.CUSTOM, + custom_action=SLIME_BURST, + cyclic=False, + detail=SLIME_BURST, + ) + + +# ── ④ 非双足角色写人体部位仍然要拒 ─────────────────────────────────────── + + +def test_a_body_part_clause_on_a_limbless_character_is_rejected_not_silently_drawn(): + """细节句必须和 custom 那条路走同一道门禁。 + + 绕过适配器直接把"手臂"喂给无肢角色,模型会凭空接上一对人的上肢 —— + 而帧数、时长、成色全部正常,没有一道会红。 + """ + from windup_ai_engine.ports import PromptRejected + + spec = ActionSpec( + action=ActionType.WALK, + facing=Facing.SIDE, + detail="角色挥动双臂,手肘弯曲。", + ) + with pytest.raises(PromptRejected): + _strat()._build_prompt(spec, CharacterStance.SERPENTINE) diff --git a/backend/tests/test_action_detail_wired_from_entry.py b/backend/tests/test_action_detail_wired_from_entry.py new file mode 100644 index 000000000..6a2346636 --- /dev/null +++ b/backend/tests/test_action_detail_wired_from_entry.py @@ -0,0 +1,56 @@ +"""入口的 custom_prompt 必须一路走到引擎的 ActionSpec.detail(#838)。 + +引擎侧修好而入口不接,是本仓最典型的静默失败:契约字段填了却没人写,生产链路照旧 +走缺省值,而单测全绿。这条从 ``CharacterActionInput`` 出发,断言的是**生产真正调用 +的那个方法**(``ActionTaskExecutor._action_spec``)造出来的 ActionSpec。 +""" +from __future__ import annotations + +import pytest + +from windup_app.server.orchestrator.executor import ( + ActionTaskExecutor, + ProjectConstraints, +) +from windup_app.server.orchestrator.model import ActionType, CharacterActionInput +from windup_common.models import ActionSpec + +DOG = "一只小狗低头在饭碗里吃饭,前爪支撑身体,持续舔食或咀嚼。" +CONS = ProjectConstraints(facing="side", stylize="pixel", sprite_w=256, sprite_h=256) + + +def _spec(**kw) -> ActionSpec: + inp = CharacterActionInput(character_id=1, num_frames=6, **kw) + # 只测输入→ActionSpec 这一段,不构造整个 executor(它要 DB / 网关 / 会话工厂)。 + _card, spec, _canvas = ActionTaskExecutor._action_spec(object(), inp, CONS) + return spec + + +@pytest.mark.parametrize( + "action_type", + [ActionType.WALK, ActionType.IDLE, ActionType.JUMP, ActionType.ATTACK], + ids=lambda a: a.value, +) +def test_custom_prompt_lands_in_action_spec_detail(action_type): + """写死的四个动作:用户那句话必须出现在 ActionSpec.detail 上。 + + 生产 124/124 条这类任务原先都丢在这一步 —— custom_prompt 只进了 + CharacterCard.desc,而视频路线不读 desc。 + """ + assert _spec(action_type=action_type, custom_prompt=DOG).detail == DOG + + +def test_custom_action_type_still_uses_custom_action_not_detail(): + """custom 走的是另一半契约:整条描述进 custom_action,detail 必须为空。 + + 两个都填会让同一段话进两次提示词(ActionSpec 的校验器会炸)。 + """ + spec = _spec(action_type=ActionType.CUSTOM, custom_prompt=DOG, loop=False) + assert spec.custom_action == DOG + assert spec.detail is None + + +@pytest.mark.parametrize("blank", [None, "", " "]) +def test_a_blank_custom_prompt_leaves_detail_unset(blank): + """绝大多数存量任务这里是空的,不能因此掉进带细节那一支。""" + assert _spec(action_type=ActionType.WALK, custom_prompt=blank).detail is None diff --git a/backend/tests/test_character_contract.py b/backend/tests/test_character_contract.py index ab48365da..af9559d07 100644 --- a/backend/tests/test_character_contract.py +++ b/backend/tests/test_character_contract.py @@ -219,8 +219,15 @@ def test_prompt_builders_expose_facing_only(build): attack 多一个 ``archetype``:它选的是运动拓扑(身体怎么发力)、取值是枚举、且真有写入方 (``ActionSpec.archetype`` → ``_build_prompt``),与"没人传的装备参数"不是一类。 + + ``detail`` 同理(#838):写入方是 ``ActionSpec.detail`` → ``_build_prompt``,再往上是 + 入口的 ``custom_prompt``——前端每次都发。它不是"没人传的参数",恰恰相反:它补的是 + 原先**发了却没人读**的那一半。真正保证它没退化成死参数的是行为测试 + ``test_action_detail_reaches_prompt.py``(回滚实现即变红),不是这里的签名断言。 """ - allowed = ["facing", "archetype"] if build is build_attack_prompt else ["facing"] + allowed = ["facing", "archetype", "detail"] if build is build_attack_prompt else [ + "facing", "detail" + ] assert list(inspect.signature(build).parameters) == allowed