feat(shortcuts): 加快捷指令(触发词→完整指令,入口层确定性展开)(bump 0.35.0)

预定义"简报 → 给我输出一份昨日的 AI 新闻简报",任意入口整条打"简报"就展开执行。

关键设计:快捷指令 ≠ memory。memory 是注上下文给模型概率召回的软上下文;快捷词是
入口层、模型跑之前的确定性替换(命中即换、零歧义)。性能上 shortcuts.md 内容永不注
上下文,存再多条平时也是 0 token;触发时进上下文的就是那条完整指令本身。

- core/shortcuts.py(新):shortcuts.md(| 触发词 | 指令 | 两列表)解析 + expand()
  整条 strip()+casefold() 精确匹配展开(与「新话题」魔法命令同风格,不部分匹配)
- web/app.py 两处共用同一 expand:渠道核心 _run_channel_conversation(微信/企业微信)
  + 网页 post_message,起 run 前展开,任意入口行为一致
- core/memory.py memory_block:加一行契约让模型可维护 shortcuts.md;内容不注上下文
- tests/test_shortcuts.py(新):解析 + 展开全覆盖
- DESIGN §3.7 加"快捷指令 ≠ memory"取舍段 + 文件树;PROGRESS 加条目

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-01 14:58:55 +08:00
parent c2d24b20b4
commit e46eb01766
7 changed files with 220 additions and 2 deletions

View File

