Ctrl K
ComunidadGuías…

butler/gateway/handler_helpers.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Gateway message handler helper functions.

Extracted from message_handler.py. Contains text normalization,
command classification, project overview building, and session
management utilities.
"""

from __future__ import annotations

import logging
import os
import re
import threading
from pathlib import Path
from typing import Any, Callable, TYPE_CHECKING, cast

from butler.tools._file_cache import read_json_cached
from butler.core.loop_types import LoopCallbacks
from butler.core.auto_continue import resolve_auto_continue_user_message
from butler.core.best_effort import safe_best_effort
from butler.defaults.env_defaults import BUTLER_HOME_DEFAULT, ONBOARDING_WELCOME_DEFAULT
from butler.utilities.env_parse import env_truthy, float_env, int_env
from butler.gateway.command_registry import lookup
from butler.gateway.outbound_bridge import get_current_bridge
from butler.mcp.registry_hook import disconnect_mcp_session
from butler.runtime.service import list_jobs_status, runtime_enabled
from butler.tools.registry import get_tool_audit_events, reset_tool_audit_events
from butler.tools.reminder import _load_all

if TYPE_CHECKING:
    from butler.core.agent_loop.loop import AgentLoop

logger = logging.getLogger(__name__)

# EXT-2 / 外部 SaaS:含这些词时不走 Butler 多项目 /总览、/项目 归一化
_EXTERNAL_INTEGRATION_MARKERS = (
    "todoist",
    "notion",
    "linear",
    "trello",
    "asana",
)


def _mentions_external_integrations(text: str) -> bool:
    lower = (text or "").strip().lower()
    return any(marker in lower for marker in _EXTERNAL_INTEGRATION_MARKERS)


def _normalize_switch_request(text: str) -> str | None:
    """Map「切换到 <项目>」to ``/切换 <项目>`` (WeChat natural phrasing)."""
    stripped = (text or "").strip().rstrip("。.!!??")
    _switch_prefixes = ("切换到", "切换至", "切换去", "切换回")
    if stripped.startswith("切换") and not any(
        stripped.startswith(p) for p in _switch_prefixes
    ):
        name = stripped[len("切换") :].strip().rstrip("。.!!??")
        if name and not name.startswith("/"):
            return f"/切换 {name}"
    for prefix in (
        "切换到",
        "切换至",
        "切换去",
        "切换回",
        "现在切到",
        "现在切回",
        "切到",
        "切回",
        "把项目切回去",
        "把项目切回",
    ):
        if stripped.startswith(prefix):
            name = stripped[len(prefix) :].strip().rstrip("。.!!??")
            if name:
                return f"/切换 {name}"
    if stripped.startswith("切回去"):
        name = stripped[len("切回去") :].strip().rstrip("。.!!??")
        if name:
            return f"/切换 {name}"
    return None


def _normalize_status_request(text: str) -> str | None:
    """Map project-status questions to ``/状态``."""
    stripped = (text or "").strip().rstrip("??").strip()
    purpose_hints = (
        "做什么",
        "干什么",
        "用途",
        "目的",
        "介绍",
        "是干嘛",
        "干啥",
        "干什么用",
        "用来做什么",
        "用来干什么",
    )
    if any(hint in stripped for hint in purpose_hints):
        return None
    if stripped in (
        "当前在哪个项目",
        "当前是什么项目",
        "当前什么项目",
        "现在在哪个项目",
        "现在在什么项目",
        "当前项目是什么",
        "确认一下当前项目",
        "报一下当前项目",
    ):
        return "/状态"
    if any(
        phrase in stripped
        for phrase in ("哪个项目", "什么项目", "当前项目", "现在在哪个")
    ) and any(
        hint in stripped
        for hint in ("当前", "现在", "哪个", "什么", "确认", "报", "我是")
    ):
        if any(
            verb in stripped
            for verb in ("读", "读取", "列出", "写", "删", "委派", "检查", "README", "文件")
        ):
            return None
        return "/状态"
    if any(
        phrase in stripped
        for phrase in ("有哪些项目", "项目列表", "哪些workspace", "几个项目")
    ):
        if _mentions_external_integrations(stripped):
            return None
        return "/项目"
    if any(
        phrase in stripped
        for phrase in ("总览", "全部项目状态", "所有项目", "项目仪表盘", "dashboard")
    ):
        if _mentions_external_integrations(stripped):
            return None
        return "/总览"
    return None


def _normalize_new_session_request(text: str) -> str | None:
    """Allow ``/新对话`` / ``/新会话`` with trailing natural-language hints."""
    stripped = (text or "").strip()
    if stripped == "/新对话" or stripped.startswith("/新对话"):
        return "/新对话"
    if stripped == "/新会话" or stripped.startswith("/新会话"):
        return "/新对话"
    return None


def _normalize_memo_request(text: str) -> str | None:
    """Map natural phrases about memos to ``/备忘``."""
    stripped = (text or "").strip().rstrip("。.!!??")
    if not stripped:
        return None
    if stripped in ("查看备忘", "我的备忘", "备忘录", "备忘列表", "看看备忘",
                    "有什么备忘", "备忘有哪些"):
        return "/备忘"
    return None


def _normalize_contacts_request(text: str) -> str | None:
    """Map natural phrases about contacts to ``/通讯录``."""
    stripped = (text or "").strip().rstrip("。.!!??")
    if not stripped:
        return None
    if stripped in ("通讯录", "联系人", "我的通讯录", "查看通讯录", "联系人列表"):
        return "/通讯录"
    return None


def _normalize_expense_request(text: str) -> str | None:
    """Map natural phrases about expenses to ``/记账``."""
    stripped = (text or "").strip().rstrip("。.!!??")
    if not stripped:
        return None
    if stripped in ("记账", "账单", "看看账单", "这个月花了多少", "本月支出",
                    "本月账单", "收支", "我的账单"):
        return "/记账"
    return None


def _normalize_habits_request(text: str) -> str | None:
    """Map natural phrases about habits to ``/打卡``."""
    stripped = (text or "").strip().rstrip("。.!!??")
    if not stripped:
        return None
    if stripped in ("打卡", "习惯", "今日打卡", "我的习惯", "习惯打卡",
                    "看看打卡", "打卡情况"):
        return "/打卡"
    return None


def _normalize_detail_request(text: str) -> str | None:
    """Map WeChat-friendly「详细」to ``/详细`` without requiring a slash prefix."""
    stripped = (text or "").strip()
    if not stripped:
        return None
    lower = stripped.lower()
    if "报错" in stripped or "错误信息" in stripped:
        return None
    for marker, cmd in (("/详细", "/详细"), ("/detail", "/detail")):
        idx = lower.find(marker)
        if idx >= 0:
            rest = stripped[idx + len(marker) :].strip().rstrip(",,。.!!??")
            return f"{cmd} {rest}".strip() if rest else cmd
    detail_aliases = {
        "/详细",
        "/detail",
        "详细",
        "detail",
        "查看详细",
        "看详细",
        "完整报告",
        "详细信息",
        "查看详细信息",
        "看一下详细",
        "看一下详细信息",
        "我要看一下详细信息",
        "看详细信息",
    }
    if lower in detail_aliases or stripped in detail_aliases:
        return "/详细"
    for prefix in ("详细", "详细信息", "看一下详细"):
        if stripped.startswith(prefix) and len(stripped) > len(prefix):
            rest = stripped[len(prefix) :].strip()
            if rest.startswith("信息"):
                rest = rest[2:].strip()
            if rest:
                return "/详细 " + rest
    if stripped.startswith("详细 "):
        return "/详细 " + stripped[3:].strip()
    if lower.startswith("detail "):
        return "/detail " + stripped[7:].strip()
    return None


def _gateway_run_callbacks() -> LoopCallbacks | None:

    bridge = get_current_bridge()
    if bridge is None:
        return None

    def _on_stream_delta(chunk: str) -> None:
        bridge.append_stream_preview(chunk)

    return LoopCallbacks(
        on_tool_start=bridge.on_tool_start,
        on_tool_complete=bridge.on_tool_complete,
        on_stream_delta=_on_stream_delta,
    )


def _is_prequeue_interrupt_command(text: str) -> bool:
    stripped = (text or "").strip().lower()
    if not stripped.startswith("/"):
        return False
    cmd = stripped.split(maxsplit=1)[0]
    return cmd in {
        "/stop",
        "/停止",
        "/中断",
        "/cancel-loop",
        "/停止循环",
        "/stoploop",
    }


def apply_auto_continue_rewrite(session_key: str, text: str) -> str | None:
    """Sprint 16 TST-10-5 第八批: 抽自 message_handler.py:426-433 pre-dispatch rewriter.

    如果用户输入是 continue marker 且有 fresh pending, 返回改写后的 text (供后续 dispatch);
    否则返 None (text 不变). 异常吞掉 + logger.debug, 与原 inline try/except 行为一致.

    注意: 这是 text rewriter, **不**是 slash dispatch handler — 返回值是 "新 text" 而非 reply 字符串.
    """
    def _run() -> str | None:
        return cast(str | None, resolve_auto_continue_user_message(session_key, text))

    return cast(
        str | None,
        safe_best_effort(
            _run,
            label="handler_helpers.auto_continue",
            default=None,
        ),
    )


def _env_int(name: str, default: int) -> int:
    return cast(int, int_env(name, default))


def _env_float(name: str, default: float) -> float:
    return cast(float, float_env(name, default))


def _is_sessionless_command(text: str) -> bool:
    """Decide whether *text* names a sessionless slash command.

    Sprint 18-4 D CRITICAL-2: 真源 = command_registry. 已注册命令(含 aliases)
    立即识别, 动态注册的新命令零漂移. 未注册命令返 False (降级到 session
    队列后 LLM, 与原硬编码 set 中"未注册"成员的实际行为对齐 — 那些命令
    在 dispatch 失败后同样 fallback 到 LLM).

    行为变化: 之前 27 项已注册但不在 set 的命令 (新 dispatch 命令) 现在
    sessionless, 立即响应; 之前 45 项在 set 但未注册的命令现在等 session
    队列, 仅延迟差异 (最终 LLM 接收, 功能不变).
    """
    stripped = (text or "").strip()
    if not stripped.startswith("/"):
        return False
    parts = stripped.split(maxsplit=1)
    cmd = parts[0].lower()
    if not cmd:
        return False
    return lookup(cmd) is not None


def _tool_audit_summary(session_key: str) -> dict[str, Any]:
    def _run() -> dict[str, Any]:
        events = get_tool_audit_events(limit=50, session_key=session_key)
        failed = [event for event in events if not event.get("ok", False)]
        codes = sorted({str(event.get("code")) for event in failed if event.get("code")})
        return {"total": len(events), "failed": len(failed), "codes": codes}

    return safe_best_effort(
        _run,
        label="handler_helpers.tool_audit_summary",
        default={"total": 0, "failed": 0, "codes": []},
    ) or {"total": 0, "failed": 0, "codes": []}


def _reset_tool_audit_events(session_key: str | None = None) -> None:
    def _run() -> None:
        reset_tool_audit_events(session_key)

    safe_best_effort(_run, label="handler_helpers.reset_tool_audit", default=None)


_WELCOMED_SESSIONS: set[str] = set()
_WELCOMED_LOCK = threading.Lock()

_WELCOME_TEXT = """你好,我是 Butler 管家(莎丽)。

