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 dfdd4018..d4cdda43 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 @@ -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 不到本层的门禁与骨架。 @@ -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"] @@ -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" diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/rewrite.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/rewrite.py new file mode 100644 index 00000000..d49ce02b --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/rewrite.py @@ -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 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 47fb6b16..4e8a75d3 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -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: @@ -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: diff --git a/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py index e788baff..e5d351d1 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py @@ -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, diff --git a/backend/tests/test_generation_orchestration.py b/backend/tests/test_generation_orchestration.py index caaedabf..3f6cd658 100644 --- a/backend/tests/test_generation_orchestration.py +++ b/backend/tests/test_generation_orchestration.py @@ -21,6 +21,7 @@ ActionType, CharacterActionInput, CharacterActionOutput, + GenerationType, TaskStatus, ) from windup_app.server.orchestrator.executor import ActionTaskExecutor @@ -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 补边, diff --git a/backend/tests/test_prompt_rewrite.py b/backend/tests/test_prompt_rewrite.py new file mode 100644 index 00000000..6544d633 --- /dev/null +++ b/backend/tests/test_prompt_rewrite.py @@ -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(" ") == ""