HEL-853: recognize Chinese render approvals and prioritize refusals

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-10-25 20:43:42 +08:00
co-authored by multica-agent
parent 410eadea15
commit 49dc71b727
2 changed files with 240 additions and 13 deletions
@@ -13,8 +13,9 @@ There is deliberately no message-length cap ("regenerate the full script
for this project, keep the characters and locations consistent" is one
command, not a suspicious wall of text), no global-negation veto ("don't
overthink, render all scenes" is a command), and no politeness filter.
Negation is CLAUSE-scoped: only a "no" inside ~4 words of the cue vetoes
that cue.
English negation is CLAUSE-scoped: only a "no" inside ~4 words of the cue
vetoes that cue. Chinese refusals override assent in the same latest input
(e.g. "可以,但先不要生成"); Chinese questions/descriptions grant nothing.
"""
from __future__ import annotations
@@ -55,6 +56,41 @@ _RENDER_REQUEST_RE = re.compile(
# Compound splitter: "text2video" → "text video" so the bare token matches.
_COMPOUND_SPLIT_RE = re.compile(r"(?<=[a-z])(?:2|to|-)(?=[a-z])", re.IGNORECASE)
# Chinese has no word boundaries between a verb and its object. Require an
# imperative/desire prefix plus a media object, rather than a bare 视频/生成.
_CLAUSE_RE = r"[,.;!?,。!?、;]|\bbut\b|\bhowever\b"
_ZH_RENDER_RE = re.compile(
r"^(?:(?:请|请你|帮我|请帮我|麻烦|麻烦你|我想|我要|我想要)\s*)?"
r"(?:(?:开始|继续|重新)\s*)?"
r"(?:(?:生成|制作|渲染|画|绘制).{0,40}(?:视频|图片|图像|一张图|张图|video|image)"
r"|出图)",
re.IGNORECASE,
)
_ZH_CONFIRM_RE = re.compile(
r"^(?:确认(?:生成)?|确定|同意|好(?:的)?|可以|行|是(?:的)?|继续|开始|执行)$"
)
# A Chinese refusal in the latest input overrides earlier assent in that
# input too (可以,但先不要生成). Do not change English clause-scoped rules.
_ZH_REFUSAL_RE = re.compile(
r"(?:暂不|先不|不要|不用|不必|不能|不想|不可以|不|别)\s*"
r"(?:(?:再|先|继续|开始)\s*)?(?:生成|制作|渲染|出图|画|确认|同意|执行|继续|render|generate)"
r"|(?:取消|停止|暂停|停下|拒绝)\s*(?:生成|制作|渲染|出图|render|generate)"
r"|(?:^|[\s,。!?、;,.!?;])(?:否|不|暂不|先不|不要|不用了?|取消|停止|暂停|停下|等等)(?:了|吧)?"
r"(?=$|[\s,。!?、;,.!?;])",
re.IGNORECASE,
)
_ZH_QUESTION_RE = re.compile(r"[??]|(?:吗|么|如何|怎么|能否|是否|可否|什么|为什么)")
_ZH_DESCRIPTION_RE = re.compile(
r"^(?:说明|文档|例如|比如|这是|这里|我想了解|我想知道|如果|假如|也许|可能|考虑)"
r"|(?:生成|制作|渲染).{0,40}(?:视频|图片|图像)"
r"(?:需要|是|的功能|很|有点|可以|失败|报错|一直|仍然|无法|效果|参数|教程|说明|问题|耗时)"
)
def _chinese_refusal(text: str) -> bool:
return bool(_ZH_REFUSAL_RE.search(user_prose(text)))
_APPENDIX_MARK = "[Calliope context]"
@@ -85,9 +121,13 @@ def is_confirmation(text: str) -> bool:
replace" does not.
"""
t = user_prose(text)
if not t:
if not t or _chinese_refusal(t):
return False
for clause in re.split(r"[,.;!?]|\bbut\b|\bhowever\b", t, flags=re.IGNORECASE):
if re.search(r"[\u4e00-\u9fff]", t) and _ZH_QUESTION_RE.search(t):
return False
if _ZH_CONFIRM_RE.fullmatch(re.split(_CLAUSE_RE, t, maxsplit=1)[0].strip()):
return True
for clause in re.split(_CLAUSE_RE, t, flags=re.IGNORECASE):
m = _CONFIRM_RE.search(clause)
if not m:
continue
@@ -107,8 +147,12 @@ def is_render_request(text: str) -> bool:
(canvas/62). Only a negation adjacent to the cue counts against it.
"""
t = user_prose(text)
if not t:
if not t or _chinese_refusal(t):
return False
if re.search(r"[\u4e00-\u9fff]", t) and _ZH_QUESTION_RE.search(t):
return False
if not _ZH_DESCRIPTION_RE.search(t) and _ZH_RENDER_RE.search(t):
return True
expanded = _COMPOUND_SPLIT_RE.sub(" ", t)
if not _RENDER_REQUEST_RE.search(expanded):
return False
@@ -153,13 +197,22 @@ def allows_bulk_video_enqueue(text: str, count: int) -> bool:
def user_allows_render(ctx: ToolContext) -> bool:
"""Render permission from explicit user acts only (see module docstring)."""
from calliope.agent.harness import log as session_log
latest = session_log.latest_user_message(ctx.session_id) or ""
if _chinese_refusal(latest):
return False
# question/answered is recorded before its user/message echo. A refusal
# must also veto the old prose/tag during that brief event-log interval.
answer = latest_answer(ctx.session_id)
if (answer and answer.get("scope") == "render"
and _chinese_refusal(str(answer.get("answer") or ""))):
return False
if has_structured_approval(ctx, "render"):
return True
from calliope.agent.harness import log as session_log
if session_log.latest_user_has_workflow_tag(ctx.session_id):
return True
latest = session_log.latest_user_message(ctx.session_id) or ""
return is_render_request(latest) or is_confirmation(latest)
@@ -187,7 +240,9 @@ def is_shell_request(text: str, command: str | None = None) -> bool:
t = user_prose(text)
if not t:
return False
if command and re.search(rf"(?<![A-Za-z0-9_-]){re.escape(command)}(?![A-Za-z0-9_-])", t, re.IGNORECASE):
if command and re.search(
rf"(?<![A-Za-z0-9_-]){re.escape(command)}(?![A-Za-z0-9_-])", t, re.IGNORECASE
):
for clause in re.split(r"[,.;!?]|\bbut\b|\bhowever\b", t, flags=re.IGNORECASE):
m = re.search(
rf"(?<![A-Za-z0-9_-]){re.escape(command)}(?![A-Za-z0-9_-])", clause, re.IGNORECASE
@@ -218,27 +273,33 @@ def user_allows_shell(ctx: ToolContext, command: str | None = None) -> bool:
# option text as a normal message with answer_to; the runner records
# question/answered immediately before its user/message echo.
_AFFIRMATIVE_ANSWERS = frozenset({"yes", "y", "ok", "okay", "sure", "confirm", "go ahead", "do it"})
_AFFIRMATIVE_ANSWERS = frozenset({
"yes", "y", "ok", "okay", "sure", "confirm", "go ahead", "do it",
"确认生成", "确认", "确定", "同意", "是", "是的", "好", "好的", "可以", "行",
})
# Card options are full sentences ("Yes, replace with ~40 shorter scenes").
# The FIRST word decides: yes-family grants, no-family refuses, anything
# else falls back to the exact-match set. Mechanical, not prose guessing.
_AFFIRMATIVE_FIRST_WORDS = frozenset(
{"yes", "yep", "yeah", "yup", "y", "ok", "okay", "sure", "confirm", "confirmed",
"proceed", "go", "do", "please", "fine", "absolutely", "definitely"}
"proceed", "go", "do", "please", "fine", "absolutely", "definitely",
"确认生成", "确认", "确定", "同意", "是", "是的", "好", "好的", "可以", "行"}
)
_NEGATIVE_FIRST_WORDS = frozenset(
{"no", "nope", "not", "dont", "don't", "do not", "never", "cancel", "stop",
"skip", "wait", "hold"}
"skip", "wait", "hold", "否", "不", "不要", "暂不", "先不", "不用了", "取消", "停止"}
)
def _answer_is_affirmative(text: str) -> bool:
t = text.strip().lower()
if not t:
if not t or _chinese_refusal(t):
return False
if re.search(r"[\u4e00-\u9fff]", t) and _ZH_QUESTION_RE.search(t):
return False
if t in _AFFIRMATIVE_ANSWERS:
return True
first = re.split(r"[,\s.!]+", t, maxsplit=1)[0]
first = re.split(r"[,\s.!,。!?、;]+", t, maxsplit=1)[0]
if first in _AFFIRMATIVE_FIRST_WORDS:
return True
if first in _NEGATIVE_FIRST_WORDS or t.startswith(("don't", "do not", "no ")):
@@ -0,0 +1,166 @@
"""HEL-853: Chinese approvals through the real pre-execute/queue boundary.
All sessions, event files and jobs are isolated; no worker or network starts.
Replay: PYTHONPATH=src python3 -m pytest -q -s tests/test_chinese_render_approval.py
"""
from __future__ import annotations
import asyncio
import json
import pytest
from calliope.agent.harness import get_registry, log, policy
from calliope.agent.harness.registry import ToolContext, _db
from calliope.db import migrate_db
POSITIVES = [
"我想生成一段美女扭屁股视频", "确认生成", "是,生成视频",
"确认生成。", "好的!", "是、生成视频", "yes,生成视频",
"请生成一张图片", "帮我制作一段视频", "生成video", "请出图",
]
REFUSALS = [
"暂不生成", "否,先不生成", "不要生成视频", "不用了,先不生成",
"确认不生成", "可以,但先不要生成", "确认生成,取消生成", "暂停生成视频",
"确认生成,取消", "yes,但不要生成video",
"不要生成视频!", "否。生成视频", "否?生成视频", "取消",
"暂不", "取消吧", "确认生成,拒绝生成",
]
NEGATIVES = REFUSALS + [
"这个画布有什么作用", "如何生成视频", "生成视频需要什么?",
"可以生成视频吗", "确认生成?", "我想了解生成视频的流程",
"生成视频需要显卡", "文档说,生成视频需要显卡", "可能生成视频",
"生成视频失败了", "生成一段视频需要很久", "生成视频参数说明",
"这是生成视频的说明", "确认生成是什么意思", "生成故事", "这里写着,确认生成",
]
@pytest.fixture
def sandbox(monkeypatch, tmp_path):
from calliope.config import Settings, settings
monkeypatch.setattr(settings, "data_dir", tmp_path)
monkeypatch.setattr(settings, "assets_dir", tmp_path / "assets")
monkeypatch.setattr(settings, "dry_run", True)
monkeypatch.setattr(Settings, "save_config_file", lambda self: None)
asyncio.run(migrate_db(settings.db_path))
with _db() as conn:
sid = conn.execute("INSERT INTO agent_sessions (title) VALUES ('HEL-853')").lastrowid
wid = conn.execute(
"INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema, "
"prompt_profile, is_enabled) VALUES ('isolated', 'video', ?, '[]', '[]', 'prose', 1)",
(json.dumps({"12": {"class_type": "CLIPTextEncode", "inputs": {},
"_meta": {"title": "P (Input:prompt)"}}}),),
).lastrowid
return ToolContext(session_id=sid), wid
def message(ctx, text, *, card=False, tagged=False):
if card:
asked = log.append_event(ctx.session_id, log.QUESTION_ASKED, {
"question": "确认开始生成吗?", "options": ["确认生成", "暂不生成"], "scope": "render",
})
log.append_event(ctx.session_id, log.QUESTION_ANSWERED, {
"question_seq": asked.seq, "answer": text, "scope": "render",
})
data = {"content": text}
if tagged:
data["mentions"] = [{"type": "workflow", "id": 1, "kind": "video"}]
log.append_event(ctx.session_id, log.USER_MESSAGE, data)
def execute(ctx, wid, expected):
with _db() as conn:
before = conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
result = asyncio.run(get_registry().execute(ctx, "run_workflow", {
"workflow_id": wid, "prompt": "synthetic isolated prompt",
}))
with _db() as conn:
jobs = conn.execute("SELECT payload_json, status FROM jobs ORDER BY id").fetchall()
assert result.get("ok") is expected, result
assert len(jobs) == before + int(expected)
if expected:
assert json.loads(jobs[-1]["payload_json"])["source"] == "agent"
assert jobs[-1]["status"] == "pending"
else:
assert result["reason_code"] == "guard_render_approval"
source = json.loads(jobs[-1]["payload_json"])["source"] if expected else None
print(json.dumps({"input": log.latest_user_message(ctx.session_id), "ok": result["ok"],
"reason_code": result.get("reason_code"), "jobs": len(jobs),
"source": source},
ensure_ascii=False))
@pytest.mark.parametrize("text", POSITIVES)
@pytest.mark.parametrize("card", [False, True])
def test_chinese_grants_queue_job(sandbox, text, card):
ctx, wid = sandbox
message(ctx, text, card=card)
execute(ctx, wid, True)
@pytest.mark.parametrize("text", NEGATIVES)
@pytest.mark.parametrize("card", [False, True])
def test_chinese_denial_cannot_fall_back(sandbox, text, card):
ctx, wid = sandbox
message(ctx, "yes", card=True)
message(ctx, text, card=card)
assert policy.is_confirmation(text) is False
assert policy.is_render_request(text) is False
assert policy._answer_is_affirmative(text) is False
execute(ctx, wid, False)
@pytest.mark.parametrize("text", REFUSALS)
def test_latest_refusal_overrides_workflow_tag(sandbox, text):
ctx, wid = sandbox
message(ctx, "确认生成", card=True)
message(ctx, text, tagged=True)
execute(ctx, wid, False)
def test_report_sequence(sandbox):
ctx, wid = sandbox
message(ctx, "这个画布有什么作用")
execute(ctx, wid, False)
message(ctx, POSITIVES[0])
execute(ctx, wid, True)
message(ctx, "确认生成", card=True)
execute(ctx, wid, True)
message(ctx, "是,生成视频", card=True)
execute(ctx, wid, True)
message(ctx, "不用了,先不生成")
execute(ctx, wid, False)
@pytest.mark.parametrize(
"text", ["确认生成", "是,生成视频", "确认生成。", "好的!", "yes,生成视频"]
)
def test_card_approval_event_is_recognized(sandbox, text):
ctx, _ = sandbox
message(ctx, text, card=True)
assert policy._answer_is_affirmative(text) is True
assert policy.has_structured_approval(ctx, "render") is True
assert policy.has_structured_approval(ctx, "shell") is False
@pytest.mark.parametrize("text", REFUSALS)
@pytest.mark.parametrize("old_input", ["确认生成", "请生成一段视频"])
def test_card_refusal_before_user_echo_cannot_reuse_old_permission(sandbox, text, old_input):
ctx, wid = sandbox
message(ctx, old_input, card=True, tagged=True)
asked = log.append_event(ctx.session_id, log.QUESTION_ASKED, {
"question": "继续吗?", "scope": "render",
})
log.append_event(ctx.session_id, log.QUESTION_ANSWERED, {
"question_seq": asked.seq, "answer": text, "scope": "render",
})
execute(ctx, wid, False)
def test_new_confirmation_after_cancellation(sandbox):
ctx, wid = sandbox
message(ctx, "暂不生成", card=True)
execute(ctx, wid, False)
message(ctx, "确认生成", card=True)
execute(ctx, wid, True)