Files
ss-tools/backend/src/agent/_embedding_router.py
busya 12118ac4ec fix(security): resolve Critical+High findings from module audit — agent, translate, superset_client
P0 — CRITICAL (CWE-798): JWT_SECRET crash-early
  Replace hardcoded super-secret-key fallback with os.environ["JWT_SECRET"]
  and ${JWT_SECRET:?} syntax in app.py + docker-compose files

P1 — HIGH: Frontend dependency CVEs
  Upgrade svelte 5.43.8 → 5.56.4 — resolves devalue DoS (GHSA-g2pg-6438-jwpf)
  and svelte XSS (GHSA-crpf-4hrx-3jrp, GHSA-m56q-vw4c-c2cp, GHSA-rcqx-6q8c-2c42)

P2 — MEDIUM: Logging hygiene + contract gaps + tool resolver refactor
  Apply _redact_sensitive_fields() in middleware + event streaming
  Truncate LLM error body to 100 chars
  Add @RATIONALE/@REJECTED to HandleResume + SaveConversation
  Refactor deterministic intent matching → LLM-driven tool resolution

P3 — LOW: Translate logging hardening
  Move _sanitize_url() to _utils.py (shared, no circular imports)
  Sanitize base_url before logging in _llm_call.py and _llm_async_http.py
  Emit EXPLORE warning when LLM_SSL_VERIFY=false disables TLS

superset_client module: passed clean — no changes needed
2026-07-01 13:17:29 +03:00

156 lines
6.0 KiB
Python

# backend/src/agent/_embedding_router.py
# #region AgentChat.EmbeddingRouter [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,embedding,fallback]
# @defgroup AgentChat Embedding-based tool router — fallback when keyword matching yields <3 tools.
# @LAYER Service
# @BRIEF Lazy-loaded embedding model for cosine similarity between user query and tool descriptions.
# @RATIONALE Tool descriptions are auto-generated from @tool docstrings (enforced by LangChain).
# Optional _TOOL_DESCRIPTIONS_OVERRIDES in tools.py provides RU/EN synonyms for
# tools where the docstring alone isn't descriptive enough. This eliminates the
# hardcoded _TOOL_DESCRIPTIONS dict as a separate source of truth.
# @REJECTED Hardcoded _TOOL_DESCRIPTIONS dict — drifts out of sync with get_all_tools().
# @INVARIANT Descriptions are derived from get_all_tools() docstrings — always 1:1 with tools.
# @INVARIANT embedding_top_k() returns empty list (never raises) when model unavailable.
import logging
import os
from typing import Optional
logger = logging.getLogger("superset_tools_app")
# ═══════════════════════════════════════════════════════════════════
# Tool description — auto-generated from @tool docstrings.
# Override via _TOOL_DESCRIPTIONS_OVERRIDES in tools.py for RU/EN synonyms.
# ═══════════════════════════════════════════════════════════════════
def _get_descriptions() -> tuple[list[str], list[str]]:
"""Return (descriptions, tool_names) from get_all_tools() docstrings.
Uses _TOOL_DESCRIPTIONS_OVERRIDES from tools.py for optional RU/EN synonyms.
"""
from src.agent.tools import get_all_tools, _TOOL_DESCRIPTIONS_OVERRIDES
all_tools = get_all_tools()
names = []
descriptions = []
for tool_obj in all_tools:
name = tool_obj.name
names.append(name)
desc = _TOOL_DESCRIPTIONS_OVERRIDES.get(name) or (tool_obj.description or "").strip()
if not desc:
desc = name # fallback: use tool name as description
descriptions.append(desc)
return descriptions, names
# ═══════════════════════════════════════════════════════════════════
# Model state — lazy-loaded on first call
# ═══════════════════════════════════════════════════════════════════
_embedding_model: Optional[object] = None
_tool_embeddings: Optional[object] = None # torch.Tensor or numpy array
_tool_names: list[str] = []
_THRESHOLD = float(os.getenv("EMBEDDING_SIMILARITY_THRESHOLD", "0.65"))
_TOP_K = int(os.getenv("EMBEDDING_TOP_K", "5"))
_MODEL_NAME = os.getenv(
"EMBEDDING_MODEL",
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
)
def _load_model() -> bool:
"""Lazy-load the embedding model and pre-embed tool descriptions.
Returns True on success, False on any failure (missing package, download error,
OOM, etc.). On failure, the router degrades gracefully — embedding_top_k()
returns an empty list and the caller falls back to keyword-only results.
"""
global _embedding_model, _tool_embeddings, _tool_names
if _embedding_model is not None:
return True
try:
from sentence_transformers import SentenceTransformer
except ImportError:
logger.warning(
"sentence-transformers not installed — embedding router disabled. "
"Install with: pip install sentence-transformers"
)
return False
try:
logger.info("Loading embedding model: %s", _MODEL_NAME)
_embedding_model = SentenceTransformer(_MODEL_NAME)
descriptions, _tool_names[:] = _get_descriptions()
_tool_embeddings = _embedding_model.encode(
descriptions,
convert_to_tensor=True,
show_progress_bar=False,
)
logger.info(
"Embedding model loaded. Tools: %d, model: %s",
len(_tool_names), _MODEL_NAME,
)
return True
except Exception as exc:
logger.warning(
"Failed to load embedding model '%s': %s — embedding router disabled",
_MODEL_NAME, exc,
)
_embedding_model = None
return False
def embedding_top_k(query: str) -> list[str]:
"""Return top-K tool names above cosine similarity threshold.
Args:
query: Raw user query text (any language).
Returns:
List of tool name strings ordered by descending similarity.
Empty list if model unavailable, no descriptions match, or an error occurs.
"""
if not query or not query.strip():
return []
if not _load_model():
return []
try:
from sentence_transformers.util import cos_sim
query_embedding = _embedding_model.encode(
query,
convert_to_tensor=True,
show_progress_bar=False,
)
similarities = cos_sim(query_embedding, _tool_embeddings)[0]
results: list[str] = []
for idx in similarities.argsort(descending=True)[:_TOP_K]:
score = float(similarities[idx])
if score >= _THRESHOLD:
results.append(_tool_names[idx])
if results:
logger.debug(
"Embedding router: query='%s'%d tools above %.2f: %s",
query[:80], len(results), _THRESHOLD, results,
)
return results
except Exception as exc:
logger.warning("Embedding router failed for query '%s': %s", query[:80], exc)
return []
def embedding_is_available() -> bool:
"""Check if embedding model is loaded and ready (non-blocking)."""
return _load_model()
# #endregion AgentChat.EmbeddingRouter