@ -31,6 +31,7 @@ zcbot/
│ ├── skills.py # SkillRegistry(Anthropic 渐进披露) │ ├── skills.py # SkillRegistry(Anthropic 渐进披露)
│ ├── task.py # TaskState │ ├── task.py # TaskState
│ ├── memory.py # per-user .memory/ 双层记忆 │ ├── memory.py # per-user .memory/ 双层记忆
│ ├── shortcuts.py # 快捷指令(触发词→完整指令,入口层确定性展开;.memory/shortcuts.md)
│ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path) │ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path)
│ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM │ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM
│ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name │ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name
@ -118,6 +119,8 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
**前端记忆面板 = 只读窗口,"改"全走对话(取舍)**:web 左栏「记忆」按钮开只读 modal,直接读 FS 渲染全貌(`GET /v1/memory` 全貌 + `GET /v1/memory/extended/{filename}` 单篇),**故意不提供写/删 API**。理由:① "看全貌"是读、不是 operation —— 走 LLM 反而又贵又只能拿到转述,看地面真相必须直读 FS;② "改"走对话(agent 自管,上文契约)= 单一写入口、自然语言、能合并改写,且用户不会写坏 frontmatter。对照业界:Claude(同为文件式记忆)给全套 view+edit;ChatGPT/Gemini 黑箱式只给看/删、长期不支持内联编辑。我们取"GUI 当眼睛、模型当手":既守住文件式记忆的透明卖点,又不引第二套写代码。后续若"删一条 / prune 臃肿 core.md"这类确定性精确操作摩擦明显,再单加直接的 delete(delete 是唯一廉价且确定性强、值得直连的 mutation,同 ChatGPT 做法)。路径穿越校验收口在 `core/memory.py`(只许 `.memory/extended/` 下扁平 `.md` + resolve 子树兜底)。 **前端记忆面板 = 只读窗口,"改"全走对话(取舍)**:web 左栏「记忆」按钮开只读 modal,直接读 FS 渲染全貌(`GET /v1/memory` 全貌 + `GET /v1/memory/extended/{filename}` 单篇),**故意不提供写/删 API**。理由:① "看全貌"是读、不是 operation —— 走 LLM 反而又贵又只能拿到转述,看地面真相必须直读 FS;② "改"走对话(agent 自管,上文契约)= 单一写入口、自然语言、能合并改写,且用户不会写坏 frontmatter。对照业界:Claude(同为文件式记忆)给全套 view+edit;ChatGPT/Gemini 黑箱式只给看/删、长期不支持内联编辑。我们取"GUI 当眼睛、模型当手":既守住文件式记忆的透明卖点,又不引第二套写代码。后续若"删一条 / prune 臃肿 core.md"这类确定性精确操作摩擦明显,再单加直接的 delete(delete 是唯一廉价且确定性强、值得直连的 mutation,同 ChatGPT 做法)。路径穿越校验收口在 `core/memory.py`(只许 `.memory/extended/` 下扁平 `.md` + resolve 子树兜底)。
**快捷指令 ≠ memory(两种机制,别混)**(`core/shortcuts.py`):触发词 → 完整指令的映射,存 `.memory/shortcuts.md`(`| 触发词 | 指令 |` 两列 md 表)。**关键区别**:memory 是注上下文、给模型**概率召回**的软上下文;快捷指令是入口层、模型跑之前的**确定性替换** —— 每条入站消息先经 `shortcuts.expand(ws, uid, text)` 整条 `strip()+casefold()` 精确匹配,命中即把文本换成完整指令再跑 agent(与「新话题」魔法命令同风格,"帮我出个简报"不误伤)。取舍:① **性能** —— shortcuts.md **内容永不注上下文**(触发靠入口层查表,不靠模型),存再多条平时上下文也是 0,触发时进上下文的就是那条完整指令本身(= 用户本来要打的字),无额外 token;若反过来把它塞进 core.md 让模型概率召回,则既不确定、又每轮烧 token,正是本设计要绕开的坑。② **渠道无关** —— `expand` 在渠道核心 `_run_channel_conversation`(微信/企业微信)与网页 `post_message` 两处共用,任意入口打同一触发词行为一致。③ **维护复用 memory 心智** —— 存储蹭 `.memory/` per-user 壳(agent 已有写权限),`memory_block` 加一行契约让模型在用户说"记个快捷词 X→Y"时写 shortcuts.md;但这行契约只讲"能维护 + 格式",不注文件内容。故:**存储借 memory 的壳,触发逻辑独立且确定**。
--- ---
## 4. 模型路由 ## 4. 模型路由

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-01(ppt 导出图标门升硬 + 修 CLI 退出码不传播 + 验收改全量 + bump 0.34.7) 最后更新:2026-07-01(加快捷指令:触发词→完整指令,入口层确定性展开 + bump 0.35.0)
--- ---
@ -21,6 +21,9 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-07-01 / 加快捷指令(触发词 → 完整指令,渠道无关)(bump 0.35.0)
用户需求:预先定义"简报 → 给我输出一份昨日的 AI 新闻简报",之后任意入口整条打"简报"就展开执行。关键设计判断:**快捷指令不是 memory**——memory 是注上下文给模型概率召回的软上下文,快捷词必须是入口层、模型跑之前的**确定性替换**(命中即换、零歧义、0 额外 token;存再多条平时上下文也是 0)。落地(方案 A:蹭 memory 的 per-user 存储壳、但触发逻辑独立):①新模块 `core/shortcuts.py`——`shortcuts.md`(`| 触发词 | 指令 |` 两列 md 表)解析 + `expand(ws, uid, text)` 整条 `strip()+casefold()` 精确匹配展开(与「新话题」魔法命令同风格,"帮我出个简报"不误伤);②入口接线两处共用同一 `expand`:渠道核心 `_run_channel_conversation`(微信/企业微信自动都覆盖)+ 网页 `post_message`,起 run 前展开;③`core/memory.py memory_block` 加一行契约告诉模型可维护 `shortcuts.md`(用户说"记个快捷词 X→Y"时写),但**内容不注上下文**、触发不问模型。维护沿用 memory 心智(对话里让模型写,无新增管理 UI)。`tests/test_shortcuts.py` 覆盖解析(跳表头/分隔行、首行赢、大小写归一)+ 展开(精确命中、不部分匹配、缺文件、空文本)全过。
### 2026-07-01 / ppt skill 修复 ppt生成2(966041e5):图标门升硬 + CLI 退出码传播 + 验收改全量(bump 0.34.7) ### 2026-07-01 / ppt skill 修复 ppt生成2(966041e5):图标门升硬 + CLI 退出码传播 + 验收改全量(bump 0.34.7)
诊断真实产出 `陶瓷资源节点建设方案.pptx`(deepseek-v4-flash 跑)两个缺陷:①23 页零图标(spec_lock 锁了 chunk-filled+inventory 却全 deck 0 个 `<use data-icon>`);②不少错位。根因不是缺 gate 而是 gate 被打穿:(a) `svg_to_pptx.py:22``main()``sys.exit(main())`——**main() 里所有 `return 1`(图标门/无 SVG/坏路径)全被吞成退出 0**,这是最致命的一处;(b) 导出侧图标检查 `_warn_if_icons_unused` 按设计只软 WARN、照常产出;(c) 模型质检时 `svg_quality_checker.py ... | head -30`,管道吞非零退出码 + `head` 截掉打在最后的零图标 `[ERROR]` 结论;(d) 验收阶段 SKILL.md 本就只要求抽查 3 页,23 页里只肉眼看了 2 页,且封面 vision 已报"半成品/错位"仍未返工直接交付。改动:①`svg_to_pptx.py` → `sys.exit(main())`;②`pptx_cli.py` 把导出侧检查从软 WARN 升为**硬门**(锁图标却全 deck 零 `<use data-icon>``[ERROR]` 退非零、不产出 pptx),加显式逃生口 `--allow-iconless`(应对 lock 过期/有意无图标);③SKILL.md 阶段六验收改「默认渲整本、逐页过目、差评即阻断返工」(废掉抽查 3 页),阶段四/五/反模式补「别用 `| head` 截断质检/导出输出」「别只看几页」「看到差评必返工」。合成测试三例(默认拒/`--allow-iconless` 放行/有图标正常)全过。**注:此修仅改 skill 侧,不改动线上跑法**;导出门只兜"锁了图标却零引用",正常有图标 deck 不受影响。 诊断真实产出 `陶瓷资源节点建设方案.pptx`(deepseek-v4-flash 跑)两个缺陷:①23 页零图标(spec_lock 锁了 chunk-filled+inventory 却全 deck 0 个 `<use data-icon>`);②不少错位。根因不是缺 gate 而是 gate 被打穿:(a) `svg_to_pptx.py:22``main()``sys.exit(main())`——**main() 里所有 `return 1`(图标门/无 SVG/坏路径)全被吞成退出 0**,这是最致命的一处;(b) 导出侧图标检查 `_warn_if_icons_unused` 按设计只软 WARN、照常产出;(c) 模型质检时 `svg_quality_checker.py ... | head -30`,管道吞非零退出码 + `head` 截掉打在最后的零图标 `[ERROR]` 结论;(d) 验收阶段 SKILL.md 本就只要求抽查 3 页,23 页里只肉眼看了 2 页,且封面 vision 已报"半成品/错位"仍未返工直接交付。改动:①`svg_to_pptx.py` → `sys.exit(main())`;②`pptx_cli.py` 把导出侧检查从软 WARN 升为**硬门**(锁图标却全 deck 零 `<use data-icon>``[ERROR]` 退非零、不产出 pptx),加显式逃生口 `--allow-iconless`(应对 lock 过期/有意无图标);③SKILL.md 阶段六验收改「默认渲整本、逐页过目、差评即阻断返工」(废掉抽查 3 页),阶段四/五/反模式补「别用 `| head` 截断质检/导出输出」「别只看几页」「看到差评必返工」。合成测试三例(默认拒/`--allow-iconless` 放行/有图标正常)全过。**注:此修仅改 skill 侧,不改动线上跑法**;导出门只兜"锁了图标却零引用",正常有图标 deck 不受影响。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.34.7" __version__ = "0.35.0"

