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:
parent
c2d24b20b4
commit
e46eb01766
|
|
@ -31,6 +31,7 @@ zcbot/
|
|||
│ ├── skills.py # SkillRegistry(Anthropic 渐进披露)
|
||||
│ ├── task.py # TaskState
|
||||
│ ├── memory.py # per-user .memory/ 双层记忆
|
||||
│ ├── shortcuts.py # 快捷指令(触发词→完整指令,入口层确定性展开;.memory/shortcuts.md)
|
||||
│ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path)
|
||||
│ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM
|
||||
│ └── 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 子树兜底)。
|
||||
|
||||
**快捷指令 ≠ 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. 模型路由
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `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)
|
||||
诊断真实产出 `陶瓷资源节点建设方案.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 不受影响。
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.34.7"
|
||||
__version__ = "0.35.0"
|
||||
|
|
|
|||
|
|
@ -150,6 +150,15 @@ def memory_block(
|
|||
f"\n\n**写到这里**:core → `{base}/core.md`;"
|
||||
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:
|
||||
parts.append("\n### Core (常驻 prompt)\n")
|
||||
parts.append(core)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
18
web/app.py
18
web/app.py
|
|
@ -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)
|
||||
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,保留上一轮
|
||||
# 原文做续聊锚点)。在入站消息落库前判断,故 last_at 取的是上一轮的时间。push 不走这。
|
||||
from core.agent_builder import load_config as _load_config
|
||||
|
|
@ -2349,6 +2359,14 @@ def create_app() -> FastAPI:
|
|||
content = (body.content or "").strip()
|
||||
if not 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:
|
||||
row = s.execute(
|
||||
select(Task.run_status, Task.model_profile)
|
||||
|
|
|
|||
Loading…
Reference in New Issue