Skip to content
Merged
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
@@ -1,11 +1,7 @@
"""零模型的 :class:`~windup_ai_engine.ports.PromptAdapterPort` 实现。

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

它只做确定性做得到的三件事:跑门禁并在 error 级上拒掉、把用户那句话嵌进已验证的骨架、
追加统一的单主体与构图后缀。**翻译、改写措辞、把"轻微"换成一个具体幅度,规则做不到**
—— 那些是 LLM 版的活,这里只负责讲清楚拦在哪、为什么。
用户描述先经 :mod:`rewrite` 用 Chat Gateway 大模型预改写,再跑措辞门禁与骨架装配。
改写失败时回退原文;门禁的拒绝逻辑不变。

放在 ai_engine 而不是 framework:分层门禁(``lint-imports`` 的"包分层链")规定
framework 在 ai_engine 之下,framework 里的模块 import 不到本层的门禁与骨架。
Expand All @@ -21,6 +17,7 @@
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
from windup_ai_engine.prompt.rewrite import rewrite_prompt

__all__ = ["RuleBasedPromptAdapter"]

Expand Down Expand Up @@ -98,6 +95,8 @@ def adapt(
f"夹带角色外观,而外观由母版承载,写两遍会打架。只留动作本身。",
)

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

issues = lint(clause, kind=kind)
blockers = [
(_CODE_BY_CATEGORY[i.category], i.message) for i in issues if i.level == "error"
Expand Down
105 changes: 105 additions & 0 deletions backend/packages/ai_engine/src/windup_ai_engine/prompt/rewrite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""提示词预改写 —— 在措辞门禁之前,用 Chat Gateway 按 lint 标准优化用户描述。

改写失败时回退原文,不阻断生成;措辞门禁与拒绝逻辑仍由 adapter 负责。
"""
from __future__ import annotations

from typing import Any

from langchain_core.messages import HumanMessage, SystemMessage

from windup_common.models import CharacterStance

from windup_ai_engine.prompt.custom import MAX_ACTION_CHARS
from windup_ai_engine.prompt.lint import Kind

__all__ = ["LlmPromptRewriter", "rewrite_prompt"]

_STANCE_HINT = {
CharacterStance.BIPED: "双足角色,可以使用手臂/手等人体部位词。",
CharacterStance.QUADRUPED: "四足角色,不要用人的手臂/手,改用前肢、头颈或尾。",
CharacterStance.SERPENTINE: "蛇形角色,不要用人的手臂/手,改用躯干起伏、头颈或尾。",
}

_KIND_HINT = {
"i2v": "目标是一次视频动作生成:描述一段连续动作,亚阈值微动要改成可见幅度。",
"still": "目标是单张静态姿势:只保留一个瞬间,去掉多阶段(然后/再/最后之后的内容)。",
}


def _system_prompt(*, kind: Kind, stance: CharacterStance) -> str:
return (
"你是角色动作描述的预改写器。把用户输入改写成更适合 i2v/静态姿势模型的"
"正向动作描述。只输出改写后的描述本身,不要引号、解释或前后缀。\n\n"
"必须遵守与措辞门禁相同的标准:\n"
"1. 否定式改成正面描述(该通路没有 negative_prompt,\"不要 X\"会把 X 画进画面)\n"
"2. 去掉烟尘/火花/火焰/扬尘等特效名词,只写身体在做什么\n"
"3. 去掉装备形状先验(刃面/弧线/前手等),只写身体怎么发力\n"
"4. 亚阈值微动改成看得见的幅度\n"
"5. 若出现持物动作,补一句身体整体怎么动(躯干/重心/整体位移)\n"
f"6. {_STANCE_HINT[stance]}\n"
f"7. {_KIND_HINT[kind]}\n\n"
f"保持原意,只描述动作,不超过 {MAX_ACTION_CHARS} 字。"
)


def _clean_rewrite(raw: object, *, original: str) -> str:
text = raw if isinstance(raw, str) else str(raw or "")
text = text.strip().strip("\"'“”‘’").strip()
if not text:
return original
return text[:MAX_ACTION_CHARS]


class LlmPromptRewriter:
"""经 Chat Gateway 改写动作描述;``chat_model`` 可注入以便测试。"""

def __init__(self, chat_model: Any | None = None) -> None:
self._model = chat_model

def _chat_model(self) -> Any:
if self._model is None:
from windup_framework.providers import create_chat_model

self._model = create_chat_model()
return self._model