View File

@ -150,6 +150,15 @@ def memory_block(
f"\n\n**写到这里**:core → `{base}/core.md`;" f"\n\n**写到这里**:core → `{base}/core.md`;"
f"专题 → `{base}/extended/<slug>.md`\n" f"专题 → `{base}/extended/<slug>.md`\n"
) )
# 快捷指令(与记忆是两套机制):触发词 → 完整指令的映射,存 shortcuts.md。**内容不注上下文**
# (入口层查表展开,不靠你召回),这里只给"能维护 + 格式",让你在用户要建/改快捷词时会写。
parts.append(
f"\n**快捷指令**:用户说\"记个快捷词 X → Y\"/\"把快捷词 X 改成/删掉\"时,维护 "
f"`{base}/shortcuts.md`(先 `read` 再 `edit`)。格式是两列 markdown 表 "
f"`| 触发词 | 完整指令 |`(表头 + `|---|---|` 分隔行 + 每条一行;触发词别含 `|`)。"
f"之后用户在任意入口(网页/微信/企业微信)整条打这个触发词,系统自动展开成完整指令 —— "
f"你无需在对话里替他执行触发,只负责把这行写对。\n"
)
if core: if core:
parts.append("\n### Core (常驻 prompt)\n") parts.append("\n### Core (常驻 prompt)\n")
parts.append(core) parts.append(core)

103
core/shortcuts.py Normal file
View File