建议三步上手:
1️⃣ /切换 <项目名> — 选定当前项目(附摘要)
2️⃣ 只读一句 — 例:「读取 README 前 10 行并摘要」
3️⃣ 试一次委派 — 例:「交给开发代理:只读检查 docs/ 目录」

日常:/简报 · /帮助(五个说法:查改批记管)
首周 playbook:docs/guides/owner-first-week-2026-06.md
分工与七步剧本:/分工 · /帮助"""

# User already asked for identity/capabilities — LLM reply supersedes static welcome.
_WELCOME_SUPERSEDED_PATTERNS: tuple[re.Pattern[str], ...] = (
    re.compile(r"介绍.{0,8}(自己|你)|自我介绍", re.I),
    re.compile(r"你是谁|你叫什么|什么名字|who\s+are\s+you", re.I),
    re.compile(r"(可以|能|会).{0,12}(干什么|做什么|帮我|什么)", re.I),
    re.compile(r"(什么|哪些).{0,8}(能力|功能|命令)", re.I),
    re.compile(r"^/?帮助$", re.I),
)


def _user_turn_supersedes_welcome(user_text: str) -> bool:
    text = (user_text or "").strip()
    if not text:
        return False
    return any(pat.search(text) for pat in _WELCOME_SUPERSEDED_PATTERNS)


def _mark_session_welcomed(session_key: str) -> None:
    """Persist first-contact marker without emitting welcome text."""
    with _WELCOMED_LOCK:
        if session_key in _WELCOMED_SESSIONS:
            return
        _WELCOMED_SESSIONS.add(session_key)

    def _persist() -> None:
        marker = Path(os.getenv("BUTLER_HOME", BUTLER_HOME_DEFAULT)).expanduser() / "welcomed_sessions.txt"
        if marker.is_file():
            known = set(marker.read_text(encoding="utf-8").strip().splitlines())
            if session_key in known:
                return
        marker.parent.mkdir(parents=True, exist_ok=True)
        with open(marker, "a", encoding="utf-8") as f:
            f.write(session_key + "\n")

    safe_best_effort(_persist, label="handler_helpers.mark_welcomed", default=None)


def _maybe_welcome_prefix(session_key: str, user_text: str = "") -> str:
    """Return welcome text for first-time sessions, empty string otherwise."""
    if os.getenv("BUTLER_ONBOARDING_WELCOME", ONBOARDING_WELCOME_DEFAULT).strip() == "0":
        return ""
    if _user_turn_supersedes_welcome(user_text):
        _mark_session_welcomed(session_key)
        return ""
    with _WELCOMED_LOCK:
        if session_key in _WELCOMED_SESSIONS:
            return ""
        _WELCOMED_SESSIONS.add(session_key)

    marker = Path(os.getenv("BUTLER_HOME", BUTLER_HOME_DEFAULT)).expanduser() / "welcomed_sessions.txt"
    def _known_sessions() -> set[str]:
        if not marker.is_file():
            return set()
        return set(marker.read_text(encoding="utf-8").strip().splitlines())

    if session_key in (safe_best_effort(
        _known_sessions,
        label="handler_helpers.welcome_known",
        default=set(),
    ) or set()):
        return ""

    def _persist() -> None:
        marker.parent.mkdir(parents=True, exist_ok=True)
        with open(marker, "a", encoding="utf-8") as f:
            f.write(session_key + "\n")

    safe_best_effort(_persist, label="handler_helpers.welcome_marker", default=None)
    return _WELCOME_TEXT


def _safe_overview_sub(fn: Callable[[], str | None], label: str) -> str | None:
    """Run a `_build_project_overview` sub-info extractor; swallow + log on failure."""
    return cast(
        str | None,
        safe_best_effort(fn, label=f"handler_helpers.overview.{label}", default=None),
    )


def _todos_subinfo(ws: Path) -> str | None:
    todos_path = ws / ".butler" / "todos.json"
    if not todos_path.is_file():
        return None
    todos_data = read_json_cached(todos_path)
    if not isinstance(todos_data, list):
        todos_data = []
    pending = [t for t in todos_data if t.get("status") == "pending"]
    if pending:
        return f"待办 {len(pending)} 项"
    return None


def _jobs_subinfo(ws: Path, project_name: str) -> str | None:
    jobs_path = ws / "runtime" / "jobs.yaml"
    if not jobs_path.is_file():
        return None
    if not runtime_enabled():
        return None
    job_rows = list_jobs_status(project_name)
    if job_rows:
        return f"定时任务 {len(job_rows)} 个"
    return None


def _summary_subinfo(ws: Path) -> str | None:
    summary_path = ws / ".butler" / "session_summary.json"
    if not summary_path.is_file():
        return None
    summary = read_json_cached(summary_path)
    if not isinstance(summary, dict):
        summary = {}
    turns = summary.get("turns", 0)
    if turns:
        return f"上次会话 {turns} 轮"
    return None


def _build_project_overview(orchestrator: Any, session_key: str) -> str:
    """Build a multi-project dashboard for /总览."""
    pm = orchestrator.project_manager
    projects = pm.list_projects()
    if not projects:
        return "暂无项目。"

    current = pm.resolve_active_project_name(session_key=session_key)
    lines = ["📋 项目总览", ""]

    for p in sorted(projects, key=lambda x: x.name):
        mark = "▸ " if p.name == current else "  "
        header = f"{mark}{p.name}"
        if p.name == current:
            header += "(当前)"

        sub: list[str] = []
        workspace = getattr(p, "workspace", None)

        if workspace:
            from pathlib import Path

            ws = Path(workspace)
            for label, msg in (
                ("todos", _safe_overview_sub(lambda: _todos_subinfo(ws), "todos")),
                ("jobs", _safe_overview_sub(lambda: _jobs_subinfo(ws, p.name), "jobs")),
                ("summary", _safe_overview_sub(lambda: _summary_subinfo(ws), "summary")),
            ):
                if msg:
                    sub.append(msg)
        pack = getattr(p, "pack", "") or ""
        ptype = p.type or ""
        desc_parts = [ptype]
        if pack:
            desc_parts.append(f"pack={pack}")
        if sub:
            desc_parts.append("|".join(sub))

        lines.append(f"{header}  [{', '.join(desc_parts)}]")
        if p.description:
            lines.append(f"    {p.description[:60]}")

    lines.append("")
    lines.append("提醒:")
    def _reminders() -> str:
        reminders = [r for r in _load_all() if r.get("status") == "pending"]
        if reminders:
            rows = [f"  待触发提醒 {len(reminders)} 个"]
            for r in reminders[:3]:
                rows.append(f"    · {r.get('due_human', '')} — {r.get('message', '')[:40]}")
            return "\n".join(rows)
        return "  无待触发提醒"

    reminder_block = safe_best_effort(
        _reminders,
        label="handler_helpers.reminders",
        default="  提醒系统不可用",
    )
    lines.append(reminder_block or "  提醒系统不可用")

    return "\n".join(lines)


def _inject_previous_session_summary(loop: "AgentLoop", project: Any) -> None:
    """Inject previous session summary into a new AgentLoop for context continuity."""
    def _run() -> None:
        if not env_truthy("BUTLER_SESSION_SUMMARY", default=True):
            return
        if project is None:
            return
        workspace = getattr(project, "workspace", None)
        if not workspace:
            return

        from pathlib import Path

        summary_path = Path(workspace) / ".butler" / "session_summary.json"
        if not summary_path.is_file():
            return

        raw = read_json_cached(summary_path)
        if not isinstance(raw, dict):
            return

        parts: list[str] = []
        proj_name = raw.get("project", "")
        turns = raw.get("turns", 0)
        if proj_name:
            parts.append(f"上次会话项目:{proj_name},{turns} 轮对话")
        for field, label in [
            ("persona", "用户偏好"),
            ("preference", "设置变更"),
            ("experience", "经验记录"),
        ]:
            items = raw.get(field) or []
            if items:
                parts.append(f"{label}:" + ";".join(str(i) for i in items[:5]))

        if not parts:
            return

        context = "[上次会话摘要]\n" + "\n".join(parts) + "\n[/上次会话摘要]"
        if hasattr(loop, "_messages"):
            loop._messages.append({"role": "system", "content": context})
            logger.debug("Injected previous session summary (%d chars)", len(context))

    safe_best_effort(_run, label="handler_helpers.session_summary_inject", default=None)


def _on_gateway_session_removed(session_key: str) -> None:
    _reset_tool_audit_events(session_key)
    def _disconnect() -> None:
        disconnect_mcp_session(session_key)

    safe_best_effort(_disconnect, label="handler_helpers.mcp_disconnect", default=None)