def rewrite(
self,
text: str,
*,
kind: Kind = "i2v",
stance: CharacterStance | str = CharacterStance.BIPED,
) -> str:
clause = (text or "").strip()
if not clause:
return clause

stance = CharacterStance(stance)
result = self._chat_model().invoke(
[
SystemMessage(content=_system_prompt(kind=kind, stance=stance)),
HumanMessage(content=clause),
]
)
content = getattr(result, "content", result)
return _clean_rewrite(content, original=clause)


def rewrite_prompt(
text: str,
*,
kind: Kind = "i2v",
stance: CharacterStance | str = CharacterStance.BIPED,
chat_model: Any | None = None,
) -> str:
"""LLM 预改写;失败时回退原文。"""
clause = (text or "").strip()
if not clause:
return clause
try:
return LlmPromptRewriter(chat_model).rewrite(
clause, kind=kind, stance=stance,
)
except Exception:
return clause
Original file line number Diff line number Diff line change
Expand Up @@ -273,24 +273,16 @@ def run_action_task(
# 单独捕获而不是落进下面那个兜底:兜底只存 str(exc),``code`` 就丢了,server
# 于是分不出"用户改一句话就能过的输入错"和"引擎故障",只能去解析异常文本。
logger.info("动作任务 %s 的描述被措辞门禁拒绝: %s", task_id, exc.code.value)
task_repo.update_result(
session, task_id, _ACTION_RESULT,
{"type": _ACTION_RESULT, "reject_code": exc.code.value,
"reject_detail": exc.detail},
)
task_repo.update_status(
session, task_id, TaskStatus.FAILED, error_message=user_message(exc),
task_repo.fail_task(
session, task_id, error_message=user_message(exc),
)
if own:
session.commit()
except Exception as exc: # noqa: BLE001 —— 兜底任何生成/上传/网络异常
logger.exception("动作任务 %s 失败", task_id)
session.rollback()
task_repo.update_status(
session,
task_id,
TaskStatus.FAILED,
error_message=user_message(exc),
task_repo.fail_task(
session, task_id, error_message=user_message(exc),
)
_settle_credit(session, task_id, success=False)
if own:
Expand Down Expand Up @@ -619,8 +611,8 @@ def run_image_task(
except Exception as exc: # noqa: BLE001 —— 兜底
logger.exception("图片任务 %s 失败", task_id)
session.rollback()
task_repo.update_status(
session, task_id, TaskStatus.FAILED, error_message=user_message(exc)
task_repo.fail_task(
session, task_id, error_message=user_message(exc),
)
_settle_credit(session, task_id, success=False)
if own:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,29 @@ def update_status(
_publish_task_update(task_id, _record_to_domain(record))


def fail_task(
session: Session,
task_id: int,
*,
error_message: str,
) -> None:
"""将任务标记为失败,并清空结果。

前端合同要求非 ``completed`` 任务不得携带 ``result``;失败路径统一走这里,
避免先 ``update_result()`` 再改 ``failed`` 时遗留脏数据。
"""
record = session.get(GenerationTaskRecord, task_id)
if record is None:
return
record.status = TaskStatus.FAILED.value
record.error_message = error_message
record.result_type = None
record.result = None
record.update_at = datetime.now(timezone.utc)
session.flush()
_publish_task_update(task_id, _record_to_domain(record))


def update_result(
session: Session,
task_id: int,
Expand Down
68 changes: 68 additions & 0 deletions backend/tests/test_generation_orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ActionType,
CharacterActionInput,
CharacterActionOutput,
GenerationType,
TaskStatus,
)
from windup_app.server.orchestrator.executor import ActionTaskExecutor
Expand Down Expand Up @@ -304,6 +305,73 @@ def _boom(_input):
assert done.error_message and "母版下载失败" not in done.error_message


def test_action_task_prompt_rejected_leaves_no_result(session_factory):
"""措辞门禁拒绝时任务应 failed 且 result 为空,避免前端合同校验弹窗。"""
from windup_ai_engine.ports import PromptRejectCode, PromptRejected
from windup_app.server.orchestrator import task_repo

class _RejectGen:
def generate(self, *args, **kwargs):
raise PromptRejected(PromptRejectCode.NEGATION, "描述里不要写否定词")

service = AiGenerationService()
executor = ActionTaskExecutor(
generator=_RejectGen(),
fetch_master=lambda _input: _tiny_png(),
session_factory=session_factory,
)
action_input = CharacterActionInput(
character_id=1,
action_type=ActionType.CUSTOM,
custom_prompt="不要扬尘",
num_frames=4,
)
with session_factory() as s:
task = service.generate_character_action(s, user_id=1, input=action_input)
s.commit()
task_id = task.id

executor.run_action_task(task_id, action_input)

with session_factory() as s:
done = service.get_task(s, project_id=1, task_id=task_id)
assert done.status is TaskStatus.FAILED
assert done.result is None
assert done.error_message and "动作描述没通过检查" in done.error_message
payload = task_repo.task_event_payload(done)
assert payload["status"] == "failed"
assert payload["result"] is None
assert payload["error_message"]


def test_fail_task_clears_stale_result(session_factory):
from windup_app.server.orchestrator import task_repo

with session_factory() as s:
task = task_repo.create_task(
s,
user_id=1,
project_id=1,
task_type=GenerationType.CHARACTER_ACTION,
input_payload={"character_id": 1},
)
task_repo.update_result(
s,
task.id,
"character_action",
{"type": "character_action", "reject_code": "negation"},
)
task_repo.fail_task(s, task.id, error_message="动作描述没通过检查")
s.commit()
done = task_repo.get_task(s, task.id)

assert done.status is TaskStatus.FAILED
assert done.result is None
assert done.error_message == "动作描述没通过检查"
payload = task_repo.task_event_payload(done)
assert payload["result"] is None


# ── 交付尺寸传给引擎(2026-08-11 挣得)──────────────────────────────────────────
#
# 这里以前是拿到 256 的帧再 _fit_to 到项目 sprite 尺寸。那步用 Image.thumbnail 补边,
Expand Down
92 changes: 92 additions & 0 deletions backend/tests/test_prompt_rewrite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""提示词 LLM 预改写层测试。"""
from __future__ import annotations

from types import SimpleNamespace

import pytest

from windup_ai_engine.prompt.lint import lint
from windup_ai_engine.prompt.rewrite import LlmPromptRewriter, rewrite_prompt
from windup_common.models import CharacterStance


class _FakeChat:
def __init__(self, content: object, *, error: Exception | None = None) -> None:
self.content = content
self.error = error
self.messages = None

def invoke(self, messages):
self.messages = messages
if self.error is not None:
raise self.error
return SimpleNamespace(content=self.content)


def _errors(text: str, *, kind: str = "i2v") -> list:
return [i for i in lint(text, kind=kind) if i.level == "error"]


def test_llm_rewrite_invokes_chat_gateway():
chat = _FakeChat("双脚平稳着地行走")
out = rewrite_prompt("不要扬尘", kind="i2v", chat_model=chat)
assert out == "双脚平稳着地行走"
assert chat.messages is not None


def test_llm_rewrite_passes_stance_and_kind_in_system_prompt():
chat = _FakeChat("前肢抬高")
rewrite_prompt(
"举起左手",
kind="i2v",
stance=CharacterStance.QUADRUPED,
chat_model=chat,
)
system = chat.messages[0].content
assert "四足" in system
assert "i2v" in system or "视频" in system


def test_llm_rewrite_falls_back_to_original_on_failure():
chat = _FakeChat("", error=RuntimeError("gateway down"))
out = rewrite_prompt("不要扬尘", kind="i2v", chat_model=chat)
assert out == "不要扬尘"


def test_llm_rewrite_empty_output_falls_back_to_original():
chat = _FakeChat(" ")
out = rewrite_prompt("轻微抖动一下", kind="i2v", chat_model=chat)
assert out == "轻微抖动一下"


def test_llm_rewrite_truncates_overlong_model_output():
chat = _FakeChat("动" * 300)
out = LlmPromptRewriter(chat).rewrite("walk", kind="i2v")
assert len(out) <= 200


def test_llm_rewrite_construction_does_not_touch_chat_provider():
rewriter = LlmPromptRewriter()
assert rewriter._model is None


@pytest.mark.parametrize(
("source", "rewritten"),
[
("不要扬尘", "双脚平稳着地行走"),
("轻微抖动一下", "明显抖动一下"),
(
"holds the sword steady at the shoulder",
"holds the sword while the whole body shifts forward",
),
],
)
def test_llm_rewrite_output_can_satisfy_gate(source, rewritten):
chat = _FakeChat(rewritten)
out = rewrite_prompt(source, kind="i2v", chat_model=chat)
assert not _errors(out), _errors(out)


def test_rewrite_empty_text_is_noop():
assert rewrite_prompt("") == ""
assert rewrite_prompt(" ") == ""
Loading