@ -0,0 +1,103 @@
"""用户快捷指令(触发词 → 完整指令)。渠道无关,入口层确定性展开。
存储:`workspace/users/<user_id>/.memory/shortcuts.md` memory per-user 存储壳
(同一 workspace 内按 user_id 隔离,agent 已有该目录写权限),** memory 是两种机制**:
- memory 是注进 system prompt给模型**参考**的软上下文(概率召回)
- 快捷指令**不进上下文**:展开发生在入口层模型跑之前 每条入站消息先经 `expand()`
查表,整条精确命中触发词就把文本替换成完整指令再跑 agent所以存再多条,平时上下文也是 0;
触发时进上下文的就是那条完整指令本身(= 用户本来要打的字),无额外 token
维护(agent 自管, memory):用户在对话里说"记个快捷词:X → Y",模型往 shortcuts.md 写一行
(memory 契约里加了一句告诉它格式);触发不靠模型,靠本模块解析,确定零歧义
格式(markdown 两列表,容错解析;表头/分隔行自动跳过):
| 触发词 | 指令 |
|---|---|
| 简报 | 给我输出一份昨日的 AI 新闻简报 |
匹配语义:整条消息 `strip()` + `casefold()` 后与某触发词**精确相等**才展开;
"帮我出个简报" 不命中(当普通消息走)新话题魔法命令同风格,零误伤
触发词含 `|` 会破坏表格解析 约定触发词不含竖线;指令正文含竖线也会被截断,同样避免
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, Optional, Tuple
from uuid import UUID
# 表头行的触发词(解析时跳过,避免把表头当成一条快捷词)
_HEADER_TRIGGERS = {"触发词", "触发", "快捷词", "快捷指令", "命令", "trigger", "shortcut"}
# markdown 表格分隔行的单元格:`---` / `:--` / `:-:` 之类
_SEP_RE = re.compile(r"^:?-+:?$")
def _shortcuts_file(workspace_dir: Path, user_id: UUID) -> Path:
return workspace_dir / "users" / str(user_id) / ".memory" / "shortcuts.md"
def _normalize(s: str) -> str:
return s.strip().casefold()
def _is_separator(cell: str) -> bool:
return bool(_SEP_RE.match(cell.replace(" ", "")))
def parse_shortcuts(text: str) -> Dict[str, str]:
"""解析 shortcuts.md 文本 → {归一化触发词: 完整指令}。纯函数,可测。
容错:只认以 `|` 起头的表格行;跳过分隔行表头行空单元格行;
触发词重复时**先出现者赢**(首行优先,和人读顺序一致)
"""
mapping: Dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 2:
continue
trigger, prompt = cells[0], cells[1]
if not trigger or not prompt:
continue
if _is_separator(trigger) and _is_separator(prompt):
continue # 分隔行 |---|---|
key = _normalize(trigger)
if not key or key in _HEADER_TRIGGERS:
continue # 空或表头
mapping.setdefault(key, prompt) # 首行优先
return mapping
def load_shortcuts(workspace_dir: Path, user_id: UUID) -> Dict[str, str]:
"""读该用户 shortcuts.md 并解析;文件不存在 / 读失败 → 空表(不抛,不挡入站)。"""
p = _shortcuts_file(workspace_dir, user_id)
if not p.is_file():
return {}
try:
return parse_shortcuts(p.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError):
return {}
def expand(
workspace_dir: Path, user_id: UUID, text: str
) -> Tuple[str, Optional[str]]:
"""入口层展开:整条 `text` 精确命中某触发词 → 返回 (完整指令, 命中的触发词原文);
未命中 返回 (text 原样, None)空文本直接原样返回
调用点:渠道核心 `_run_channel_conversation` + 网页 `post_message`,共用此函数,
保证任何入口打同一个触发词行为一致
"""
if not text or not text.strip():
return text, None
mapping = load_shortcuts(workspace_dir, user_id)
if not mapping:
return text, None
prompt = mapping.get(_normalize(text))
if prompt is None:
return text, None
return prompt, text.strip()

82
tests/test_shortcuts.py Normal file
View File

@ -0,0 +1,82 @@
"""core/shortcuts.py:解析 + 入口层展开(纯函数 + 文件读)。"""
from __future__ import annotations
from uuid import uuid4
from core import shortcuts
SAMPLE = """\
# 我的快捷指令
| 触发词 | 指令 |
|---|---|
| 简报 | 给我输出一份昨日的 AI 新闻简报 |
| Standup | Summarize yesterday's commits |
| 简报 | 这条重复应被首行覆盖 |
"""
def test_parse_skips_header_and_separator():
m = shortcuts.parse_shortcuts(SAMPLE)
# 表头「触发词」、分隔行 |---| 都不进表
assert "触发词" not in m
assert "---" not in m
assert "简报" in m
assert m["简报"] == "给我输出一份昨日的 AI 新闻简报" # 首行赢
def test_parse_case_insensitive_key():
m = shortcuts.parse_shortcuts(SAMPLE)
# 触发词归一化用 casefold,英文键存成小写
assert "standup" in m
assert m["standup"] == "Summarize yesterday's commits"
def test_parse_empty_and_garbage():
assert shortcuts.parse_shortcuts("") == {}
assert shortcuts.parse_shortcuts("没有表格\n只是普通文本") == {}
# 单元格缺失 / 只有一列 → 跳过
assert shortcuts.parse_shortcuts("| 只有一列 |") == {}
def _write(tmp_path, user_id, body):
d = tmp_path / "users" / str(user_id) / ".memory"
d.mkdir(parents=True, exist_ok=True)
(d / "shortcuts.md").write_text(body, encoding="utf-8")
def test_expand_exact_match(tmp_path):
uid = uuid4()
_write(tmp_path, uid, SAMPLE)
out, hit = shortcuts.expand(tmp_path, uid, "简报")
assert out == "给我输出一份昨日的 AI 新闻简报"
assert hit == "简报"
# 首尾空格 / 大小写不影响命中
out2, hit2 = shortcuts.expand(tmp_path, uid, " Standup ")
assert out2 == "Summarize yesterday's commits"
assert hit2 == "Standup"
def test_expand_no_partial_match(tmp_path):
uid = uuid4()
_write(tmp_path, uid, SAMPLE)
# 整条不等于触发词 → 原样返回,不展开
out, hit = shortcuts.expand(tmp_path, uid, "帮我出个简报")
assert out == "帮我出个简报"
assert hit is None
def test_expand_missing_file(tmp_path):
uid = uuid4() # 没写文件
out, hit = shortcuts.expand(tmp_path, uid, "简报")
assert out == "简报"
assert hit is None
def test_expand_empty_text(tmp_path):
uid = uuid4()
_write(tmp_path, uid, SAMPLE)
out, hit = shortcuts.expand(tmp_path, uid, " ")
assert out == " "
assert hit is None

View File

@ -495,6 +495,16 @@ async def _run_channel_conversation(app, uid, text, attachments, *, channel):
await asyncio.to_thread(_wx.reset_channel_context, tid, hard=True) await asyncio.to_thread(_wx.reset_channel_context, tid, hard=True)
return "已开启新话题,之前的对话已归档(网页端仍可查看完整历史)。" return "已开启新话题,之前的对话已归档(网页端仍可查看完整历史)。"
# 快捷指令展开(渠道无关,见 core/shortcuts.py):整条精确命中触发词 → 文本换成完整指令
# 再照常跑;不进上下文、不问模型。放「新话题」命令之后、附件/gap 之前:展开后的文本仍会
# 被下面的附件行追加,故打「简报」+ 附图也成立。
from core.agent_builder import resolve_workspace as _resolve_ws
from core import shortcuts as _shortcuts
_ws = await asyncio.to_thread(_resolve_ws, None)
text, _hit = await asyncio.to_thread(_shortcuts.expand, _ws, uid, text)
if _hit:
print(f"[shortcut] {str(uid)[:8]} '{_hit}' expanded")
# 自动分段:距上次消息超过 gap 阈值 → 软重置(base=最后一条 user 消息 idx,保留上一轮 # 自动分段:距上次消息超过 gap 阈值 → 软重置(base=最后一条 user 消息 idx,保留上一轮
# 原文做续聊锚点)。在入站消息落库前判断,故 last_at 取的是上一轮的时间。push 不走这。 # 原文做续聊锚点)。在入站消息落库前判断,故 last_at 取的是上一轮的时间。push 不走这。
from core.agent_builder import load_config as _load_config from core.agent_builder import load_config as _load_config
@ -2349,6 +2359,14 @@ def create_app() -> FastAPI:
content = (body.content or "").strip() content = (body.content or "").strip()
if not content: if not content:
raise HTTPException(400, "empty content") raise HTTPException(400, "empty content")
# 快捷指令展开(与渠道入口共用 core/shortcuts.py):整条精确命中触发词 → 换成完整
# 指令。在起 run 之前、落库之前展开,模型看到的就是完整指令(不进上下文、不问模型)。
from core.agent_builder import resolve_workspace as _resolve_ws
from core import shortcuts as _shortcuts
_ws = await asyncio.to_thread(_resolve_ws, None)
content, _sc_hit = await asyncio.to_thread(_shortcuts.expand, _ws, user_id, content)
if _sc_hit:
print(f"[shortcut] {str(user_id)[:8]} '{_sc_hit}' expanded")
with session_scope() as s: with session_scope() as s:
row = s.execute( row = s.execute(
select(Task.run_status, Task.model_profile) select(Task.run_status, Task.model_profile)