feat: 不用斜杠,单发 goal 也触发 /goal 目标

触发词从仅 /goal 放宽到 /goal 或裸 goal(大小写不敏感,
单数 only,goals/mygoal 不算):goal 收xxx、goal,目标是
收xxx、发goal 收xxx 均同款触发,新建与主线转换入口共用
同一解析。讨论句式仍不触发:goal 的用法、goal 是什么、
讨论goal的用法、/goal的用法 均返回 None。
单测补 4 项:裸 goal 触发、裸 goal 讨论不触发、followup
裸 goal 转换、新建裸 goal。
This commit is contained in:
host committed 2026-10-30 07:15:05 +08:00
1 parent f3bd65c171
commit f1aab5415c
3 files changed
+93 -32

No files matched your search

+6 -3
View File
@@ -535,10 +535,13 @@ goal 模式的继续:server 把任务转成新 goal 语义(goal=新消息、
## 主线 /goal 命令(发给主线一句话就自动建 goal)
不用先知道主线是进行中还是已终态,也不用勾选项:给主线发
一条含字面 `/goal` 的消息即可,例如“请在这条主线开启一个
持久 /goal,目标是收xxx”,或直接写“/goal 收xxx”。解析在
一条含触发词的消息即可,例如“请在这条主线开启一个
持久 /goal,目标是收xxx”,或直接写“/goal 收xxx”。斜杠
不用:单发 `goal` 也同款触发,例如“goal 收xxx”、
“goal,目标是收xxx”。解析在
`cloud/goal_command.py`(server 与 worker 共用同一份,防两端
判断不一致);只是在讨论里提到“/goal 的用法”不算命令。
判断不一致);只是在讨论里提到“/goal 的用法”“goal 是什么”
这种不算命令。
新建目标也同款:`POST /tasks` 的 prompt 里直接写 `/goal
收xxx`(或上面那句整话)即新建 goal 任务,prompt 与 goal
都存解析出的目标,不用勾 goal 选项;空目标、plan 冲突、
+57 -29
View File
@@ -7,15 +7,19 @@
触发转换,调用方不用再知道任务当前是进行中还是已终态。
解析规则(刻意收紧,防误触发):
- 文本里必须出现字面 "/goal"(全角 / 也认,大小写不敏感)。
- 触发词是 "/goal" 或裸 "goal"(斜杠不用,全角 / 也认,
大小写不敏感,单数 only——"goals" 不算)。
- 光出现还不够,必须是命令形状,二者居一:
1. 文本以 /goal 开头(最显式的命令);
2. /goal 后面紧跟分隔符(空格、,、:、、)或“目标”标记,
例如“/goal 收xxx”“/goal,目标是收xxx”;
3. /goal 前面有开启类动词(开启/建立/创建/添加/设为/设置/
开一个),例如“请开启一个持久/goal收xxx”。
只是在讨论里提到“/goal 的用法”这种不算命令。
- 目标取 /goal 之后的部分:先去分隔符,再去“目标是/目标为/
1. 文本以触发词开头(最显式的命令),例如“goal 收xxx”;
2. 触发词后面紧跟分隔符(空格、,、:、、)或“目标”标记,
例如“/goal 收xxx”“goal,目标是收xxx”;
3. 触发词前面有开启/发送类动词(开启/建立/创建/添加/设为/
设置/开一个/发/发送),例如“请开启一个持久/goal收xxx”、
“发goal 收xxx”。
只是在讨论里提到“/goal 的用法”“goal 的用法”“goal 是什么”
这种不算命令:触发词后面紧跟“的/是/吗/什么/怎么/如何/
模式/用法/意思/功能”时一律不当命令。
- 目标取触发词之后的部分:先去分隔符,再去“目标是/目标为/
目标:/目标:”或“持久目标是/为/:/:”这类前缀,最后去尾句号。
- 返回值:不是命令返回 None;是命令但没给目标返回 ""(调用方
据此回 400“目标不能为空”,而不是当成普通消息悄悄排队);
@@ -28,12 +32,17 @@
import re
_GOAL_RE = re.compile(r"/goal(?![A-Za-z])", re.IGNORECASE)
_BARE_GOAL_RE = re.compile(r"(?<![A-Za-z])goal(?![A-Za-z])", re.IGNORECASE)
_SEPARATORS = " \t\r\n::,,、"
_TARGET_MARKERS = (
"持久目标是", "持久目标为", "持久目标:", "持久目标:",
"目标是", "目标为", "目标:", "目标:",
)
_OPEN_VERBS = ("开启", "建立", "创建", "添加", "设为", "设置", "开一个")
_OPEN_VERBS = ("开启", "建立", "创建", "添加", "设为", "设置", "开一个",
"发", "发送")
# 触发词后紧跟这些词头时是在讨论 goal 本身,不是下命令。
_DISCUSSION_PREFIXES = ("的", "是", "吗", "什么", "怎么", "如何",
"模式", "用法", "意思", "功能")
def _strip_target(rest):
@@ -49,6 +58,20 @@ def _strip_target(rest):
return rest
def _rest_is_command(rest):
"""触发词后的部分是不是命令形状(讨论词头除外)。"""
if not rest:
# 单独一句“/goal”/“goal”:是命令,但没目标,调用方回 400。
return True
if rest[0] in _SEPARATORS:
return True
if rest.startswith("目标"):
return True
if rest.startswith(_DISCUSSION_PREFIXES):
return False
return True
def parse_goal_command(text):
"""解析主线消息里的 /goal 命令,返回目标或 None/""(见模块说明)。"""
if not isinstance(text, str):
@@ -56,24 +79,29 @@ def parse_goal_command(text):
s = text.strip().replace("/", "/")
if not s:
return None
# 先认带斜杠的:是命令直接返回;不是命令(如讨论里提到)
# 也不急着否掉,后面裸 goal 的位置还可能是一条真命令。
match = _GOAL_RE.search(s)
if match is None:
return None
before = s[:match.start()]
rest = s[match.end():]
stripped = s.lstrip()
is_command = False
if stripped.lower().startswith("/goal"):
is_command = True
elif rest and rest[0] in _SEPARATORS:
is_command = True
elif rest.startswith("目标"):
is_command = True
elif any(verb in before for verb in _OPEN_VERBS):
is_command = True
elif not rest:
# 单独一句“/goal”:是命令,但没目标,调用方回 400。
is_command = True
if not is_command:
return None
return _strip_target(rest)
if match is not None:
before = s[:match.start()]
rest = s[match.end():]
# 斜杠本身已是显式标记,句首与否不再单算:命令与否
# 只看前面有没有动词、后面是不是讨论词头——这样
# “/goal的用法”也不会被误当成命令。
if (any(verb in before for verb in _OPEN_VERBS)
or _rest_is_command(rest)):
return _strip_target(rest)
# 再认不带斜杠的 goal:位置必须在句首,或前面是分隔符/
# 开启·发送类动词,否则“讨论goal的用法”这类也会误触发。
for bare in _BARE_GOAL_RE.finditer(s):
if bare.start() > 0 and s[bare.start() - 1] == "/":
continue # 斜杠那处已在上面判过
before = s[:bare.start()]
rest = s[bare.end():]
at_start = not before.strip()
before_ok = at_start or (
before.rstrip() and before.rstrip()[-1] in _SEPARATORS) or (
any(verb in before for verb in _OPEN_VERBS))
if before_ok and _rest_is_command(rest):
return _strip_target(rest)
return None
+30
View File
@@ -56,6 +56,22 @@ class ParserTestCase(unittest.TestCase):
self.assertIsNone(parse_goal_command("请帮我完成目标收xxx"))
self.assertIsNone(parse_goal_command("hello world"))
def test_bare_goal_without_slash(self):
# 不用斜杠:用户单发 goal 也触发同款目标。
self.assertEqual(parse_goal_command("goal 收xxx"), "收xxx")
self.assertEqual(parse_goal_command("goal,目标是收xxx"), "收xxx")
self.assertEqual(parse_goal_command("goal目标是收xxx"), "收xxx")
self.assertEqual(parse_goal_command("goal收xxx"), "收xxx")
self.assertEqual(parse_goal_command("发goal 收xxx"), "收xxx")
self.assertEqual(parse_goal_command("goal"), "")
def test_bare_goal_discussion_still_not_a_command(self):
self.assertIsNone(parse_goal_command("goal的用法"))
self.assertIsNone(parse_goal_command("goal是什么意思"))
self.assertIsNone(parse_goal_command("讨论goal的用法"))
self.assertIsNone(parse_goal_command("请问goal是什么意思"))
self.assertIsNone(parse_goal_command("/goal的用法"))
class ServerTestCase(unittest.TestCase):
def setUp(self):
@@ -248,6 +264,20 @@ class ServerTestCase(unittest.TestCase):
task = self._create(prompt="讨论/goal的用法")
self.assertEqual(task["goal_mode"], 0)
def test_followup_bare_goal_converts(self):
task = self._create()
status, body = self._req(
"POST", "/tasks/%s/followups" % task["id"],
{"body": "goal 收xxx"}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["delivery"], "goal_converted")
self.assertEqual(body["goal"], "收xxx")
def test_create_task_bare_goal(self):
task = self._create(prompt="goal 收xxx")
self.assertEqual(task["goal_mode"], 1)
self.assertEqual(task["goal"], "收xxx")
def test_continue_goal_command(self):
task = self._create()
self._set_status(task["id"], "completed")