diff --git a/DESIGN.md b/DESIGN.md index 3045f8f..8027c45 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. 模型路由 diff --git a/PROGRESS.md b/PROGRESS.md index 040a377..605de0f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 个 ``);②不少错位。根因不是缺 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 零 `` → `[ERROR]` 退非零、不产出 pptx),加显式逃生口 `--allow-iconless`(应对 lock 过期/有意无图标);③SKILL.md 阶段六验收改「默认渲整本、逐页过目、差评即阻断返工」(废掉抽查 3 页),阶段四/五/反模式补「别用 `| head` 截断质检/导出输出」「别只看几页」「看到差评必返工」。合成测试三例(默认拒/`--allow-iconless` 放行/有图标正常)全过。**注:此修仅改 skill 侧,不改动线上跑法**;导出门只兜"锁了图标却零引用",正常有图标 deck 不受影响。 diff --git a/core/__init__.py b/core/__init__.py index 298463c..835c915 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.34.7" +__version__ = "0.35.0" diff --git a/core/memory.py b/core/memory.py index 9903377..ebcb602 100644 --- a/core/memory.py +++ b/core/memory.py @@ -150,6 +150,15 @@ def memory_block( f"\n\n**写到这里**:core → `{base}/core.md`;" f"专题 → `{base}/extended/.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) diff --git a/core/shortcuts.py b/core/shortcuts.py new file mode 100644 index 0000000..da53b35 --- /dev/null +++ b/core/shortcuts.py @@ -0,0 +1,103 @@ +"""用户快捷指令(触发词 → 完整指令)。渠道无关,入口层确定性展开。 + +存储:`workspace/users//.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() diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py new file mode 100644 index 0000000..d3012b7 --- /dev/null +++ b/tests/test_shortcuts.py @@ -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 diff --git a/web/app.py b/web/app.py index 55d4e86..4cbb6a9 100644 --- a/web/app.py +++ b/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)