Ctrl K
ComunidadGuías…

butler/tools/orthogonality_lint.py

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

Archivo original ↗Preguntar a KiBot
"""Builtin tool orthogonality lint — warn on overlapping descriptions within toolsets."""

from __future__ import annotations

from typing import Any, cast

# Builtin search tools intentionally overlap MCP scrape/fetch (layered fallback).
_BENIGN_SEARCH_TOOLS = frozenset({"web_search", "web_fetch"})


def _tool_name(defn: dict[str, Any]) -> str:
    fn_raw = defn.get("function")
    fn = fn_raw if isinstance(fn_raw, dict) else {}
    return str(fn.get("name") or defn.get("name") or "").strip()


def _tool_description(defn: dict[str, Any]) -> str:
    fn_raw = defn.get("function")
    fn = fn_raw if isinstance(fn_raw, dict) else {}
    return str(fn.get("description") or "").strip().lower()


def _tool_text(defn: dict[str, Any]) -> str:
    """Embedding text for orthogonality: description only (names are fixed API)."""
    return _tool_description(defn)


def _toolset_for(name: str) -> str:
    from butler.tools.orthogonality_lint_ops import toolset_for_safe

    ts = toolset_for_safe(name)
    if ts:
        return cast(str, ts)
    if name.startswith("mcp_"):
        parts = name.split("_", 2)
        if len(parts) >= 2:
            return f"mcp:{parts[1]}"
    return "default"


def _is_benign_overlap(name_a: str, name_b: str) -> bool:
    """Suppress expected builtin↔MCP search/scrape redundancy in /诊断."""
    a = str(name_a or "").lower()
    b = str(name_b or "").lower()
    names = {a, b}
    if names & _BENIGN_SEARCH_TOOLS:
        for n in names - _BENIGN_SEARCH_TOOLS:
            if "firecrawl" in n or n.startswith("mcp_fetch"):
                return True
    return False


def _canonical_pair(name_a: str, name_b: str) -> tuple[str, str]:
    return (min(name_a, name_b), max(name_a, name_b))


def _collect_tool_vectors(
    defs: list[dict[str, Any]],
    embedder: Any,
) -> list[tuple[str, str, list[float]]]:
    out: list[tuple[str, str, list[float]]] = []
    for defn in defs:
        name = _tool_name(defn)
        if not name:
            continue
        ts = _toolset_for(name)
        from butler.tools.orthogonality_lint_ops import embed_tool_text_safe

        vec = embed_tool_text_safe(embedder, _tool_text(defn))
        if vec is None:
            continue
        out.append((name, ts, vec))
    return out


def _lint_tool_vectors(
    items: list[tuple[str, str, list[float]]],
    *,
    threshold: float,
    max_pairs: int,
    cross_toolset: bool,
) -> list[str]:
    from butler.memory.embedding import cosine_similarity

    issues: list[tuple[float, str]] = []
    seen: set[tuple[str, str]] = set()
    for i in range(len(items)):
        n1, ts1, v1 = items[i]
        for j in range(i + 1, len(items)):
            n2, ts2, v2 = items[j]
            if not cross_toolset and ts1 != ts2:
                continue
            pair = _canonical_pair(n1, n2)
            if pair in seen:
                continue
            if _is_benign_overlap(n1, n2):
                continue
            sim = cosine_similarity(v1, v2)
            if sim >= threshold:
                seen.add(pair)
                scope = "cross" if ts1 != ts2 else ts1
                issues.append(
                    (
                        sim,
                        f"{scope}: {n1} ↔ {n2} cosine={sim:.3f} (≥{threshold})",
                    )
                )
    issues.sort(key=lambda x: x[0], reverse=True)
    return [line for _, line in issues[:max_pairs]]


def lint_builtin_tool_orthogonality(
    *,
    threshold: float = 0.82,
    max_pairs: int = 12,
) -> list[str]:
    """Return warning lines for semantically overlapping builtin tools."""
    from butler.tools.orthogonality_lint_ops import load_embedder_for_lint_safe
    from butler.tools.registry import get_tool_definitions

    embedder, skip = load_embedder_for_lint_safe()
    if embedder is None:
        return [skip or "orthogonality lint skipped: embedder unavailable"]
    if embedder.model_id.startswith("hashing"):
        return ["orthogonality lint skipped: hashing embedder (set real embedding model)"]

    defs = get_tool_definitions()
    items = _collect_tool_vectors(defs, embedder)
    return _lint_tool_vectors(items, threshold=threshold, max_pairs=max_pairs, cross_toolset=False)


def lint_tool_orthogonality_for_diagnostics(
    *,
    session_key: str = "",
    threshold: float = 0.82,
    max_pairs: int = 2,
) -> list[str]:
    """Builtin + MCP overlap warnings for /诊断 (deduped, noise-reduced)."""
    from butler.tools.orthogonality_lint_ops import (
        get_builtin_tool_definitions_safe,
        get_mcp_tool_definitions_safe,
        load_embedder_for_diagnostics_safe,
    )

    embedder = load_embedder_for_diagnostics_safe()
    if embedder is None:
        return []
    if embedder.model_id.startswith("hashing"):
        return []

    defs: list[dict[str, Any]] = list(get_builtin_tool_definitions_safe())

    mcp_defs = get_mcp_tool_definitions_safe(session_key)
    known = {_tool_name(d) for d in defs}
    for defn in mcp_defs:
        name = _tool_name(defn)
        if name and name not in known:
            defs.append(defn)

    if len(defs) < 2:
        return []

    items = _collect_tool_vectors(defs, embedder)
    return _lint_tool_vectors(
        items,
        threshold=threshold,
        max_pairs=max_pairs,
        cross_toolset=True,
    )


def format_orthogonality_report(issues: list[str]) -> str:
    if not issues:
        return "Builtin tool orthogonality: OK (no high-overlap pairs)"
    lines = ["Builtin tool orthogonality warnings:"]
    lines.extend(f"  - {i}" for i in issues)
    return "\n".join(lines)


__all__ = [
    "format_orthogonality_report",
    "lint_builtin_tool_orthogonality",
    "lint_tool_orthogonality_for_diagnostics",
]