zcbot/core/shortcuts.py

104 lines
4.2 KiB
Python

"""用户快捷指令(触发词 → 完整指令)。渠道无关,入口层确定性展开。
存储:`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()