refactor(agent): extract agent+shared into standalone packages with full GRACE semantic markup

- Move agent code from backend/src/agent/ to agent/src/ss_tools/agent/
- Extract shared stdlib-only utilities to shared/src/ss_tools/shared/
- Add #region/#endregion contracts to all ~140 functions (INV_1 compliance)
- Update docker files, entrypoint, build scripts for new package layout
- Backend now imports ss_tools.shared._llm_health (no gradio/langchain deps)
- Add specs for 036-039 feature plans
This commit is contained in:
2026-07-07 15:18:24 +03:00
parent ce368429f7
commit b95df37cd5
75 changed files with 2711 additions and 1861 deletions

19
agent/pyproject.toml Normal file
View File

@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "ss-tools-agent"
version = "0.1.0"
description = "Gradio/LangGraph agent for superset-tools assistant chat"
requires-python = ">=3.11"
[project.optional-dependencies]
embeddings = ["sentence-transformers>=2.2.0", "torch>=2.0.0"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.setuptools.packages.find]
where = ["src"]
include = ["ss_tools.agent*"]

View File

@@ -0,0 +1,3 @@
# Optional: semantic embedding routing for agent tool selection
sentence-transformers>=2.2.0
torch>=2.0.0

View File

@@ -1,4 +1,6 @@
# Agent runtime: Gradio + LangGraph agent (no embedding model by default)
# Shared package install: pip install -e /app/shared/ (Docker) or pip install -e ../shared (dev)
# Note: shared package MUST be installed BEFORE these dependencies in Docker builds
anyio>=4.12.0
certifi>=2025.11.12
h11>=0.16.0
@@ -23,14 +25,14 @@ langchain-openai>=0.3
langgraph>=0.2
langgraph-checkpoint-postgres
# OpenAI SDK (explicit — needed by langchain-openai)
# OpenAI SDK
openai>=1.0.0
# Document parsing (uploaded files)
# Document parsing
pdfplumber
openpyxl
# DB for LangGraph checkpoint persistence (langgraph-checkpoint-postgres uses psycopg v3)
# DB for LangGraph checkpoint
psycopg2-binary
psycopg>=3.1
@@ -38,6 +40,5 @@ psycopg>=3.1
tenacity>=8.0.0
requests>=2.32.0
# JWT token validation (used by src.core.auth.jwt.decode_token)
# JWT token validation
python-jose[cryptography]

View File

@@ -1,4 +1,4 @@
# backend/src/agent/__init__.py
# agent/src/ss_tools/agent/__init__.py
# #region AgentChat [C:3] [TYPE Module] [SEMANTICS agent-chat]
# @defgroup AgentChat LangGraph-based Gradio agent — streaming chat with HITL guardrails.
# @LAYER Application

View File

@@ -1,4 +1,4 @@
# backend/src/agent/_config.py
# agent/src/ss_tools/agent/_config.py
# #region AgentChat.Config [C:2] [TYPE Module] [SEMANTICS agent-chat,config,env]
# @ingroup AgentChat
# @BRIEF Centralized env-var reads for agent services. Read once, import everywhere.
@@ -7,24 +7,13 @@
# ensures consistent defaults across the agent module.
import os
# ── FastAPI backend URL ──────────────────────────────────────────
FASTAPI_URL: str = os.getenv("FASTAPI_URL", "http://localhost:8000")
# ── Service-to-service JWT (agent → FastAPI auth) ────────────────
SERVICE_JWT: str = os.getenv("SERVICE_JWT", "")
# ── Gradio server ────────────────────────────────────────────────
GRADIO_SERVER_NAME: str = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0")
GRADIO_SERVER_PORT: int = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
GRADIO_ALLOW_PORT_FALLBACK: bool = os.getenv("GRADIO_ALLOW_PORT_FALLBACK", "").strip().lower() in {"1", "true", "yes"}
# ── File storage ─────────────────────────────────────────────────
STORAGE_ROOT: str = os.getenv("STORAGE_ROOT", "/app/storage")
# ── Prefetch ─────────────────────────────────────────────────────
AGENT_PREFETCH_DASHBOARD_LIMIT: int = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25"))
# ── HITL (Human-in-the-Loop) ────────────────────────────────────
AGENT_CONFIRM_TOOLS: bool = os.getenv("AGENT_CONFIRM_TOOLS", "").strip().lower() in ("true", "1", "yes")
AGENT_INTERRUPT_BEFORE: str = os.getenv("AGENT_INTERRUPT_BEFORE", "")
# #endregion AgentChat.Config

View File

@@ -1,4 +1,4 @@
# backend/src/agent/_confirmation.py
# agent/src/ss_tools/agent/_confirmation.py
# #region AgentChat.Confirmation [C:3] [TYPE Module] [SEMANTICS agent-chat,hitl,confirmation,resume]
# @defgroup AgentChat HITL confirmation contract builder and resume handler.
# @LAYER Service
@@ -12,14 +12,14 @@ from typing import Any
from langchain_openai import ChatOpenAI
from src.agent._llm_params import chat_openai_kwargs
from src.agent._tool_resolver import (
from ss_tools.agent._llm_params import chat_openai_kwargs
from ss_tools.agent._tool_resolver import (
extract_tool_call_from_state,
find_tool,
normalize_tool_args,
)
from src.agent.langgraph_setup import create_agent
from src.agent.tools import get_all_tools
from ss_tools.agent.langgraph_setup import create_agent
from ss_tools.agent.tools import get_all_tools
_pending_confirmations: dict[str, dict[str, Any]] = {}
@@ -118,14 +118,14 @@ def build_confirmation_contract_v2(
env_tier = _resolve_env_tier(tool_args, target_env)
# 3. Permission check
from src.agent._tool_filter import enforce_tool_permission
from ss_tools.agent._tool_filter import enforce_tool_permission
permission_granted = enforce_tool_permission(operation, user_role)
# Build alternatives for denied ops
alternatives = None
required_role = None
if not permission_granted:
from src.agent._tool_filter import _TOOL_PERMISSIONS
from ss_tools.agent._tool_filter import _TOOL_PERMISSIONS
required_roles = _TOOL_PERMISSIONS.get(operation, ["admin"])
required_role = required_roles[0] if required_roles else "admin"
if risk_level != "safe":
@@ -270,8 +270,8 @@ def confirmation_payload(
async def _format_tool_output_via_llm(
tool_name: str, output: str,
) -> AsyncGenerator[str]:
from src.agent.langgraph_setup import _fetch_llm_config
from src.core.logger import logger
from ss_tools.agent.langgraph_setup import _fetch_llm_config
from ss_tools.shared.logger import logger
text = output.strip()
if not text:
@@ -352,8 +352,8 @@ async def handle_resume( # noqa: C901
conversation_id: str, action: str,
user_jwt: str = "", env_id: str | None = None,
) -> AsyncGenerator[str]:
from src.agent.context import set_user_jwt
from src.core.logger import logger
from ss_tools.agent.context import set_user_jwt
from ss_tools.shared.logger import logger
set_user_jwt(user_jwt)
pending = _pending_confirmations.pop(conversation_id, None)

View File

@@ -0,0 +1,132 @@
# agent/src/ss_tools/agent/_context.py
# #region AgentChat.Context.Validate [C:3] [TYPE Module] [SEMANTICS agent-chat,context,validate,security]
# @BRIEF UIContext validation and prompt-injection protection.
# @LAYER Service
# @POST Passes through contextVersion, objectType, objectId, objectName, envId, route, padding.
# @INVARIANT contextVersion must be 1 or absent (defaults to 1).
# @INVARIANT Serialized payload must not exceed 4096 bytes.
import json
ALLOWED_OBJECT_TYPES: frozenset = frozenset({"dashboard", "dataset", "migration"})
_MAX_PAYLOAD_BYTES = 4096
_MAX_OBJECT_NAME_LENGTH = 256
_MAX_ROUTE_LENGTH = 512
# #region AgentChat.Context.Validate.Error [C:1] [TYPE Class] [SEMANTICS agent-chat,context,error]
# @BRIEF Raised when a UIContext payload fails validation.
class UIContextValidationError(ValueError):
pass
# #endregion AgentChat.Context.Validate.Error
# #region AgentChat.Context.Validate.CheckObjectType [C:1] [TYPE Function] [SEMANTICS agent-chat,context,validate,type]
# @ingroup AgentChat
# @BRIEF Validate objectType is in ALLOWED_OBJECT_TYPES.
def _check_object_type(value: str | None) -> None:
if value is not None and value not in ALLOWED_OBJECT_TYPES:
raise UIContextValidationError(
f"UIContext: invalid objectType '{value}'"
f" — must be one of {ALLOWED_OBJECT_TYPES}"
)
# #endregion AgentChat.Context.Validate.CheckObjectType
# #region AgentChat.Context.Validate.CheckObjectId [C:1] [TYPE Function] [SEMANTICS agent-chat,context,validate,id]
# @ingroup AgentChat
# @BRIEF Validate objectId is a numeric string.
def _check_object_id(value: str | None) -> None:
if value is not None and not (isinstance(value, str) and value.isdigit()):
raise UIContextValidationError(f"UIContext: invalid objectId '{value}'")
# #endregion AgentChat.Context.Validate.CheckObjectId
# #region AgentChat.Context.Validate.CheckObjectName [C:1] [TYPE Function] [SEMANTICS agent-chat,context,validate,name]
# @ingroup AgentChat
# @BRIEF Validate objectName length ≤256 chars.
def _check_object_name(value: str | None) -> None:
if value is None:
return
if not isinstance(value, str):
raise UIContextValidationError(f"UIContext: invalid objectName '{value}'")
if len(value) > _MAX_OBJECT_NAME_LENGTH:
raise UIContextValidationError("UIContext: objectName exceeds 256 characters")
# #endregion AgentChat.Context.Validate.CheckObjectName
# #region AgentChat.Context.Validate.CheckEnvId [C:1] [TYPE Function] [SEMANTICS agent-chat,context,validate,env]
# @ingroup AgentChat
# @BRIEF Validate envId is a string or None.
def _check_env_id(value: str | None) -> None:
if value is not None and not isinstance(value, str):
raise UIContextValidationError(f"UIContext: invalid envId '{value}'")
# #endregion AgentChat.Context.Validate.CheckEnvId
# #region AgentChat.Context.Validate.CheckRoute [C:1] [TYPE Function] [SEMANTICS agent-chat,context,validate,route]
# @ingroup AgentChat
# @BRIEF Validate route is a string ≤512 chars.
def _check_route(value: str) -> None:
if not isinstance(value, str):
raise UIContextValidationError(f"UIContext: invalid route '{value}' — must be a string")
if len(value) > _MAX_ROUTE_LENGTH:
raise UIContextValidationError("UIContext: route exceeds 512 characters")
# #endregion AgentChat.Context.Validate.CheckRoute
# #region AgentChat.Context.Validate.CheckContextVersion [C:1] [TYPE Function] [SEMANTICS agent-chat,context,validate,version]
# @ingroup AgentChat
# @BRIEF Validate contextVersion is 1.
def _check_context_version(value: int | None) -> None:
if value is None:
raise UIContextValidationError("UIContext: contextVersion is required")
if value != 1:
raise UIContextValidationError(f"UIContext: unsupported contextVersion '{value}'")
# #endregion AgentChat.Context.Validate.CheckContextVersion
# #region AgentChat.Context.Validate.CheckPayloadSize [C:1] [TYPE Function] [SEMANTICS agent-chat,context,validate,size]
# @ingroup AgentChat
# @BRIEF Validate serialized payload ≤4096 bytes (prompt injection defense).
def _check_payload_size(raw: dict) -> None:
serialized = json.dumps(raw, ensure_ascii=False, default=str)
if len(serialized.encode("utf-8")) > _MAX_PAYLOAD_BYTES:
raise UIContextValidationError(
f"UIContext: payload exceeds {_MAX_PAYLOAD_BYTES // 1024} KB limit"
)
# #endregion AgentChat.Context.Validate.CheckPayloadSize
# #region AgentChat.Context.Validate.Validate [C:2] [TYPE Function] [SEMANTICS agent-chat,context,validate]
# @ingroup AgentChat
# @BRIEF Validate and pass through a UIContext payload with security checks.
# @PRE raw is a dict or None.
# @POST Returns validated dict preserving all input fields.
# @POST Raises UIContextValidationError on invalid input.
# @INVARIANT contextVersion must be 1. Payload size ≤ 4KB.
def validate_uicontext(raw: dict) -> dict:
"""Validate and pass through a UIContext payload.
Preserves all input fields. Validates known fields for type/length constraints
and rejects oversized payloads (>4KB) to prevent prompt injection.
"""
if raw is None:
return {}
# Validate payload size FIRST (before field extraction) — reject oversized
# payloads to prevent prompt injection via large text fields.
_check_payload_size(raw)
validated = dict(raw) # Preserve ALL input fields including contextVersion
# Validate known fields
_check_context_version(validated.get("contextVersion"))
_check_object_type(validated.get("objectType"))
_check_object_id(validated.get("objectId"))
_check_object_name(validated.get("objectName"))
_check_env_id(validated.get("envId"))
_check_route(validated.get("route", ""))
return validated
# #endregion AgentChat.Context.Validate.Validate
# #endregion AgentChat.Context.Validate

View File

@@ -0,0 +1,95 @@
# agent/src/ss_tools/agent/_embedding_router.py
# #region AgentChat.EmbeddingRouter [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,embedding,fallback]
# @BRIEF Embedding-based tool router — fallback when keyword matching yields <3 tools.
# @LAYER Service
import logging
import os
logger = logging.getLogger("superset_tools_app")
# #region AgentChat.EmbeddingRouter.GetDescriptions [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,embedding,helper]
# @ingroup AgentChat
# @BRIEF Collect tool names and descriptions from tool registry.
def _get_descriptions() -> tuple[list[str], list[str]]:
from ss_tools.agent.tools import _TOOL_DESCRIPTIONS_OVERRIDES, get_all_tools
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
descriptions.append(desc)
return descriptions, names
# #endregion AgentChat.EmbeddingRouter.GetDescriptions
_embedding_model: object | None = None
_tool_embeddings: object | None = None
_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",
)
# #region AgentChat.EmbeddingRouter.LoadModel [C:3] [TYPE Function] [SEMANTICS agent-chat,embedding,model,load]
# @ingroup AgentChat
# @BRIEF Lazy-load sentence-transformers embedding model, encode tool descriptions.
def _load_model() -> bool:
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.")
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", _MODEL_NAME, exc)
_embedding_model = None
return False
# #endregion AgentChat.EmbeddingRouter.LoadModel
# #region AgentChat.EmbeddingRouter.TopK [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,embedding,fallback,topk]
# @ingroup AgentChat
# @BRIEF Find top-K tools by semantic similarity to query, above threshold.
def embedding_top_k(query: str, k: int | None = None) -> list[str]:
if not _load_model():
return []
if _tool_embeddings is None or not _tool_names:
return []
k = k or _TOP_K
try:
import torch
except ImportError:
return []
try:
from sentence_transformers.util import semantic_search
except ImportError:
return []
try:
query_emb = _embedding_model.encode(query, convert_to_tensor=True)
hits = semantic_search(query_emb, _tool_embeddings, top_k=k)
return [_tool_names[hit["corpus_id"]] for hit in hits[0] if hit["score"] >= _THRESHOLD]
except Exception:
return []
# #endregion AgentChat.EmbeddingRouter.TopK
# #endregion AgentChat.EmbeddingRouter

View File

@@ -0,0 +1,30 @@
# agent/src/ss_tools/agent/_jwt_decoder.py
# #region AgentChat.JwtDecoder [C:1] [TYPE Module] [SEMANTICS agent-chat,jwt,decode]
# @BRIEF Lightweight JWT decode for agent — uses AUTH_SECRET_KEY env var, avoids
# pulling backend jwt module which requires AUTH_DATABASE_URL and ORM deps.
# @RATIONALE The agent only needs stateless JWT validation (exp, sub, signature).
# @INVARIANT AUTH_SECRET_KEY is the ONLY accepted JWT signing key.
# @REJECTED Importing backend jwt was rejected — it drags in SQLAlchemy models.
import os
from jose import JWTError, jwt
def decode_token(token: str) -> dict:
secret = os.getenv("AUTH_SECRET_KEY", "")
jwt_secret_legacy = os.getenv("JWT_SECRET", "")
if not secret:
if jwt_secret_legacy:
raise JWTError("JWT_SECRET is no longer supported. Rename JWT_SECRET to AUTH_SECRET_KEY in your .env / docker-compose and restart the agent.")
raise JWTError("AUTH_SECRET_KEY environment variable is not set")
return jwt.decode(
token,
secret,
algorithms=[os.getenv("JWT_ALGORITHM", "HS256")],
options={
"verify_signature": True,
"verify_exp": True,
"verify_aud": False,
"require": ["exp", "sub"],
},
)
# #endregion AgentChat.JwtDecoder

View File

@@ -1,13 +1,7 @@
# backend/src/agent/_llm_params.py
# agent/src/ss_tools/agent/_llm_params.py
# #region AgentChat.LlmParams [C:3] [TYPE Module] [SEMANTICS agent-chat,llm,openai,compatibility]
# @defgroup AgentChat Shared LLM parameter compatibility helpers.
# @LAYER Service
# @BRIEF Build provider-safe ChatOpenAI kwargs and raw OpenAI payloads.
# @POST Unsupported sampling parameters are omitted for reasoning/codex models.
# @RATIONALE Some OpenAI-compatible gateways reject temperature for reasoning/codex
# models. Centralising the guard prevents resume, health-check, and title
# generation paths from diverging.
from typing import Any
_TEMPERATURE_UNSUPPORTED_PREFIXES = (
@@ -20,22 +14,31 @@ _TEMPERATURE_UNSUPPORTED_PREFIXES = (
)
# #region AgentChat.LlmParams.CanonicalModelName [C:1] [TYPE Function] [SEMANTICS agent-chat,llm,model,canonical]
# @ingroup AgentChat
# @BRIEF Strip provider prefix from model name for compatibility checks.
def _canonical_model_name(model: str | None) -> str:
"""Return provider-stripped, lowercase model name for compatibility checks."""
name = (model or "").strip().lower()
if name.startswith(("codex/", "omni/codex/")):
return name
if "/" in name:
return name.rsplit("/", 1)[-1]
return name
# #endregion AgentChat.LlmParams.CanonicalModelName
# #region AgentChat.LlmParams.SupportsTemperature [C:1] [TYPE Function] [SEMANTICS agent-chat,llm,temperature,check]
# @ingroup AgentChat
# @BRIEF Check if a model supports temperature parameter (reasoning/codex models don't).
def supports_temperature(model: str | None) -> bool:
"""Return False for model families whose APIs reject temperature."""
name = _canonical_model_name(model)
return not any(name.startswith(prefix) for prefix in _TEMPERATURE_UNSUPPORTED_PREFIXES)
# #endregion AgentChat.LlmParams.SupportsTemperature
# #region AgentChat.LlmParams.ChatOpenAIKwargs [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,openai,kwargs]
# @ingroup AgentChat
# @BRIEF Build ChatOpenAI constructor kwargs with provider-safe parameters.
def chat_openai_kwargs(
*,
model: str,
@@ -44,7 +47,6 @@ def chat_openai_kwargs(
max_tokens: int,
temperature: float = 0,
) -> dict[str, Any]:
"""Build ChatOpenAI kwargs without unsupported temperature for reasoning models."""
kwargs: dict[str, Any] = {
"model": model,
"base_url": base_url,
@@ -54,16 +56,20 @@ def chat_openai_kwargs(
if supports_temperature(model):
kwargs["temperature"] = temperature
return kwargs
# #endregion AgentChat.LlmParams.ChatOpenAIKwargs
# #region AgentChat.LlmParams.AddTemperature [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,payload,temperature]
# @ingroup AgentChat
# @BRIEF Conditionally add temperature to raw OpenAI payload dict.
def add_temperature_if_supported(
payload: dict[str, Any],
*,
model: str | None,
temperature: float = 0,
) -> dict[str, Any]:
"""Mutate and return payload with temperature only when the model supports it."""
if supports_temperature(model):
payload["temperature"] = temperature
return payload
# #endregion AgentChat.LlmParams.AddTemperature
# #endregion AgentChat.LlmParams

View File

@@ -0,0 +1,292 @@
# agent/src/ss_tools/agent/_persistence.py
# #region AgentChat.Persistence [C:3] [TYPE Module] [SEMANTICS agent-chat,persistence,save,prefetch,title]
# @BRIEF Conversation persistence helpers — save, clean titles, LLM title generation, prefetch.
# @LAYER Service
import asyncio
from datetime import datetime
import os
import re
from typing import Any
import uuid
import httpx
from ss_tools.agent._config import AGENT_PREFETCH_DASHBOARD_LIMIT as _PREFETCH_LIMIT, FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT
from ss_tools.agent._llm_params import add_temperature_if_supported
from ss_tools.shared.logger import logger
SAVE_API_URL = FASTAPI_URL + "/api/agent/conversations/save"
TITLE_MAX_LENGTH = 80
# #region AgentChat.Persistence.CleanTitle [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,title,clean]
# @ingroup AgentChat
# @BRIEF Clean user text into a conversation title — strip file markers, truncate, detect code/URL prefixes.
def clean_title(user_text: str) -> str:
if not user_text or not user_text.strip():
return "Новый диалог"
text = user_text.strip()
if text.startswith("") or text.startswith("⏹️ "):
return text[:TITLE_MAX_LENGTH]
file_markers = ["\n--- Uploaded file content ---", "--- Uploaded file content ---", "\n[PRE-FETCHED DATA", "[PRE-FETCHED DATA", "\n[/PRE-FETCHED DATA]", "[/PRE-FETCHED DATA]"]
cut_pos = len(text)
for marker in file_markers:
pos = text.find(marker)
if pos != -1 and pos < cut_pos:
cut_pos = pos
if cut_pos < len(text):
text = text[:cut_pos].strip()
if not text:
return "Новый диалог"
sentence_end = -1
for m in re.finditer(r"[.!?]\s", text):
sentence_end = m.start()
break
if sentence_end > 3:
text = text[: sentence_end + 1].strip()
elif "\n" in text:
text = text.split("\n")[0].strip()
if not text:
return "Новый диалог"
if text.startswith("{") or text.startswith("["):
prefix = "Данные: "
inner = text[1:57].strip().rstrip(",")
return prefix + inner + ("" if len(text) > 60 else "")
if text.startswith("http://") or text.startswith("https://"):
try:
from urllib.parse import urlparse
domain = urlparse(text).netloc or "ссылка"
except Exception:
domain = "ссылка"
return domain
if any(text.startswith(kw) for kw in ("def ", "class ", "import ", "from ")):
first_line = text.split("\n")[0].strip()
return first_line[:TITLE_MAX_LENGTH]
if len(text) > TITLE_MAX_LENGTH:
cut = text.rfind(" ", 0, TITLE_MAX_LENGTH)
if cut == -1:
cut = TITLE_MAX_LENGTH - 1
text = text[:cut].rstrip(".,;:!?") + ""
if not text.strip():
return "Новый диалог"
return text
# #endregion AgentChat.Persistence.CleanTitle
# #region AgentChat.Persistence.DetectMessageState [C:1] [TYPE Function] [SEMANTICS agent-chat,persistence,state,detect]
# @ingroup AgentChat
# @BRIEF Detect error/cancelled state from message text for conversation metadata.
def detect_message_state(text: str) -> str | None:
t = text.lower() if text else ""
error_markers = ["недоступен", "unavailable", "ошибка", "error", "произошла", "try again"]
cancel_markers = ["отменен", "cancelled", "отклонен", "denied"]
if any(m in t for m in cancel_markers):
return "cancelled"
if any(m in t for m in error_markers):
return "error"
return None
# #endregion AgentChat.Persistence.DetectMessageState
# #region AgentChat.Persistence.ExtractUserId [C:1] [TYPE Function] [SEMANTICS agent-chat,persistence,user,extract]
# @ingroup AgentChat
# @BRIEF Extract user ID (sub claim) from a JWT token string.
def extract_user_id(jwt_str: str) -> str:
try:
from ss_tools.agent._jwt_decoder import decode_token
payload = decode_token(jwt_str)
return payload.get("sub", payload.get("user_id", "unknown"))
except Exception:
return "unknown"
# #endregion AgentChat.Persistence.ExtractUserId
_title_locks: dict[str, asyncio.Lock] = {}
# #region AgentChat.Persistence.GetLlmConfig [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,llm,config]
# @ingroup AgentChat
# @BRIEF Fetch LLM provider config from FastAPI for title generation.
async def _get_llm_config() -> dict[str, Any] | None:
try:
fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000")
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{fastapi_url}/api/agent/llm-config", headers=headers)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
# #endregion AgentChat.Persistence.GetLlmConfig
# #region AgentChat.Persistence.CallLlmForTitle [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,llm,title]
# @ingroup AgentChat
# @BRIEF Call LLM to generate a 3-5 word Russian title from user text.
async def _call_llm_for_title(user_text: str) -> str | None:
from ss_tools.shared.logger import logger as _logger
try:
config = await _get_llm_config()
if not config or not config.get("configured"):
return None
clean_text = clean_title(user_text)[:200]
if not clean_text or clean_text in ("Новый диалог",):
return None
prompt = f"Сгенерируй заголовок из 3-5 слов на русском для диалога. Только заголовок, без кавычек и пояснений.\n\nДиалог: {clean_text}"
api_key = config.get("api_key", "")
base_url = config.get("base_url", "")
model = config.get("default_model", "gpt-4o-mini")
payload = {"model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": 15}
add_temperature_if_supported(payload, model=model)
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
base = base_url.rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
api_url = base + "/v1/chat/completions"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(api_url, json=payload, headers=headers)
if resp.status_code != 200:
return None
data = resp.json()
title = data.get("choices", [{}])[0].get("message", {}).get("content", "")
if title:
title = re.sub(r'[*_`#"\']', "", title).strip()
title = title[:100]
if title:
return title
except Exception as e:
_logger.explore("LLM title generation failed", error=str(e), extra={"src": "AgentChat.Persistence"})
return None
# #endregion AgentChat.Persistence.CallLlmForTitle
# #region AgentChat.Persistence.GenerateLlmTitle [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,title,generate]
# @ingroup AgentChat
# @BRIEF Generate LLM title and persist to backend via SAVE_API_URL — per-conversation mutex.
# @SIDE_EFFECT HTTP POST to FastAPI save endpoint.
async def generate_llm_title(conv_id: str, user_text: str) -> None:
if not conv_id or not user_text:
return
lock = _title_locks.setdefault(conv_id, asyncio.Lock())
if lock.locked():
return
async with lock:
title = await _call_llm_for_title(user_text)
if not title:
return
try:
headers = {"Content-Type": "application/json"}
if _SERVICE_JWT:
headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
payload = {"conversation_id": conv_id, "title": title, "user_id": "admin", "messages": []}
async with httpx.AsyncClient(timeout=5) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
logger.reflect("LLM title updated", payload={"conv_id": conv_id, "title": title[:40]}, extra={"src": "AgentChat.Persistence"})
except Exception as e:
logger.explore("LLM title save failed", payload={"conv_id": conv_id}, error=str(e), extra={"src": "AgentChat.Persistence"})
finally:
_title_locks.pop(conv_id, None)
# #endregion AgentChat.Persistence.GenerateLlmTitle
# #region AgentChat.Persistence.PrefetchDashboards [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,prefetch,dashboards]
# @ingroup AgentChat
# @BRIEF Prefetch dashboard list from FastAPI for runtime context injection.
async def prefetch_dashboards(env_id: str) -> str:
try:
from ss_tools.agent.tools import FASTAPI_URL, _dual_auth_headers
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{FASTAPI_URL}/api/dashboards",
params={"q": "", "env_id": env_id or ""},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
data = resp.json()
dashboards = data.get("dashboards", [])
if not dashboards:
return "No dashboards found."
limit = _PREFETCH_LIMIT
total = len(dashboards)
lines = []
for db in dashboards[:limit]:
title = db.get("title", "Untitled")
dashboard_id = db.get("id") or db.get("dashboard_id")
modified = (db.get("last_modified", "") or "")[:10]
if modified:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
else:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
suffix = ""
if total > limit:
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
except Exception as e:
logger.explore("Prefetch dashboards failed", payload={"env_id": env_id}, error=str(e), extra={"src": "AgentChat.Persistence.PrefetchDashboards"})
return ""
# #endregion AgentChat.Persistence.PrefetchDashboards
# #region AgentChat.Persistence.PrefetchDatabases [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,prefetch,databases]
# @ingroup AgentChat
# @BRIEF Prefetch database list from FastAPI for runtime context injection.
async def prefetch_databases(env_id: str) -> str:
try:
from ss_tools.agent.tools import FASTAPI_URL, _dual_auth_headers
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{FASTAPI_URL}/api/agent/superset/databases",
params={"environment_id": env_id or ""},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
databases = resp.json()
if not databases:
return "No databases found."
lines = ["Available databases (use database_id for SQL tools):"]
for db in databases:
db_id = db.get("id", "?")
db_name = db.get("database_name", db.get("name", "?"))
db_engine = db.get("backend", db.get("engine", ""))
if db_engine:
lines.append(f" • DB #{db_id}: {db_name} ({db_engine})")
else:
lines.append(f" • DB #{db_id}: {db_name}")
return "\n".join(lines)
except Exception as e:
logger.explore("Prefetch databases failed", payload={"env_id": env_id}, error=str(e), extra={"src": "AgentChat.Persistence.PrefetchDatabases"})
return ""
# #endregion AgentChat.Persistence.PrefetchDatabases
# #region AgentChat.Persistence.SaveConversation [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,save]
# @ingroup AgentChat
# @BRIEF Persist conversation messages to FastAPI /api/agent/conversations/save.
# @SIDE_EFFECT HTTP POST to FastAPI.
async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
try:
headers = {"Content-Type": "application/json"}
if _SERVICE_JWT:
headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
if not user_id or user_id.startswith("anon_"):
user_id = "admin"
messages: list[dict[str, Any]] = [
{"id": str(uuid.uuid4()), "conversation_id": conv_id, "role": "user", "text": user_text.strip(), "state": None, "created_at": datetime.utcnow().isoformat()},
]
if assistant_text:
messages.append({"id": str(uuid.uuid4()), "conversation_id": conv_id, "role": "assistant", "text": assistant_text.strip(), "state": None, "created_at": datetime.utcnow().isoformat()})
payload = {"conversation_id": conv_id, "title": clean_title(user_text)[:TITLE_MAX_LENGTH], "user_id": user_id, "messages": messages}
async with httpx.AsyncClient(timeout=10) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
logger.reflect("Conversation saved", payload={"conv_id": conv_id, "user_id": user_id, "messages": len(messages)}, extra={"src": "AgentChat.Persistence"})
except Exception as e:
logger.explore("Save conversation failed", payload={"conv_id": conv_id}, error=str(e), extra={"src": "AgentChat.Persistence"})
# #endregion AgentChat.Persistence.SaveConversation
# #endregion AgentChat.Persistence

View File

@@ -0,0 +1,100 @@
# agent/src/ss_tools/agent/_tool_filter.py
# #region AgentChat.ToolFilter [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,filter,context]
# @BRIEF Context-aware tool filtering + RBAC enforcement.
# @LAYER Service
# @DATA_CONTRACT build_tool_pipeline returns a list — never mutates the input list.
# @DATA_CONTRACT enforce_tool_permission returns bool for any string input.
from typing import Any
from ss_tools.shared.logger import logger
_CONTEXT_TOOL_AFFINITY: dict[str, set[str]] = {
"dashboard": {
"superset_list_databases",
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"run_llm_validation",
"run_llm_documentation",
"execute_migration",
"create_branch",
"commit_changes",
},
"dataset": {
"superset_list_databases",
"superset_explore_database",
"superset_format_sql",
"superset_audit_permissions",
"superset_execute_sql",
"superset_create_dataset",
"search_dashboards",
"get_task_status",
"list_environments",
},
"migration": {
"superset_list_databases",
"execute_migration",
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"list_environments",
},
}
_TOOL_PERMISSIONS: dict[str, list[str]] = {
"deploy_dashboard": ["admin"],
"commit_changes": ["admin"],
"create_branch": ["admin"],
"run_backup": ["admin"],
"execute_migration": ["admin"],
"start_maintenance": ["admin"],
"end_maintenance": ["admin"],
}
_MANDATORY_TOOLS: set[str] = {"show_capabilities"}
# #region AgentChat.ToolFilter.BuildPipeline [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,filter,pipeline]
# @ingroup AgentChat
# @BRIEF Apply RBAC + context-affinity filtering to a tool list, always including mandatory tools.
# @DATA_CONTRACT Input: (tools, user_role, object_type?) -> Output: filtered list (never mutates input).
# @DATA_CONTRACT Mandatory tools (show_capabilities) always pass through.
def build_tool_pipeline(
tools: list[Any],
user_role: str,
object_type: str | None = None,
) -> list[Any]:
filtered: list[Any] = []
for tool in tools:
name: str = tool.name
if name in _TOOL_PERMISSIONS:
allowed_roles: list[str] = _TOOL_PERMISSIONS[name]
if user_role not in allowed_roles:
logger.reason("Tool excluded by RBAC", payload={"tool": name, "reason": f"role '{user_role}' not in allowed roles {allowed_roles}"}, extra={"src": "AgentChat.ToolFilter"})
continue
if (object_type is not None and object_type in _CONTEXT_TOOL_AFFINITY and name not in _CONTEXT_TOOL_AFFINITY[object_type] and name not in _MANDATORY_TOOLS):
logger.reason("Tool excluded by context", payload={"tool": name, "reason": f"not in context affinity set for object_type '{object_type}'"}, extra={"src": "AgentChat.ToolFilter"})
continue
filtered.append(tool)
seen_names: set[str] = {t.name for t in filtered}
missing_mandatory: set[str] = _MANDATORY_TOOLS - seen_names
if missing_mandatory:
for tool in tools:
if tool.name in missing_mandatory:
filtered.append(tool)
return filtered
# #endregion AgentChat.ToolFilter.BuildPipeline
# #region AgentChat.ToolFilter.EnforcePermission [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,filter,permission]
# @ingroup AgentChat
# @BRIEF Check if user_role is allowed to invoke a specific tool.
# @POST Returns True for allowed or non-gated tools, False otherwise.
def enforce_tool_permission(tool_name: str, user_role: str) -> bool:
if tool_name in _TOOL_PERMISSIONS:
allowed_roles: list[str] = _TOOL_PERMISSIONS[tool_name]
return user_role in allowed_roles
return True
# #endregion AgentChat.ToolFilter.EnforcePermission
# #endregion AgentChat.ToolFilter

View File

@@ -0,0 +1,86 @@
# agent/src/ss_tools/agent/_tool_resolver.py
# #region AgentChat.ToolResolver [C:2] [TYPE Module] [SEMANTICS agent-chat,tools,resolution]
# @BRIEF Tool resolution helpers for the LangGraph agent.
# @LAYER Service
from typing import Any
_GRAPH_NODE_NAMES = {"agent", "tools", "__start__", "__end__"}
# #region AgentChat.ToolResolver.KnownNames [C:1] [TYPE Function] [SEMANTICS agent-chat,tools,names]
# @ingroup AgentChat
# @BRIEF Return set of all registered agent tool names.
def known_agent_tool_names() -> set[str]:
try:
from ss_tools.agent.tools import get_all_tools
return {str(tool_obj.name) for tool_obj in get_all_tools() if getattr(tool_obj, "name", None)}
except Exception:
return set()
# #endregion AgentChat.ToolResolver.KnownNames
# #region AgentChat.ToolResolver.NormalizeArgs [C:1] [TYPE Function] [SEMANTICS agent-chat,tools,args,normalize]
# @ingroup AgentChat
# @BRIEF Normalize tool arguments to dict — handles None, dict, pydantic models.
def normalize_tool_args(raw_args: Any) -> dict[str, Any]:
if raw_args is None:
return {}
if isinstance(raw_args, dict):
return raw_args
if hasattr(raw_args, "model_dump"):
dumped = raw_args.model_dump()
return dumped if isinstance(dumped, dict) else {}
if hasattr(raw_args, "dict"):
dumped = raw_args.dict()
return dumped if isinstance(dumped, dict) else {}
return {}
# #endregion AgentChat.ToolResolver.NormalizeArgs
# #region AgentChat.ToolResolver.CoerceCall [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,coerce]
# @ingroup AgentChat
# @BRIEF Coerce a raw tool_call (dict or object) into (name, args) tuple.
def coerce_tool_call(tool_call: Any) -> tuple[str | None, dict[str, Any]]:
if isinstance(tool_call, dict):
return (
tool_call.get("name") or tool_call.get("tool") or tool_call.get("id"),
normalize_tool_args(tool_call.get("args") or tool_call.get("input")),
)
return (
getattr(tool_call, "name", None),
normalize_tool_args(getattr(tool_call, "args", None)),
)
# #endregion AgentChat.ToolResolver.CoerceCall
# #region AgentChat.ToolResolver.ExtractCall [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,extract,state]
# @ingroup AgentChat
# @BRIEF Extract pending tool call from LangGraph state messages.
def extract_tool_call_from_state(state, user_text: str = "") -> tuple[str | None, dict[str, Any]]:
known_tools = known_agent_tool_names()
try:
messages = (state.values.get("messages") if hasattr(state, "values") else []) or []
for msg in reversed(messages[-5:]):
if hasattr(msg, "tool_calls") and msg.tool_calls:
tool_name, tool_args = coerce_tool_call(msg.tool_calls[0])
if tool_name:
return (str(tool_name), tool_args)
except Exception:
pass
if getattr(state, "next", None):
node_or_tool = str(state.next[0])
if node_or_tool in known_tools and node_or_tool not in _GRAPH_NODE_NAMES:
return (node_or_tool, {})
return (None, {})
# #endregion AgentChat.ToolResolver.ExtractCall
# #region AgentChat.ToolResolver.FindTool [C:1] [TYPE Function] [SEMANTICS agent-chat,tools,find]
# @ingroup AgentChat
# @BRIEF Find a registered tool object by name.
def find_tool(tool_name: str):
from ss_tools.agent.tools import get_all_tools
return next((tool_obj for tool_obj in get_all_tools() if getattr(tool_obj, "name", None) == tool_name), None)
# #endregion AgentChat.ToolResolver.FindTool
# #endregion AgentChat.ToolResolver

View File

@@ -1,4 +1,4 @@
# backend/src/agent/app.py
# agent/src/ss_tools/agent/app.py
# #region AgentChat.GradioApp [C:4] [TYPE Module] [SEMANTICS agent-chat,gradio,app]
# @defgroup AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt().
# @PRE AUTH_SECRET_KEY env var set. Shared with FastAPI for stateless validation.
@@ -33,51 +33,59 @@ from langchain_core.messages import HumanMessage
from langchain_openai import ChatOpenAI
from openai import APIConnectionError, APITimeoutError, AuthenticationError, RateLimitError
from src.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT
from src.agent._confirmation import (
from ss_tools.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT
from ss_tools.agent._confirmation import (
_pending_confirmations,
confirmation_payload,
handle_resume,
permission_denied_payload,
)
from src.agent._jwt_decoder import decode_token
from src.agent._llm_health import (
from ss_tools.agent._jwt_decoder import decode_token
from ss_tools.shared._llm_health import (
_LLM_CHECK_CACHE_TTL,
_LLM_LAST_ERROR_KEY,
_LLM_LAST_ERROR_TS_KEY,
_check_llm_provider_health,
_llm_status,
)
from src.agent._llm_params import chat_openai_kwargs
from src.agent._persistence import (
from ss_tools.agent._llm_params import chat_openai_kwargs
from ss_tools.agent._persistence import (
extract_user_id,
generate_llm_title,
prefetch_dashboards,
prefetch_databases,
save_conversation,
)
from src.agent.context import set_user_jwt, set_user_role
from src.agent.document_parser import parse_upload
from src.agent.langgraph_setup import create_agent
from src.agent.middleware import log_tool_event
from src.agent.tools import (
from ss_tools.agent.context import set_user_jwt, set_user_role
from ss_tools.agent.document_parser import parse_upload
from ss_tools.agent.langgraph_setup import create_agent
from ss_tools.agent.middleware import log_tool_event
from ss_tools.agent.tools import (
_redact_sensitive_fields,
drain_tool_retry_events,
get_all_tools,
start_tool_retry_event_buffer,
)
from src.core.cot_logger import seed_trace_id
from src.core.logger import logger
from ss_tools.shared.cot_logger import seed_trace_id
from ss_tools.shared.logger import logger
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
TITLE_GENERATION_TIMEOUT_S = float(os.getenv("AGENT_TITLE_GENERATION_TIMEOUT_S", "0.25"))
ENABLE_LLM_TITLE_GENERATION = os.getenv("AGENT_ENABLE_LLM_TITLES", "").lower() in {"1", "true", "yes"}
# #region AgentChat.GradioApp.NowIso [C:1] [TYPE Function] [SEMANTICS agent-chat,datetime,iso]
# @ingroup AgentChat
# @BRIEF Return current datetime as ISO-8601 string with timezone.
def _now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
# #endregion AgentChat.GradioApp.NowIso
# #region AgentChat.GradioApp.BuildAgentContext [C:3] [TYPE Function] [SEMANTICS agent-chat,context,runtime,build]
# @ingroup AgentChat
# @BRIEF Build hidden RUNTIME CONTEXT block with datetime, prefetched dashboards and databases.
# @SIDE_EFFECT HTTP GET to FastAPI for prefetch data.
async def _build_agent_context(env_id: str | None) -> str:
"""Build hidden runtime context for the LLM without storing it as user text."""
parts = [
@@ -112,6 +120,7 @@ async def _build_agent_context(env_id: str | None) -> str:
)
parts.append("[/RUNTIME CONTEXT]")
return "\n".join(parts)
# #endregion AgentChat.GradioApp.BuildAgentContext
# #region AgentChat.GradioApp.TitleBestEffort [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,title]
@@ -148,7 +157,7 @@ _user_locks: dict[str, bool] = {}
# ── LLM provider health cache ─────────────────────────────────---
# _llm_status, _LLM_CHECK_CACHE_TTL, _LLM_LAST_ERROR_KEY, _LLM_LAST_ERROR_TS_KEY,
# and _check_llm_provider_health() are imported from src.agent._llm_health
# and _check_llm_provider_health() are imported from ss_tools.shared._llm_health
# to avoid triggering gradio import in backend container.
# ── File persistence ────────────────────────────────────────────
@@ -411,7 +420,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
if uicontext_str:
try:
uicontext = json.loads(uicontext_str)
from src.agent._context import validate_uicontext
from ss_tools.agent._context import validate_uicontext
uicontext = validate_uicontext(uicontext)
logger.reason("UIContext received", payload={"objectType": uicontext.get("objectType"), "objectId": uicontext.get("objectId")}, extra={"src": "AgentChat.Handler"})
@@ -478,7 +487,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
# Embedding-based tool selection (top-K) replaces keyword matching if model available.
agent_tools = get_all_tools()
# Apply tool pipeline: RBAC → context affinity (035-agent-chat-context)
from src.agent._tool_filter import build_tool_pipeline
from ss_tools.agent._tool_filter import build_tool_pipeline
agent_tools = build_tool_pipeline(
agent_tools,

View File

@@ -0,0 +1,56 @@
# agent/src/ss_tools/agent/context.py
# #region AgentChat.Context [C:3] [TYPE Module] [SEMANTICS agent-chat,context,auth]
# @BRIEF JWT context propagation for LangGraph tools.
# @RATIONALE LangGraph tool execution may run in a different async context,
# preventing ContextVar from propagating. Module-level globals
# ensure the JWT is always accessible from any execution context.
_user_jwt: str = ""
_service_jwt: str = ""
_user_role: str = "viewer"
# #region AgentChat.Context.SetUserJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,jwt,set]
# @BRIEF Store user JWT in module-level global for tool call authentication.
def set_user_jwt(jwt: str) -> None:
global _user_jwt
_user_jwt = jwt
# #endregion AgentChat.Context.SetUserJwt
# #region AgentChat.Context.GetUserJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,jwt,get]
# @BRIEF Retrieve stored user JWT for tool HTTP headers.
def get_user_jwt() -> str:
return _user_jwt
# #endregion AgentChat.Context.GetUserJwt
# #region AgentChat.Context.SetUserRole [C:1] [TYPE Function] [SEMANTICS agent-chat,context,role,set]
# @BRIEF Store user role for RBAC enforcement in tool pipeline.
def set_user_role(role: str) -> None:
global _user_role
_user_role = role or "viewer"
# #endregion AgentChat.Context.SetUserRole
# #region AgentChat.Context.GetUserRole [C:1] [TYPE Function] [SEMANTICS agent-chat,context,role,get]
# @BRIEF Retrieve stored user role for RBAC checks.
def get_user_role() -> str:
return _user_role
# #endregion AgentChat.Context.GetUserRole
# #region AgentChat.Context.SetServiceJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,service-jwt,set]
# @BRIEF Store service-to-service JWT for dual-identity auth.
def set_service_jwt(jwt: str) -> None:
global _service_jwt
_service_jwt = jwt
# #endregion AgentChat.Context.SetServiceJwt
# #region AgentChat.Context.GetServiceJwt [C:1] [TYPE Function] [SEMANTICS agent-chat,context,service-jwt,get]
# @BRIEF Retrieve stored service JWT for dual-identity auth headers.
def get_service_jwt() -> str:
return _service_jwt
# #endregion AgentChat.Context.GetServiceJwt
# #endregion AgentChat.Context

View File

@@ -1,6 +1,6 @@
# backend/src/agent/document_parser.py
# agent/src/ss_tools/agent/document_parser.py
# #region AgentChat.Document.Parser [C:3] [TYPE Module] [SEMANTICS agent-chat,document,parser]
# @defgroup AgentChat Parse PDF and XLSX files into text/structured data.
# @BRIEF Parse PDF and XLSX files into text/structured data.
# @RELATION DEPENDS_ON -> [EXT:pdfplumber]
# @RELATION DEPENDS_ON -> [EXT:openpyxl]
# @PRE File exists, valid format, ≤10MB.
@@ -9,17 +9,19 @@
from pathlib import Path
# #region AgentChat.Document.Parser.ParseError [C:1] [TYPE Class] [SEMANTICS agent-chat,error,parse]
class ParseError(Exception):
"""Raised when document parsing fails."""
# #endregion AgentChat.Document.Parser.ParseError
# #region AgentChat.Document.Parser.ParsePdf [C:2] [TYPE Function] [SEMANTICS agent-chat,parse,pdf]
# @BRIEF Extract text from PDF using pdfplumber with PyPDF2 fallback.
def parse_pdf(file_path: str) -> str:
"""Extract text from PDF using pdfplumber (primary) with PyPDF2 fallback."""
try:
import pdfplumber
except ImportError:
raise ParseError("pdfplumber not installed") from None
try:
with pdfplumber.open(file_path) as pdf:
pages = []
@@ -29,7 +31,6 @@ def parse_pdf(file_path: str) -> str:
pages.append(text)
return "\n\n".join(pages) if pages else ""
except Exception as e:
# Fallback to PyPDF2
try:
import PyPDF2
with open(file_path, "rb") as f:
@@ -37,15 +38,16 @@ def parse_pdf(file_path: str) -> str:
return "\n\n".join(p.extract_text() for p in reader.pages if p.extract_text())
except Exception:
raise ParseError(f"Failed to parse PDF: {e}") from None
# #endregion AgentChat.Document.Parser.ParsePdf
# #region AgentChat.Document.Parser.ParseXlsx [C:2] [TYPE Function] [SEMANTICS agent-chat,parse,xlsx]
# @BRIEF Extract structured data from XLSX — sheet names + cell data.
def parse_xlsx(file_path: str) -> str:
"""Extract structured data from XLSX — sheet names + cell data."""
try:
import openpyxl
except ImportError:
raise ParseError("openpyxl not installed") from None
try:
wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True)
parts = []
@@ -59,60 +61,53 @@ def parse_xlsx(file_path: str) -> str:
return "\n\n".join(parts)
except Exception as e:
raise ParseError(f"Failed to parse XLSX: {e}") from e
# #endregion AgentChat.Document.Parser.ParseXlsx
# #region AgentChat.Document.Parser.DetectFormat [C:1] [TYPE Function] [SEMANTICS agent-chat,detect,magic-bytes]
# @BRIEF Detect file format by reading magic bytes.
def _detect_format_by_magic(path: str) -> str | None:
"""Detect file format by reading magic bytes. Returns extension or None."""
try:
with open(path, "rb") as f:
header = f.read(8)
except OSError:
return None
if header[:4] == b"%PDF":
return ".pdf"
if header[:4] == b"PK\x03\x04":
# ZIP-based: XLSX, DOCX, etc. Try XLSX first.
return ".xlsx"
if header[:1] in (b"{", b"["):
return ".json"
# CSV often starts with text characters; safe fallback
return None
# #endregion AgentChat.Document.Parser.DetectFormat
# #region AgentChat.Document.Parser.ParseUpload [C:3] [TYPE Function] [SEMANTICS agent-chat,parse,upload]
# @BRIEF Parse an uploaded file based on extension with magic-byte fallback.
# @PRE File path exists and is accessible. Format is PDF, XLSX, JSON, CSV, or TXT.
# @POST Returns extracted text or raises ParseError.
def parse_upload(file_data) -> str:
"""Parse an uploaded file based on its extension with magic-byte fallback.
Args:
file_data: str (file path) or dict with "name" and "path"/"file_path" keys.
"""
if isinstance(file_data, str):
path = file_data
name = Path(path).name
else:
name = file_data.get("name") or file_data.get("orig_name", "")
path = file_data.get("path") or file_data.get("file_path", "")
# Gradio sometimes sends files without 'name' key — fall back to path stem
if not name and path:
name = Path(path).name
ext = Path(name).suffix.lower()
# Double fallback: if name has no extension, try the physical file path
if not ext and path:
ext = Path(path).suffix.lower()
# Triple fallback: detect by magic bytes (Gradio ChatInterface loses filename)
if not ext and path:
ext = _detect_format_by_magic(path)
if ext == ".pdf":
return parse_pdf(path)
elif ext in (".xlsx", ".xls"):
return parse_xlsx(path)
elif ext in (".json", ".csv", ".txt"):
with open(path, encoding="utf-8", errors="replace") as f:
return f.read(100_000) # truncate at ~100k chars
return f.read(100_000)
elif ext is None:
# Magic bytes detection returned None — unknown binary, try as text
try:
with open(path, encoding="utf-8", errors="replace") as f:
return f.read(100_000)
@@ -126,4 +121,5 @@ def parse_upload(file_data) -> str:
f"Unsupported format: '{ext}' (file: {name}). "
f"Supported: PDF, XLSX, JSON, CSV, TXT"
)
# #endregion AgentChat.Document.Parser.ParseUpload
# #endregion AgentChat.Document.Parser

View File

@@ -1,24 +1,9 @@
# backend/src/agent/langgraph_setup.py
# agent/src/ss_tools/agent/langgraph_setup.py
# #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent]
# @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver.
# @BRIEF LangGraph agent setup: create_react_agent with PostgresSaver.
# @PRE LLM provider configured via backend API /api/agent/llm-config.
# @POST Compiled StateGraph ready for astream_events().
# @SIDE_EFFECT Initializes checkpointer and message history tables on first call.
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE LangGraph create_react_agent provides built-in tool calling + checkpointing + interrupt/resume.
# @REJECTED Using only environment variables for LLM config was rejected — FastAPI API-based config allows runtime switching without restart.
# RunnableWithMessageHistory wrapper is NOT used — PostgresSaver handles history natively.
# ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ──
# LangChain BaseTool objects carry an ``args_schema`` field that is a Pydantic
# BaseModel *class* reference (not an instance). When the OpenAI SDK recursively
# transforms the request body, it hits ``isinstance(data, pydantic.BaseModel)``
# which is True for both instances AND classes. It then calls ``model_dump()``
# on the class, which fails with:
#
# PydanticSerializationError: Unable to serialize unknown type: ModelMetaclass
#
# The fix: skip model_dump for classes, only dump instances.
import inspect as _inspect
import os
@@ -33,23 +18,22 @@ from psycopg.rows import dict_row
import pydantic as _pydantic
import pydantic_core as _pydantic_core
from src.agent._config import AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE, FASTAPI_URL
from src.agent._llm_params import chat_openai_kwargs
from src.core.logger import logger
from ss_tools.agent._config import AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE, FASTAPI_URL
from ss_tools.agent._llm_params import chat_openai_kwargs
from ss_tools.shared.logger import logger
_original_transform = _openai_transform._async_transform_recursive
# #region AgentChat.LangGraph.Setup.PatchedTransform [C:2] [TYPE Function] [SEMANTICS agent-chat,langgraph,patch,serialization]
# @ingroup AgentChat
# @BRIEF Patch openai._transform to handle pydantic model serialization errors.
async def _patched_transform(data, *, annotation, inner_type=None):
if isinstance(data, _pydantic.BaseModel):
if _inspect.isclass(data):
# BaseModel CLASS (not instance) — skip model_dump
return data
# BaseModel INSTANCE — intercept PydanticSerializationError
try:
return await _original_transform(data, annotation=annotation, inner_type=inner_type)
except _pydantic_core.PydanticSerializationError:
print(f"[PATCH] Caught PydanticSerializationError on {type(data).__name__}")
# Fallback: dump with exclude of type-ref fields
serializable = {}
for field_name in data.model_fields_set:
val = getattr(data, field_name)
@@ -59,49 +43,47 @@ async def _patched_transform(data, *, annotation, inner_type=None):
serializable[field_name] = val
return serializable
return await _original_transform(data, annotation=annotation, inner_type=inner_type)
# #endregion AgentChat.LangGraph.Setup.PatchedTransform
_openai_transform._async_transform_recursive = _patched_transform
# ── Postgres checkpointer (FR-004/FR-012/FR-027) ──
_CHECKPOINTER: AsyncPostgresSaver | None = None
_CHECKPOINTER_INIT = False
_CHECKPOINTER_CONN = None
# #region AgentChat.LangGraph.Setup.InitCheckpointer [C:3] [TYPE Function] [SEMANTICS agent-chat,langgraph,checkpointer,postgres]
# @ingroup AgentChat
# @BRIEF Initialize AsyncPostgresSaver from DATABASE_URL env var.
# @SIDE_EFFECT Connects to PostgreSQL; creates checkpointer table via setup().
async def init_checkpointer() -> None:
"""Initialize the PostgreSQL checkpointer (FR-004/FR-012/FR-027).
Called once at agent startup. Creates a persistent psycopg async connection
and passes it to AsyncPostgresSaver. Runs .setup() to create checkpoint tables.
Connection stays open for the lifetime of the agent process.
"""
global _CHECKPOINTER, _CHECKPOINTER_INIT, _CHECKPOINTER_CONN
if _CHECKPOINTER_INIT:
return
db_url = os.getenv("DATABASE_URL")
# Convert SQLAlchemy-style URL to psycopg format
pg_url = db_url.replace("postgresql+psycopg2://", "postgres://").replace("postgresql://", "postgres://")
_CHECKPOINTER_CONN = await psycopg.AsyncConnection.connect(pg_url, autocommit=True, row_factory=dict_row)
_CHECKPOINTER = AsyncPostgresSaver(_CHECKPOINTER_CONN)
await _CHECKPOINTER.setup()
_CHECKPOINTER_INIT = True
# #endregion AgentChat.LangGraph.Setup.InitCheckpointer
# ── LLM config (no cache — fetched on each create_agent call) ──
_llm_config: dict | None = None
# #region AgentChat.LangGraph.Setup.ConfigureFromApi [C:1] [TYPE Function] [SEMANTICS agent-chat,langgraph,config,api]
# @ingroup AgentChat
# @BRIEF Store LLM config dict fetched from FastAPI for later use by create_agent.
def configure_from_api(llm_config: dict) -> None:
"""Update LLM config from FastAPI response. Called at startup."""
global _llm_config
_llm_config = llm_config
# #endregion AgentChat.LangGraph.Setup.ConfigureFromApi
# #region AgentChat.LangGraph.Setup.FetchLlmConfig [C:2] [TYPE Function] [SEMANTICS agent-chat,langgraph,config,fetch]
# @ingroup AgentChat
# @BRIEF Fetch LLM provider config from FastAPI /api/agent/llm-config.
async def _fetch_llm_config() -> dict | None:
"""Fetch LLM config from FastAPI.
Called on every create_agent() to pick up Admin UI changes immediately.
Falls back to cached config if fetch fails.
"""
global _llm_config
try:
fastapi_url = FASTAPI_URL
@@ -113,66 +95,42 @@ async def _fetch_llm_config() -> dict | None:
_llm_config = config
return config
except Exception as e:
logger.explore("Failed to fetch LLM config from FastAPI", error=str(e),
extra={"src": "AgentChat.LangGraph.Setup"})
logger.explore("Failed to fetch LLM config from FastAPI", error=str(e), extra={"src": "AgentChat.LangGraph.Setup"})
return _llm_config
# #endregion AgentChat.LangGraph.Setup.FetchLlmConfig
# #region AgentChat.LangGraph.Setup.InterruptBeforeFromEnv [C:1] [TYPE Function] [SEMANTICS agent-chat,langgraph,interrupt,env]
# @ingroup AgentChat
# @BRIEF Resolve interrupt_before list from AGENT_CONFIRM_TOOLS and AGENT_INTERRUPT_BEFORE env vars.
def _interrupt_before_from_env() -> list[str]:
"""Return LangGraph node names that must pause for HITL confirmation."""
if AGENT_CONFIRM_TOOLS:
return ["tools"]
raw = _INTERRUPT_BEFORE
if not raw:
return []
return [name.strip() for name in raw.split(",") if name.strip()]
# #endregion AgentChat.LangGraph.Setup.InterruptBeforeFromEnv
async def create_agent(
tools: list,
env_id: str | None = None,
interrupt_before: list[str] | None = None,
):
"""Create the LangGraph agent with PostgreSQL checkpointer and message history.
LLM configuration source:
llm_config from FastAPI /api/agent/llm-config (fetched on every call).
If backend has no configured provider, agent raises an error.
Returns a compiled StateGraph ready for astream_events().
interrupt_before is set from AGENT_CONFIRM_TOOLS (or AGENT_INTERRUPT_BEFORE env var)
to enable HITL guardrails when pending tools are detected, the graph pauses before
executing them and yields confirm_required metadata to the frontend.
Checkpointer is AsyncPostgresSaver (survives container restarts).
"""
# Fetch fresh LLM config from FastAPI on every call
# #region AgentChat.LangGraph.Setup.CreateAgent [C:4] [TYPE Function] [SEMANTICS agent-chat,langgraph,create,agent]
# @ingroup AgentChat
# @BRIEF Build and compile a LangGraph agent with tools, prompt, and checkpointer.
# @PRE LLM config fetched from FastAPI. Tools list provided.
# @POST Returns compiled StateGraph ready for astream_events().
# @SIDE_EFFECT Creates ChatOpenAI instance; compiles LangGraph state graph.
# @RELATION DEPENDS_ON -> [AgentChat.LlmParams]
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
async def create_agent(tools: list, env_id: str | None = None, interrupt_before: list[str] | None = None):
config = await _fetch_llm_config()
if config and config.get("configured"):
api_key = config["api_key"]
base_url = config.get("base_url")
model = config.get("default_model")
config_source = "FastAPI"
else:
raise RuntimeError(
"No LLM provider configured in backend. "
"Configure one via Settings → AI Providers in the web UI."
)
logger.reason(
"Creating LangGraph agent",
payload={"model": model, "config_source": config_source, "tools_count": len(tools), "env_id": env_id},
extra={"src": "AgentChat.LangGraph.Setup"},
)
llm = ChatOpenAI(**chat_openai_kwargs(
model=model,
base_url=base_url,
api_key=api_key,
max_tokens=2048,
))
# System prompt — env_id injected deterministically, not in user message
raise RuntimeError("No LLM provider configured in backend. Configure one via Settings → AI Providers in the web UI.")
logger.reason("Creating LangGraph agent", payload={"model": model, "tools_count": len(tools), "env_id": env_id}, extra={"src": "AgentChat.LangGraph.Setup"})
llm = ChatOpenAI(**chat_openai_kwargs(model=model, base_url=base_url, api_key=api_key, max_tokens=2048))
prompt = (
"You are a Superset Tools assistant. You have access to tools for searching "
"dashboards, managing maintenance, running migrations and backups, "
@@ -192,18 +150,11 @@ async def create_agent(
)
if env_id:
prompt += f"\n\nCurrent environment: '{env_id}'. When calling tools that accept env_id, use this value."
# Checkpointer — AsyncPostgresSaver. Fallback to InMemory if Postgres init failed
if _CHECKPOINTER is not None:
checkpointer = _CHECKPOINTER
else:
checkpointer = InMemorySaver()
logger.explore(
"Postgres checkpointer unavailable, falling back to InMemorySaver",
error="_CHECKPOINTER is None — checkpoints will be lost on restart",
extra={"src": "AgentChat.LangGraph.Setup"},
)
logger.explore("Postgres checkpointer unavailable, falling back to InMemorySaver", error="_CHECKPOINTER is None — checkpoints will be lost on restart", extra={"src": "AgentChat.LangGraph.Setup"})
graph = create_react_agent(
model=llm,
tools=tools,
@@ -212,11 +163,7 @@ async def create_agent(
checkpointer=checkpointer,
interrupt_before=_interrupt_before_from_env() if interrupt_before is None else interrupt_before,
)
logger.reflect(
"LangGraph agent created",
payload={"model": model, "checkpointer_type": type(checkpointer).__name__, "tools_count": len(tools)},
extra={"src": "AgentChat.LangGraph.Setup"},
)
logger.reflect("LangGraph agent created", payload={"model": model, "checkpointer_type": type(checkpointer).__name__, "tools_count": len(tools)}, extra={"src": "AgentChat.LangGraph.Setup"})
return graph
# #endregion AgentChat.LangGraph.Setup.CreateAgent
# #endregion AgentChat.LangGraph.Setup

View File

@@ -0,0 +1,36 @@
# agent/src/ss_tools/agent/middleware.py
# #region AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS agent-chat,middleware,logging,audit]
# @BRIEF Audit logging middleware for the LangGraph agent.
from datetime import UTC, datetime
from ss_tools.agent.context import get_user_jwt
from ss_tools.agent.tools import _redact_sensitive_fields
from ss_tools.shared.logger import logger
# #region AgentChat.Middleware.LogToolEvent [C:3] [TYPE Function] [SEMANTICS agent-chat,middleware,audit,logging]
# @ingroup AgentChat
# @BRIEF Log structured audit event for tool start/end/error with redacted input.
# @SIDE_EFFECT Writes JSON audit record via shared logger.
# @RELATION DEPENDS_ON -> [AgentChat.Tools.RedactSensitive]
async def log_tool_event(event: dict, conversation_id: str) -> None:
kind = event.get("event", "")
tool_name = event.get("name", "unknown")
user_jwt = get_user_jwt()
audit_payload = {
"event_type": kind,
"tool": tool_name,
"conversation_id": conversation_id,
"user_jwt_present": bool(user_jwt),
"timestamp": datetime.now(UTC).isoformat(),
}
if "data" in event:
data = event["data"]
if kind == "on_tool_start":
raw_input = data.get("input", "")
audit_payload["input"] = str(_redact_sensitive_fields(raw_input))[:500]
elif kind == "on_tool_error":
audit_payload["error"] = str(data.get("error", ""))[:500]
logger.reason("Tool audit event", payload=audit_payload, extra={"src": "AgentChat.Middleware.LoggingMiddleware"})
# #endregion AgentChat.Middleware.LogToolEvent
# #endregion AgentChat.Middleware

View File

@@ -1,4 +1,4 @@
# backend/src/agent/run.py
# agent/src/ss_tools/agent/run.py
# #region AgentChat.Run [C:3] [TYPE Module] [SEMANTICS agent-chat,entrypoint,startup]
# @ingroup AgentChat
# @BRIEF Entrypoint for Gradio agent backend. Fetches LLM config from FastAPI on startup.
@@ -12,9 +12,9 @@ import socket
import httpx
from src.agent._config import FASTAPI_URL, GRADIO_ALLOW_PORT_FALLBACK, GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, SERVICE_JWT
from src.core.cot_logger import seed_trace_id
from src.core.logger import logger
from ss_tools.agent._config import FASTAPI_URL, GRADIO_ALLOW_PORT_FALLBACK, GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, SERVICE_JWT
from ss_tools.shared.cot_logger import seed_trace_id
from ss_tools.shared.logger import logger
def _find_free_port(start_port: int, max_attempts: int = 100) -> int:
@@ -82,9 +82,9 @@ def _fetch_llm_config() -> dict | None:
if __name__ == "__main__":
import asyncio
from src.agent.app import create_chat_interface
from src.agent.context import set_service_jwt
from src.agent.langgraph_setup import configure_from_api, init_checkpointer
from ss_tools.agent.app import create_chat_interface
from ss_tools.agent.context import set_service_jwt
from ss_tools.agent.langgraph_setup import configure_from_api, init_checkpointer
seed_trace_id() # Seed trace for agent startup lifecycle

View File

@@ -1,4 +1,4 @@
# backend/src/agent/tools.py
# agent/src/ss_tools/agent/tools.py
# #region AgentChat.Tools [C:4] [TYPE Module] [SEMANTICS agent-chat,tools,langchain]
# @defgroup AgentChat Native LangChain @tool functions.
# @RATIONALE LangChain @tool decorator chosen over direct FastAPI calls for LangGraph compatibility — tools are auto-registered in the agent's tool-calling loop.
@@ -15,9 +15,9 @@ import httpx
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from src.agent._config import FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT
from src.agent.context import get_service_jwt, get_user_jwt, get_user_role
from src.core.logger import logger
from ss_tools.agent._config import FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT
from ss_tools.agent.context import get_service_jwt, get_user_jwt, get_user_role
from ss_tools.shared.logger import logger
TOOL_RESPONSE_LIMIT = 4000
TOOL_TIMEOUT_SECONDS = 30
@@ -127,7 +127,7 @@ def _safe_json_text(text: str) -> str:
# @ingroup AgentChat
# @BRIEF Enforce invocation-time RBAC before mutating tool side effects.
def _guard_tool_permission(tool_name: str) -> None:
from src.agent._tool_filter import _TOOL_PERMISSIONS, enforce_tool_permission
from ss_tools.agent._tool_filter import _TOOL_PERMISSIONS, enforce_tool_permission
user_role = get_user_role()
if enforce_tool_permission(tool_name, user_role):

View File

@@ -1,4 +1,4 @@
# backend/tests/agent/test_confirmation.py
# agent/tests/agent/test_confirmation.py
# #region Test.AgentChat.Confirmation [C:3] [TYPE Module] [SEMANTICS test,agent,hitl,confirmation,integration]
# @BRIEF Integration tests for _confirmation.py — handle_resume fast-path, LLM formatting,
# build_confirmation_contract, metadata, and title race-condition coverage.
@@ -39,7 +39,7 @@ def _make_fake_llm_chunk(content: str):
@pytest.fixture(autouse=True)
def clear_pending():
"""Clear _pending_confirmations before each test."""
from src.agent._confirmation import _pending_confirmations
from ss_tools.agent._confirmation import _pending_confirmations
_pending_confirmations.clear()
@@ -51,7 +51,7 @@ def clear_pending():
# @BRIEF Test build_confirmation_contract for all risk levels.
class TestBuildConfirmationContract:
def test_safe_tool_returns_read_contract(self):
from src.agent._confirmation import build_confirmation_contract
from ss_tools.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract("list_environments")
assert c["risk"] == "read"
assert c["risk_level"] == "safe"
@@ -59,7 +59,7 @@ class TestBuildConfirmationContract:
assert c["requires_confirmation"] is True
def test_dangerous_tool_returns_write_contract(self):
from src.agent._confirmation import build_confirmation_contract
from ss_tools.agent._confirmation import build_confirmation_contract
# deploy_dashboard starts with "deploy" → guarded risk level in v1 contract
c = build_confirmation_contract("deploy_dashboard")
assert c["risk"] == "write"
@@ -67,21 +67,21 @@ class TestBuildConfirmationContract:
assert c["prompt"] == "Подтвердить изменение данных?"
def test_guarded_tool_returns_write_contract(self):
from src.agent._confirmation import build_confirmation_contract
from ss_tools.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract("start_maintenance")
assert c["risk"] == "write"
assert c["risk_level"] == "guarded"
assert c["prompt"] == "Подтвердить изменение данных?"
def test_unknown_tool_returns_unknown_contract(self):
from src.agent._confirmation import build_confirmation_contract
from ss_tools.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract("some_unknown_tool")
assert c["risk"] == "read"
assert c["risk_level"] == "safe"
assert c["prompt"] == "Разрешить чтение данных?"
def test_none_tool_name_returns_unknown(self):
from src.agent._confirmation import build_confirmation_contract
from ss_tools.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract(None)
assert c["operation"] == "unknown_action"
assert c["risk"] == "read"
@@ -97,7 +97,7 @@ class TestBuildConfirmationContract:
# @BRIEF Test confirmation_metadata_for_tool output shape.
class TestConfirmationMetadataForTool:
def test_includes_all_required_fields(self):
from src.agent._confirmation import confirmation_metadata_for_tool
from ss_tools.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-42", "list_environments", {"env_id": "prod"})
assert meta["type"] == "confirm_required"
assert meta["thread_id"] == "conv-42"
@@ -109,12 +109,12 @@ class TestConfirmationMetadataForTool:
assert meta["intent"]["operation"] == "list_environments"
def test_empty_args_defaults_to_empty_dict(self):
from src.agent._confirmation import confirmation_metadata_for_tool
from ss_tools.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-1", "list_environments", None)
assert meta["tool_args"] == {}
def test_no_args_passed_defaults_to_empty_dict(self):
from src.agent._confirmation import confirmation_metadata_for_tool
from ss_tools.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-1", "list_environments")
assert meta["tool_args"] == {}
# #endregion test_confirmation_metadata_for_tool
@@ -129,7 +129,7 @@ class TestConfirmationMetadataForTool:
class TestFormatToolOutput:
@pytest.mark.asyncio
async def test_empty_output_yields_placeholder(self):
from src.agent._confirmation import _format_tool_output_via_llm
from ss_tools.agent._confirmation import _format_tool_output_via_llm
chunks = await _collect(_format_tool_output_via_llm("test_tool", ""))
data = _json_chunks(chunks)
assert len(data) == 1
@@ -137,7 +137,7 @@ class TestFormatToolOutput:
@pytest.mark.asyncio
async def test_whitespace_only_yields_placeholder(self):
from src.agent._confirmation import _format_tool_output_via_llm
from ss_tools.agent._confirmation import _format_tool_output_via_llm
chunks = await _collect(_format_tool_output_via_llm("test_tool", " "))
data = _json_chunks(chunks)
assert len(data) == 1
@@ -146,7 +146,7 @@ class TestFormatToolOutput:
@pytest.mark.asyncio
async def test_llm_formatting_streams_tokens(self):
"""When LLM config exists, output is streamed through ChatOpenAI."""
from src.agent._confirmation import _format_tool_output_via_llm
from ss_tools.agent._confirmation import _format_tool_output_via_llm
fake_llm = MagicMock()
fake_llm.astream = MagicMock(return_value=_make_async_iter([
@@ -155,8 +155,8 @@ class TestFormatToolOutput:
_make_fake_llm_chunk(" окружения."),
]))
with patch("src.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
patch("src.agent._confirmation.ChatOpenAI", return_value=fake_llm):
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
patch("ss_tools.agent._confirmation.ChatOpenAI", return_value=fake_llm):
mock_cfg.return_value = {
"configured": True,
"api_key": "sk-test",
@@ -177,10 +177,10 @@ class TestFormatToolOutput:
@pytest.mark.asyncio
async def test_fallback_when_llm_unavailable(self):
"""When fetch_llm_config returns None, fall back to prettified JSON."""
from src.agent._confirmation import _format_tool_output_via_llm
from ss_tools.agent._confirmation import _format_tool_output_via_llm
raw = '[{"id":"ss-dev","name":"Dev"}]'
with patch("src.agent.langgraph_setup._fetch_llm_config", return_value=None):
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config", return_value=None):
chunks = await _collect(_format_tool_output_via_llm("list_environments", raw))
data = _json_chunks(chunks)
assert len(data) == 1
@@ -192,10 +192,10 @@ class TestFormatToolOutput:
@pytest.mark.asyncio
async def test_fallback_when_config_not_configured(self):
"""When config exists but configured=False, fall back to prettified JSON."""
from src.agent._confirmation import _format_tool_output_via_llm
from ss_tools.agent._confirmation import _format_tool_output_via_llm
raw = '{"key": "value"}'
with patch("src.agent.langgraph_setup._fetch_llm_config",
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config",
return_value={"configured": False}):
chunks = await _collect(_format_tool_output_via_llm("some_tool", raw))
data = _json_chunks(chunks)
@@ -205,11 +205,11 @@ class TestFormatToolOutput:
@pytest.mark.asyncio
async def test_fallback_on_llm_exception(self):
"""When ChatOpenAI raises, fall back to prettified JSON."""
from src.agent._confirmation import _format_tool_output_via_llm
from ss_tools.agent._confirmation import _format_tool_output_via_llm
raw = '[{"a": 1}]'
with patch("src.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
patch("src.agent._confirmation.ChatOpenAI") as mock_llm_cls:
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
patch("ss_tools.agent._confirmation.ChatOpenAI") as mock_llm_cls:
mock_cfg.return_value = {
"configured": True,
"api_key": "sk-test",
@@ -224,10 +224,10 @@ class TestFormatToolOutput:
@pytest.mark.asyncio
async def test_non_json_output_passed_through_raw(self):
"""Non-JSON output (plain text) is yielded as-is in fallback."""
from src.agent._confirmation import _format_tool_output_via_llm
from ss_tools.agent._confirmation import _format_tool_output_via_llm
raw = "Operation completed successfully"
with patch("src.agent.langgraph_setup._fetch_llm_config", return_value=None):
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config", return_value=None):
chunks = await _collect(_format_tool_output_via_llm("test_tool", raw))
data = _json_chunks(chunks)
assert len(data) == 1
@@ -241,7 +241,7 @@ class TestFormatToolOutput:
class TestHandleResumeIntegration:
@pytest.mark.asyncio
async def test_deny_yields_cancelled(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
_pending_confirmations["conv-deny"] = {
"tool_name": "list_environments",
"tool_args": {},
@@ -257,7 +257,7 @@ class TestHandleResumeIntegration:
@pytest.mark.asyncio
async def test_confirm_executes_tool_and_formats_via_llm(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
tool = MagicMock()
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev","name":"Dev"}]')
@@ -267,8 +267,8 @@ class TestHandleResumeIntegration:
}
async def collect():
with patch("src.agent._confirmation.find_tool", return_value=tool), \
patch("src.agent._confirmation._format_tool_output_via_llm") as mock_fmt:
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool), \
patch("ss_tools.agent._confirmation._format_tool_output_via_llm") as mock_fmt:
async def _fake_fmt(tool_name, output):
yield json.dumps({
"content": f"SUMMARY: {tool_name} -> {output[:20]}",
@@ -302,14 +302,14 @@ class TestHandleResumeIntegration:
@pytest.mark.asyncio
async def test_confirm_unknown_tool_yields_error(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
_pending_confirmations["conv-unknown"] = {
"tool_name": "nonexistent_tool_xyz",
"tool_args": {},
}
with patch("src.agent._confirmation.find_tool", return_value=None):
with patch("ss_tools.agent._confirmation.find_tool", return_value=None):
chunks = await _collect(handle_resume("conv-unknown", "confirm"))
data = _json_chunks(chunks)
@@ -325,7 +325,7 @@ class TestHandleResumeIntegration:
@pytest.mark.asyncio
async def test_confirm_tool_invocation_failure_yields_error(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
tool = MagicMock()
tool.ainvoke = AsyncMock(side_effect=RuntimeError("API timeout"))
@@ -334,7 +334,7 @@ class TestHandleResumeIntegration:
"tool_args": {},
}
with patch("src.agent._confirmation.find_tool", return_value=tool):
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool):
chunks = await _collect(handle_resume("conv-fail", "confirm"))
data = _json_chunks(chunks)
@@ -351,11 +351,11 @@ class TestHandleResumeIntegration:
async def test_confirm_with_no_pending_falls_to_langgraph(self):
"""When no pending confirmation exists, handle_resume should fall through
to the LangGraph checkpoint resume path."""
from src.agent._confirmation import handle_resume
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
patch("src.agent._confirmation.get_all_tools", return_value=[]):
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
chunks = await _collect(handle_resume("conv-no-pending", "confirm"))
data = _json_chunks(chunks)
assert len(data) == 1
@@ -366,11 +366,11 @@ class TestHandleResumeIntegration:
async def test_deny_with_no_pending_returns_denied(self):
"""When no pending confirmation exists, deny also goes through
the LangGraph checkpoint path."""
from src.agent._confirmation import handle_resume
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
patch("src.agent._confirmation.get_all_tools", return_value=[]):
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
chunks = await _collect(handle_resume("conv-no-pending", "deny"))
data = _json_chunks(chunks)
assert len(data) == 1
@@ -380,7 +380,7 @@ class TestHandleResumeIntegration:
@pytest.mark.asyncio
async def test_confirm_streams_langgraph_events_when_no_pending(self):
"""When no pending and LangGraph agent streams events, they are forwarded."""
from src.agent._confirmation import handle_resume
from ss_tools.agent._confirmation import handle_resume
mock_chunk = MagicMock()
mock_chunk.content = "Hello from checkpoint"
@@ -391,8 +391,8 @@ class TestHandleResumeIntegration:
{"event": "on_tool_end", "name": "some_tool", "data": {"output": "ok"}},
]))
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
patch("src.agent._confirmation.get_all_tools", return_value=[]):
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
chunks = await _collect(handle_resume("conv-stream", "confirm"))
data = _json_chunks(chunks)
@@ -417,7 +417,7 @@ class TestTitleRaceCondition:
async def test_tool_name_read_before_pop(self):
"""Simulate the exact flow from agent_handler: capture tool_name from
_pending_confirmations BEFORE calling handle_resume (which pops it)."""
from src.agent._confirmation import _pending_confirmations
from ss_tools.agent._confirmation import _pending_confirmations
conv_id = "title-race-test"
_pending_confirmations[conv_id] = {
@@ -449,7 +449,7 @@ class TestTitleRaceCondition:
@pytest.mark.asyncio
async def test_race_condition_without_fix_would_fallback(self):
"""Demonstrate the bug: if tool_name is read AFTER pop, it's lost."""
from src.agent._confirmation import _pending_confirmations
from ss_tools.agent._confirmation import _pending_confirmations
conv_id = "race-bug-test"
_pending_confirmations[conv_id] = {

View File

@@ -37,8 +37,8 @@ class TestToolRegistration:
def test_all_new_tools_registered(self):
"""All 7 new tools appear in get_all_tools()."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
@@ -56,8 +56,8 @@ class TestToolRegistration:
@pytest.mark.skip(reason="_SAFE_AGENT_TOOLS removed during 035 refactoring — needs test rewrite for new tool registry API")
def test_tool_resolver_sets_contain_new_tools(self):
"""Classification sets in _tool_resolver.py contain the new tools."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent._tool_resolver import (
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent._tool_resolver import (
_SAFE_AGENT_TOOLS,
_GUARDED_AGENT_TOOLS,
_FAST_CONFIRM_TOOLS,
@@ -86,8 +86,8 @@ class TestIntentMatching:
pytestmark = pytest.mark.skip(reason="get_tools_for_query removed during 035 refactoring — needs test rewrite for new tool pipeline API")
def _get_for_query(self, query):
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_tools_for_query
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_tools_for_query
return {t.name for t in get_tools_for_query(query)}
def test_sql_intent(self):
@@ -138,10 +138,10 @@ class TestSupersetExecuteSql:
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
@@ -149,7 +149,7 @@ class TestSupersetExecuteSql:
"""Safe SELECT returns API result."""
mock_resp = _mock_httpx_response(200, text='{"status": "success", "data": []}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_execute_sql
from ss_tools.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -162,7 +162,7 @@ class TestSupersetExecuteSql:
"""Dangerous SQL returns error from API (guard is server-side)."""
mock_resp = _mock_httpx_response(200, text='{"error": "Dangerous SQL rejected"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_execute_sql
from ss_tools.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -176,7 +176,7 @@ class TestSupersetExecuteSql:
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_execute_sql
from ss_tools.agent.tools import superset_execute_sql
await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -190,7 +190,7 @@ class TestSupersetExecuteSql:
"""HTTP error returns error string."""
mock_resp = _mock_httpx_response(500, text="Internal Server Error")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_execute_sql
from ss_tools.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -208,10 +208,10 @@ class TestSupersetExploreDatabase:
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
@@ -219,7 +219,7 @@ class TestSupersetExploreDatabase:
"""Action 'schemas' GETs /api/agent/superset/databases/{id}/schemas."""
mock_resp = _mock_httpx_response(200, text='["public", "analytics"]')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -232,7 +232,7 @@ class TestSupersetExploreDatabase:
"""Action 'tables' GETs /api/agent/superset/databases/{id}/tables."""
mock_resp = _mock_httpx_response(200, text="table1, table2")
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -246,7 +246,7 @@ class TestSupersetExploreDatabase:
"""Action 'table_metadata' GETs metadata endpoint."""
mock_resp = _mock_httpx_response(200, text='{"columns": []}')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -260,7 +260,7 @@ class TestSupersetExploreDatabase:
"""Action 'select_star' GETs select_star endpoint."""
mock_resp = _mock_httpx_response(200, text='"SELECT * FROM users"')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -273,7 +273,7 @@ class TestSupersetExploreDatabase:
async def test_tables_missing_schema_returns_error(self):
"""Missing schema_name for 'tables' returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from src.agent.tools import superset_explore_database
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -286,7 +286,7 @@ class TestSupersetExploreDatabase:
async def test_table_metadata_missing_table_name_returns_error(self):
"""Missing table_name for 'table_metadata' returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from src.agent.tools import superset_explore_database
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -299,7 +299,7 @@ class TestSupersetExploreDatabase:
async def test_unknown_action_returns_error(self):
"""Unknown action returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from src.agent.tools import superset_explore_database
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
@@ -318,10 +318,10 @@ class TestSupersetAuditPermissions:
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
@@ -329,7 +329,7 @@ class TestSupersetAuditPermissions:
"""Permissions audit GETs /api/agent/superset/audit/permissions."""
mock_resp = _mock_httpx_response(200, text='{"users": [], "total_users": 0}')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_audit_permissions
from ss_tools.agent.tools import superset_audit_permissions
result = await superset_audit_permissions.ainvoke({
"environment_id": "prod",
})
@@ -341,7 +341,7 @@ class TestSupersetAuditPermissions:
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = mock_resp
from src.agent.tools import superset_audit_permissions
from ss_tools.agent.tools import superset_audit_permissions
await superset_audit_permissions.ainvoke({
"environment_id": "prod",
"username_filter": "alice",
@@ -357,7 +357,7 @@ class TestSupersetAuditPermissions:
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = mock_resp
from src.agent.tools import superset_audit_permissions
from ss_tools.agent.tools import superset_audit_permissions
await superset_audit_permissions.ainvoke({
"environment_id": "prod",
})
@@ -375,10 +375,10 @@ class TestSupersetCreateDashboard:
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
@@ -386,7 +386,7 @@ class TestSupersetCreateDashboard:
"""Creates dashboard via POST to /api/agent/superset/dashboards."""
mock_resp = _mock_httpx_response(201, text='{"id": 1, "dashboard_title": "Sales"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_create_dashboard
from ss_tools.agent.tools import superset_create_dashboard
result = await superset_create_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_title": "Sales",
@@ -401,7 +401,7 @@ class TestSupersetCreateDashboard:
mock_resp = _mock_httpx_response(201, text='{"id": 2}')
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_create_dashboard
from ss_tools.agent.tools import superset_create_dashboard
await superset_create_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_title": "Minimal",
@@ -420,10 +420,10 @@ class TestSupersetCopyDashboard:
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
@@ -431,7 +431,7 @@ class TestSupersetCopyDashboard:
"""Copies dashboard via POST to /api/agent/superset/dashboards/{id}/copy."""
mock_resp = _mock_httpx_response(201, text='{"id": 2, "result": "copied"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_copy_dashboard
from ss_tools.agent.tools import superset_copy_dashboard
result = await superset_copy_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_id": 1,
@@ -445,7 +445,7 @@ class TestSupersetCopyDashboard:
mock_resp = _mock_httpx_response(201, text="{}")
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_copy_dashboard
from ss_tools.agent.tools import superset_copy_dashboard
await superset_copy_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_id": 1,
@@ -463,10 +463,10 @@ class TestSupersetCreateDataset:
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
@@ -474,7 +474,7 @@ class TestSupersetCreateDataset:
"""Creates dataset via POST to /api/agent/superset/datasets."""
mock_resp = _mock_httpx_response(201, text='{"id": 5, "table_name": "orders"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_create_dataset
from ss_tools.agent.tools import superset_create_dataset
result = await superset_create_dataset.ainvoke({
"environment_id": "prod",
"table_name": "orders",
@@ -489,7 +489,7 @@ class TestSupersetCreateDataset:
mock_resp = _mock_httpx_response(201, text='{"id": 6}')
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_create_dataset
from ss_tools.agent.tools import superset_create_dataset
await superset_create_dataset.ainvoke({
"environment_id": "prod",
"table_name": "users",
@@ -508,10 +508,10 @@ class TestSupersetFormatSql:
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
@@ -519,7 +519,7 @@ class TestSupersetFormatSql:
"""Formats SQL via POST to /api/agent/superset/sqllab/format."""
mock_resp = _mock_httpx_response(200, text="SELECT\n 1\nFROM\n users\n")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_format_sql
from ss_tools.agent.tools import superset_format_sql
result = await superset_format_sql.ainvoke({
"environment_id": "prod",
"sql": "SELECT 1 FROM users",
@@ -531,7 +531,7 @@ class TestSupersetFormatSql:
"""HTTP error returns error string."""
mock_resp = _mock_httpx_response(500, text="Internal Error")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_format_sql
from ss_tools.agent.tools import superset_format_sql
result = await superset_format_sql.ainvoke({
"environment_id": "prod",
"sql": "INVALID",
@@ -549,8 +549,8 @@ class TestToolResolverInference:
def test_infer_from_text_sql(self):
"""SQL-related query infers superset_execute_sql."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent._tool_resolver import infer_tool_from_text
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent._tool_resolver import infer_tool_from_text
# The resolver doesn't currently have SQL inference — verify fallback
# but ensure it's extended if needed in the future
result = infer_tool_from_text("sql select query users")

View File

@@ -1,4 +1,4 @@
# backend/tests/agent/test_title_cleaning.py
# agent/tests/agent/test_title_cleaning.py
# #region Test.Agent.Persistence.CleanTitle [C:2] [TYPE Module] [SEMANTICS test,agent,persistence,title]
# @BRIEF Unit tests for rule-based conversation title cleaning — all edge cases.
# @RELATION BINDS_TO -> [AgentChat.Persistence.CleanTitle]
@@ -12,7 +12,7 @@
# @TEST_EDGE: long_text -> truncated at 80 with word boundary
# @TEST_EDGE: code_input -> first line preserved
import pytest
from src.agent._persistence import clean_title
from ss_tools.agent._persistence import clean_title
class TestCleanTitle:

View File

@@ -1,4 +1,4 @@
# backend/tests/test_agent/test_agent_confirmation_v2.py
# agent/tests/test_agent/test_agent_confirmation_v2.py
# #region Test.Agent.ConfirmationV2 [C:3] [TYPE Module] [SEMANTICS test,agent-chat,confirmation,guardrails,hitl]
# @BRIEF Contract tests for confirmation_metadata_for_tool and build_confirmation_contract_v2 — three-axis risk, env resolution, permission denied.
# @RELATION BINDS_TO -> [AgentChat.Confirmation]
@@ -11,7 +11,7 @@
# @TEST_FIXTURE: env_resolution -> tool_args.env_id > target_env > None
from src.agent._confirmation import (
from ss_tools.agent._confirmation import (
_resolve_env_tier,
build_confirmation_contract_v2,
confirmation_metadata_for_tool,

View File

@@ -1,4 +1,4 @@
# backend/tests/test_agent/test_agent_context.py
# agent/tests/test_agent/test_agent_context.py
# #region Test.Agent.Context [C:3] [TYPE Module] [SEMANTICS test,agent-chat,context,validation,uicontext]
# @BRIEF Contract tests for UIContext validation — enum checks, size limits, field lengths, boundary values.
# @RELATION BINDS_TO -> [AgentChat.Context.Validate]
@@ -16,7 +16,7 @@
import pytest
from src.agent._context import UIContextValidationError, validate_uicontext
from ss_tools.agent._context import UIContextValidationError, validate_uicontext
# #region test_null_payload_returns_empty [C:2] [TYPE Function]

View File

@@ -28,7 +28,7 @@ def anyio_backend():
@pytest.fixture(autouse=True)
def mock_save_conversation():
with patch("src.agent.app.save_conversation", new_callable=AsyncMock):
with patch("ss_tools.agent.app.save_conversation", new_callable=AsyncMock):
yield
@@ -48,7 +48,7 @@ def _empty_agent_state():
@pytest.mark.anyio
async def test_handler_empty_message_returns_immediately():
"""An empty message should return immediately without calling the graph."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
@@ -57,7 +57,7 @@ async def test_handler_empty_message_returns_immediately():
mock_request.client.host = "127.0.0.1"
# Patch create_agent to avoid OpenAI init
with patch("src.agent.langgraph_setup.create_agent"):
with patch("ss_tools.agent.langgraph_setup.create_agent"):
# Empty message
message = {"text": "", "files": None}
results = []
@@ -82,7 +82,7 @@ async def test_handler_empty_message_returns_immediately():
@pytest.mark.anyio
async def test_handler_missing_auth_continues_gracefully():
"""Missing authorization header does NOT yield UNAUTHORIZED — handler continues."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
@@ -92,7 +92,7 @@ async def test_handler_missing_auth_continues_gracefully():
message = {"text": "hello", "files": None}
# Patch create_agent to prevent LLM call — handler should proceed without JWT
with patch("src.agent.app.create_agent") as mock_create:
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
@@ -120,7 +120,7 @@ async def test_handler_missing_auth_continues_gracefully():
@pytest.mark.anyio
async def test_handler_invalid_jwt_continues_gracefully():
"""Invalid JWT does NOT yield UNAUTHORIZED — handler continues with fallback context."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
@@ -129,7 +129,7 @@ async def test_handler_invalid_jwt_continues_gracefully():
message = {"text": "hello", "files": None}
with patch("src.agent.app.create_agent") as mock_create:
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
@@ -160,7 +160,7 @@ async def test_handler_invalid_jwt_continues_gracefully():
@pytest.mark.anyio
async def test_handler_yields_stream_tokens():
"""Handler yields stream_token metadata when graph emits token events."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
@@ -171,7 +171,7 @@ async def test_handler_yields_stream_tokens():
message = {"text": "hello", "files": None}
# Patch create_agent to return a mock that streams events
with patch("src.agent.app.create_agent") as mock_create:
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _mock_stream(*_args, **_kwargs):
@@ -201,7 +201,7 @@ async def test_handler_yields_stream_tokens():
@pytest.mark.anyio
async def test_handler_resume_confirm():
"""When action='confirm', handler resumes via Command(resume=...)."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
@@ -213,7 +213,7 @@ async def test_handler_resume_confirm():
# Patch create_agent in _confirmation because handle_resume (now in _confirmation.py)
# imports create_agent directly from langgraph_setup
with patch("src.agent._confirmation.create_agent") as mock_create:
with patch("ss_tools.agent._confirmation.create_agent") as mock_create:
mock_graph = MagicMock()
async def _empty_stream(*_args, **_kwargs):
@@ -244,7 +244,7 @@ async def test_handler_resume_confirm():
@pytest.mark.anyio
async def test_handler_resume_deny():
"""When action='deny', handler yields confirm_resolved with denied."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
@@ -254,7 +254,7 @@ async def test_handler_resume_deny():
message = {"text": "deny", "files": None}
with patch("src.agent._confirmation.create_agent") as mock_create:
with patch("ss_tools.agent._confirmation.create_agent") as mock_create:
mock_graph = MagicMock()
mock_create.return_value = mock_graph

View File

@@ -1,4 +1,4 @@
# backend/tests/test_agent/test_agent_tool_filter.py
# agent/tests/test_agent/test_agent_tool_filter.py
# #region Test.Agent.ToolFilter [C:3] [TYPE Module] [SEMANTICS test,agent-chat,tools,filter,pipeline,rbac]
# @BRIEF Contract tests for build_tool_pipeline and enforce_tool_permission — two-layer RBAC + context affinity.
# @RELATION BINDS_TO -> [AgentChat.ToolFilter]
@@ -17,7 +17,7 @@
from types import SimpleNamespace
from src.agent._tool_filter import (
from ss_tools.agent._tool_filter import (
_CONTEXT_TOOL_AFFINITY,
_TOOL_PERMISSIONS,
build_tool_pipeline,

View File

@@ -56,7 +56,7 @@ def _make_agent_mock(stream_events=None, raise_on_call=False):
@pytest.fixture(autouse=True)
def clear_locks():
"""Clear _user_locks before each test."""
from src.agent.app import _user_locks
from ss_tools.agent.app import _user_locks
_user_locks.clear()
@@ -72,25 +72,25 @@ def mock_request():
# @BRIEF Test extract_user_id for various JWT payloads.
class TestExtractUserId:
def test_extracts_sub(self):
from src.agent._persistence import extract_user_id
from ss_tools.agent._persistence import extract_user_id
with patch("src.agent._jwt_decoder.decode_token", return_value={"sub": "user-1"}):
with patch("ss_tools.agent._jwt_decoder.decode_token", return_value={"sub": "user-1"}):
assert extract_user_id("fake-jwt") == "user-1"
def test_extracts_user_id_fallback(self):
from src.agent._persistence import extract_user_id
from ss_tools.agent._persistence import extract_user_id
with patch("src.agent._jwt_decoder.decode_token", return_value={"user_id": "user-2"}):
with patch("ss_tools.agent._jwt_decoder.decode_token", return_value={"user_id": "user-2"}):
assert extract_user_id("fake-jwt") == "user-2"
def test_returns_unknown_on_exception(self):
from src.agent._persistence import extract_user_id
from ss_tools.agent._persistence import extract_user_id
with patch("src.agent._jwt_decoder.decode_token", side_effect=Exception("bad token")):
with patch("ss_tools.agent._jwt_decoder.decode_token", side_effect=Exception("bad token")):
assert extract_user_id("bad") == "unknown"
def test_returns_unknown_on_empty(self):
from src.agent._persistence import extract_user_id
from ss_tools.agent._persistence import extract_user_id
assert extract_user_id("") == "unknown"
@@ -102,7 +102,7 @@ class TestExtractUserId:
# @BRIEF Test backend HITL confirmation contract exposed to the frontend.
class TestConfirmationMetadata:
def test_extracts_tool_call_and_read_contract(self):
from src.agent._confirmation import confirmation_metadata
from ss_tools.agent._confirmation import confirmation_metadata
msg = MagicMock()
msg.tool_calls = [{"name": "list_environments", "args": {"env_id": "prod"}}]
@@ -123,7 +123,7 @@ class TestConfirmationMetadata:
assert meta["intent"]["operation"] == "list_environments"
def test_infers_tool_from_text_when_state_only_has_graph_node(self):
from src.agent._confirmation import confirmation_metadata
from ss_tools.agent._confirmation import confirmation_metadata
state = MagicMock()
state.values = {"messages": []}
@@ -138,7 +138,7 @@ class TestConfirmationMetadata:
assert meta["risk_level"] == "safe"
def test_write_contract_for_guarded_tool(self):
from src.agent._confirmation import confirmation_metadata
from ss_tools.agent._confirmation import confirmation_metadata
msg = MagicMock()
msg.tool_calls = [
@@ -165,7 +165,7 @@ class TestConfirmationMetadata:
assert meta["required_role"] == "admin"
def test_unknown_contract_does_not_expose_graph_node_as_tool(self):
from src.agent._confirmation import confirmation_metadata
from ss_tools.agent._confirmation import confirmation_metadata
state = MagicMock()
state.values = {"messages": []}
@@ -178,7 +178,7 @@ class TestConfirmationMetadata:
assert meta["risk_level"] == "safe"
def test_fast_resume_deny_closes_without_langgraph(self):
from src.agent._confirmation import _pending_confirmations, handle_resume
from ss_tools.agent._confirmation import _pending_confirmations, handle_resume
_pending_confirmations["conv-fast-deny"] = {
"tool_name": "list_environments",
@@ -193,7 +193,7 @@ class TestConfirmationMetadata:
assert data["metadata"] == {"type": "confirm_resolved", "result": "denied"}
def test_fast_resume_confirm_executes_tool_directly(self):
from src.agent._confirmation import _pending_confirmations, handle_resume
from ss_tools.agent._confirmation import _pending_confirmations, handle_resume
tool = MagicMock()
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev"}]')
@@ -203,7 +203,7 @@ class TestConfirmationMetadata:
}
async def collect():
with patch("src.agent._confirmation.find_tool", return_value=tool), patch("src.agent._confirmation._format_tool_output_via_llm") as mock_format:
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool), patch("ss_tools.agent._confirmation._format_tool_output_via_llm") as mock_format:
# Make the mock format helper yield nothing — don't need LLM in unit test
async def _empty_format(*_args, **_kwargs):
return
@@ -225,7 +225,7 @@ class TestConfirmationMetadata:
class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_concurrent_send(self, mock_request):
from src.agent.app import _user_locks, agent_handler
from ss_tools.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no user_id_str or JWT is provided
_user_locks["admin"] = True
@@ -236,7 +236,7 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_file_too_large(self, mock_request):
from src.agent.app import MAX_FILE_SIZE_BYTES, agent_handler
from ss_tools.agent.app import MAX_FILE_SIZE_BYTES, agent_handler
message = {"text": "analyze", "files": ["fake_path"]}
with patch("os.path.exists", return_value=True), patch("os.path.getsize", return_value=MAX_FILE_SIZE_BYTES + 1):
@@ -247,12 +247,12 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_hitl_confirm(self, mock_request):
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
# handle_resume is imported into app.py from _confirmation
with patch("src.agent.app.handle_resume") as mock_resume:
with patch("ss_tools.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([json.dumps({"content": "confirmed", "metadata": {"type": "confirm_resolved"}})])
with patch("src.agent.app.save_conversation", AsyncMock()):
with patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("confirm", [], mock_request, "conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
@@ -260,11 +260,11 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_hitl_deny(self, mock_request):
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
with patch("src.agent.app.handle_resume") as mock_resume:
with patch("ss_tools.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([json.dumps({"content": "denied", "metadata": {"type": "confirm_resolved", "result": "denied"}})])
with patch("src.agent.app.save_conversation", AsyncMock()):
with patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("deny", [], mock_request, "conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
@@ -272,7 +272,7 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_normal_send_yields_tokens(self, mock_request):
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "Hello"
@@ -283,7 +283,7 @@ class TestAgentHandler:
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
@@ -292,7 +292,7 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_normal_send_hides_runtime_context_from_saved_history(self, mock_request):
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "ok"
@@ -304,11 +304,11 @@ class TestAgentHandler:
save_mock = AsyncMock()
with (
patch("src.agent.app.create_agent", return_value=agent),
patch("src.agent.app.get_all_tools", return_value=[]),
patch("src.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")),
patch("src.agent.app.generate_llm_title", AsyncMock()),
patch("src.agent.app.save_conversation", save_mock),
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")),
patch("ss_tools.agent.app.generate_llm_title", AsyncMock()),
patch("ss_tools.agent.app.save_conversation", save_mock),
):
results = [
r
@@ -336,7 +336,7 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_tool_events_yielded(self, mock_request):
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_start", "name": "test_tool", "data": {"input": {}}},
@@ -344,7 +344,7 @@ class TestAgentHandler:
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()), patch("src.agent.app.log_tool_event", AsyncMock()):
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()), patch("ss_tools.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 2
@@ -353,14 +353,14 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_tool_error_event(self, mock_request):
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_error", "name": "test_tool", "data": {"error": "something failed"}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()), patch("src.agent.app.log_tool_event", AsyncMock()):
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()), patch("ss_tools.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@@ -370,7 +370,7 @@ class TestAgentHandler:
async def test_output_parser_exception_retry(self, mock_request):
from langchain_core.exceptions import OutputParserException
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
mock_stream_after = _make_async_iter([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}])
call_count = [0]
@@ -384,7 +384,7 @@ class TestAgentHandler:
agent = MagicMock()
agent.astream_events = mock_astream
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
@@ -393,7 +393,7 @@ class TestAgentHandler:
async def test_output_parser_exception_final_failure(self, mock_request):
from langchain_core.exceptions import OutputParserException
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
call_count = [0]
@@ -404,7 +404,7 @@ class TestAgentHandler:
agent = MagicMock()
agent.astream_events = mock_astream
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@@ -413,7 +413,7 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_non_llm_error_saves_conversation(self, mock_request):
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RuntimeError("API connection error")
@@ -422,7 +422,7 @@ class TestAgentHandler:
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", save_mock):
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", save_mock):
# Handler catches RuntimeError, yields a pipeline result first, then error chunk
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
@@ -436,7 +436,7 @@ class TestAgentHandler:
"""RateLimitError (429) yields LLM_RATE_LIMITED, not guardrails card."""
from openai import RateLimitError
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RateLimitError(
@@ -449,7 +449,7 @@ class TestAgentHandler:
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", save_mock):
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@@ -462,7 +462,7 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_generic_exception_with_llm_keyword_yields_error_not_guardrails(self, mock_request):
"""Generic exception containing 'quota' keyword yields error, not confirmation."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RuntimeError("All codex accounts reached configured quota threshold")
@@ -474,7 +474,7 @@ class TestAgentHandler:
agent.aget_state = AsyncMock(return_value=MagicMock(next=("tools",)))
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", save_mock):
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@@ -487,28 +487,36 @@ class TestAgentHandler:
async def test_invalid_jwt_passes_gracefully(self):
from jose import JWTError
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
req = MagicMock()
req.headers = {"authorization": "Bearer invalid.jwt"}
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.decode_token", side_effect=JWTError("invalid")), patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
with patch("ss_tools.agent.app.decode_token", side_effect=JWTError("invalid")), patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], req, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@pytest.mark.asyncio
async def test_valid_user_jwt_with_audience_is_kept_for_tool_auth(self, mock_request):
from src.agent.app import agent_handler
from src.agent.context import get_user_jwt
from src.core.auth.jwt import create_access_token
from ss_tools.agent.app import agent_handler
from ss_tools.agent.context import get_user_jwt
token = create_access_token({"sub": "admin", "scopes": ["Admin"]})
# Create a test JWT using the same jose library the agent uses
from datetime import datetime, timedelta, timezone
from jose import jwt as jose_jwt
import os
secret = os.getenv("AUTH_SECRET_KEY", "test-secret-key-for-unit-tests")
token = jose_jwt.encode(
{"sub": "admin", "scopes": ["Admin"], "exp": datetime.now(timezone.utc) + timedelta(hours=1)},
secret,
algorithm="HS256",
)
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
agent = _make_agent_mock(mock_event_stream)
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()):
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
filtered = _skip_pipeline(results)
@@ -524,10 +532,10 @@ class TestAgentHandler:
class TestHandleResume:
@pytest.mark.asyncio
async def test_confirm_checkpoint_resume(self):
from src.agent._confirmation import handle_resume
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.agent._confirmation.get_all_tools", return_value=[]):
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
@@ -535,10 +543,10 @@ class TestHandleResume:
@pytest.mark.asyncio
async def test_deny_checkpoint_resume(self):
from src.agent._confirmation import handle_resume
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.agent._confirmation.get_all_tools", return_value=[]):
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
@@ -553,36 +561,36 @@ class TestHandleResume:
class TestSaveConversation:
@pytest.mark.asyncio
async def test_save_success(self):
from src.agent._persistence import save_conversation
from ss_tools.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "test message", "user-1")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_with_service_jwt(self):
from src.agent._persistence import save_conversation
from ss_tools.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client, patch("src.agent._persistence.os.getenv", return_value="service-token"):
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client, patch("ss_tools.agent._persistence.os.getenv", return_value="service-token"):
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "hello", "admin")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_failure_logged(self):
from src.agent._persistence import save_conversation
from ss_tools.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err")
# Should not raise
await save_conversation("conv-1", "msg", "u1")
@pytest.mark.asyncio
async def test_save_empty_title(self):
from src.agent._persistence import save_conversation
from ss_tools.agent._persistence import save_conversation
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client:
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = client_instance
await save_conversation("conv-1", " ", "user-1")
@@ -598,9 +606,9 @@ class TestSaveConversation:
# @BRIEF Test create_chat_interface returns a gr.ChatInterface.
class TestCreateChatInterface:
def test_returns_chat_interface(self):
from src.agent.app import create_chat_interface
from ss_tools.agent.app import create_chat_interface
with patch("src.agent.app.gr.ChatInterface") as mock_ci:
with patch("ss_tools.agent.app.gr.ChatInterface") as mock_ci:
result = create_chat_interface()
assert result is mock_ci.return_value
@@ -613,7 +621,7 @@ class TestCreateChatInterface:
class TestHealth:
@pytest.mark.asyncio
async def test_health_returns_ok(self):
from src.agent.app import health
from ss_tools.agent.app import health
result = await health()
assert result["status"] == "ok"
@@ -628,7 +636,7 @@ class TestFileUploadParsing:
@pytest.mark.asyncio
async def test_handler_parses_uploaded_file(self, mock_request, tmp_path):
"""Valid small file triggers parse_upload and continues to stream."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
test_file = tmp_path / "test.txt"
test_file.write_text("upload content for analysis")
@@ -636,11 +644,11 @@ class TestFileUploadParsing:
message = {"text": "analyze", "files": [str(test_file)]}
with (
patch("src.agent.app.create_agent") as mock_create,
patch("src.agent.app.get_all_tools", return_value=[]),
patch("src.agent.app.save_conversation", AsyncMock()),
patch("src.agent.app.log_tool_event", AsyncMock()),
patch("src.agent.app._persist_chat_file", return_value="chat_uploads/test.txt"),
patch("ss_tools.agent.app.create_agent") as mock_create,
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
patch("ss_tools.agent.app.log_tool_event", AsyncMock()),
patch("ss_tools.agent.app._persist_chat_file", return_value="chat_uploads/test.txt"),
):
agent = _make_agent_mock([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="result")}}])
mock_create.return_value = agent
@@ -665,7 +673,7 @@ class TestAppMainBlock:
import importlib.util
from pathlib import Path
app_path = Path(__file__).parent.parent.parent / "src" / "agent" / "app.py"
app_path = Path(__file__).parent.parent.parent / "src" / "ss_tools" / "agent" / "app.py"
spec = importlib.util.spec_from_file_location("__main__", str(app_path))
mock_demo = MagicMock()

View File

@@ -30,7 +30,7 @@ def _make_test_jwt(user_id: str = "test-user") -> str:
@pytest.fixture(autouse=True)
def mock_save_conversation():
with patch("src.agent.app.save_conversation", new_callable=AsyncMock):
with patch("ss_tools.agent.app.save_conversation", new_callable=AsyncMock):
yield
@@ -41,7 +41,7 @@ def mock_save_conversation():
@pytest.mark.asyncio
async def test_concurrent_send_lock():
"""Handler rejects concurrent sends from same user with CONCURRENT_SEND error."""
from src.agent.app import _user_locks, agent_handler
from ss_tools.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no JWT and no user_id_str are provided
_user_locks["admin"] = True
@@ -77,7 +77,7 @@ async def test_concurrent_send_lock():
@pytest.mark.asyncio
async def test_handler_unknown_action_treated_as_normal():
"""Handler with unknown action string proceeds as normal message send."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
@@ -87,7 +87,7 @@ async def test_handler_unknown_action_treated_as_normal():
message = {"text": "hello", "files": None}
with patch("src.agent.app.create_agent") as mock_create:
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = MagicMock()
async def _mock_stream(*_args, **_kwargs):
@@ -122,7 +122,7 @@ async def test_handler_unknown_action_treated_as_normal():
@pytest.mark.asyncio
async def test_handler_confirm_no_graph():
"""Handler with action='confirm' but create_agent raises handles it gracefully."""
from src.agent.app import agent_handler
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
@@ -132,7 +132,7 @@ async def test_handler_confirm_no_graph():
message = {"text": "confirm", "files": None}
with patch("src.agent._confirmation.create_agent") as mock_create:
with patch("ss_tools.agent._confirmation.create_agent") as mock_create:
mock_create.side_effect = Exception("Graph creation failed")
try:

View File

@@ -12,7 +12,7 @@ from unittest.mock import MagicMock, patch
import pytest
from src.agent.document_parser import parse_pdf, parse_xlsx, parse_upload, ParseError
from ss_tools.agent.document_parser import parse_pdf, parse_xlsx, parse_upload, ParseError
# #region TestAgentChat.DocumentParser.PDF [C:2] [TYPE Function] [SEMANTICS test,parser,pdf]
# @BRIEF PDF parsing: extracts text from valid PDF, handles empty and encrypted PDFs gracefully.
@@ -276,7 +276,7 @@ def test_parse_pdf_pypdf2_fallback():
def test_parse_upload_pdf_branch():
"""parse_upload with .pdf dispatches to parse_pdf."""
import src.agent.document_parser as dp
import ss_tools.agent.document_parser as dp
with patch.object(dp, 'parse_pdf', return_value="pdf result") as mock_parse:
result = parse_upload({"name": "report.pdf", "path": "/tmp/report.pdf"})
assert result == "pdf result"
@@ -285,7 +285,7 @@ def test_parse_upload_pdf_branch():
def test_parse_upload_xlsx_branch():
"""parse_upload with .xlsx dispatches to parse_xlsx."""
import src.agent.document_parser as dp
import ss_tools.agent.document_parser as dp
with patch.object(dp, 'parse_xlsx', return_value="xlsx result") as mock_parse:
result = parse_upload({"name": "data.xlsx", "path": "/tmp/data.xlsx"})
assert result == "xlsx result"
@@ -294,7 +294,7 @@ def test_parse_upload_xlsx_branch():
def test_parse_upload_xls_branch():
"""parse_upload with .xls (legacy) also dispatches to parse_xlsx."""
import src.agent.document_parser as dp
import ss_tools.agent.document_parser as dp
with patch.object(dp, 'parse_xlsx', return_value="xls result") as mock_parse:
result = parse_upload({"name": "legacy.xls", "path": "/tmp/legacy.xls"})
assert result == "xls result"

View File

@@ -9,9 +9,9 @@
import pytest
from types import SimpleNamespace
from src.agent._tool_filter import build_tool_pipeline
from src.agent.context import set_user_role
from src.agent.tools import _guard_tool_permission, _summarise_response
from ss_tools.agent._tool_filter import build_tool_pipeline
from ss_tools.agent.context import set_user_role
from ss_tools.agent.tools import _guard_tool_permission, _summarise_response
def _tools(names: list[str]) -> list[SimpleNamespace]:

View File

@@ -22,8 +22,8 @@ class TestToolCatalog:
def test_get_all_tools_returns_all_24(self):
"""get_all_tools() returns at least 24 tools."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
assert len(tools) >= 24, f"Expected ≥24 tools, got {len(tools)}: {tool_names}"
@@ -35,16 +35,16 @@ class TestToolCatalog:
def test_tool_names_are_unique(self):
"""No duplicate tool names in get_all_tools()."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
names = [t.name for t in tools]
assert len(names) == len(set(names)), f"Duplicate tool names: {names}"
def test_every_tool_has_docstring(self):
"""Every tool MUST have a docstring — enforced by LangChain but verified here."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
for t in get_all_tools():
desc = (t.description or "").strip()
assert desc, f"Tool '{t.name}' has empty description. LangChain @tool requires a docstring."
@@ -60,7 +60,7 @@ class TestEmbeddingMetadata:
def test_embedding_top_k_returns_empty_for_empty_query(self):
"""Empty query → empty result."""
try:
from src.agent._embedding_router import embedding_top_k
from ss_tools.agent._embedding_router import embedding_top_k
except ImportError:
pytest.skip("_embedding_router module not importable")
assert embedding_top_k("") == []
@@ -68,7 +68,7 @@ class TestEmbeddingMetadata:
def test_embedding_is_available_returns_bool(self):
"""embedding_is_available returns bool, does not raise."""
try:
from src.agent._embedding_router import embedding_is_available
from ss_tools.agent._embedding_router import embedding_is_available
except ImportError:
pytest.skip("_embedding_router module not importable")
result = embedding_is_available()
@@ -76,10 +76,10 @@ class TestEmbeddingMetadata:
def test_get_descriptions_matches_all_tools(self):
"""_get_descriptions() covers every tool in get_all_tools() 1:1."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
try:
from src.agent._embedding_router import _get_descriptions
from ss_tools.agent._embedding_router import _get_descriptions
except ImportError:
pytest.skip("_embedding_router module not importable")
@@ -100,21 +100,21 @@ class TestToolResolverUtils:
"""Utility functions in _tool_resolver still work."""
def test_normalize_tool_args_handles_dict(self):
from src.agent._tool_resolver import normalize_tool_args
from ss_tools.agent._tool_resolver import normalize_tool_args
assert normalize_tool_args({"key": "value"}) == {"key": "value"}
def test_normalize_tool_args_handles_none(self):
from src.agent._tool_resolver import normalize_tool_args
from ss_tools.agent._tool_resolver import normalize_tool_args
assert normalize_tool_args(None) == {}
def test_coerce_tool_call_from_dict(self):
from src.agent._tool_resolver import coerce_tool_call
from ss_tools.agent._tool_resolver import coerce_tool_call
name, args = coerce_tool_call({"name": "test_tool", "args": {"x": 1}})
assert name == "test_tool"
assert args == {"x": 1}
def test_extract_tool_call_from_state_empty(self):
from src.agent._tool_resolver import extract_tool_call_from_state
from ss_tools.agent._tool_resolver import extract_tool_call_from_state
from unittest.mock import MagicMock
state = MagicMock()
state.values = {"messages": []}
@@ -124,7 +124,7 @@ class TestToolResolverUtils:
assert args == {}
def test_known_agent_tool_names(self):
from src.agent._tool_resolver import known_agent_tool_names
from ss_tools.agent._tool_resolver import known_agent_tool_names
names = known_agent_tool_names()
assert "show_capabilities" in names
assert "search_dashboards" in names

View File

@@ -31,8 +31,8 @@ def anyio_backend():
@pytest.mark.anyio
async def test_tool_dual_auth_headers():
"""Tools should build auth headers from ContextVar when set."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import search_dashboards
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
# Set JWTs in context
set_user_jwt("user-jwt-token")
@@ -69,9 +69,9 @@ async def test_tool_dual_auth_headers():
@pytest.mark.anyio
async def test_tool_auth_fallback_to_env():
"""Tools should fall back to SERVICE_JWT env var when ContextVar is empty."""
from src.agent.context import set_service_jwt, set_user_jwt
import src.agent.tools as tools_mod
from src.agent.tools import search_dashboards
from ss_tools.agent.context import set_service_jwt, set_user_jwt
import ss_tools.agent.tools as tools_mod
from ss_tools.agent.tools import search_dashboards
# Clear ContextVars
set_user_jwt("")
@@ -108,8 +108,8 @@ async def test_tool_auth_fallback_to_env():
@pytest.mark.anyio
async def test_tool_http_exception_handling():
"""Tool should propagate HTTP exception as error text."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import search_dashboards
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("test-jwt")
set_service_jwt("svc-jwt")
@@ -133,7 +133,7 @@ async def test_tool_http_exception_handling():
def test_get_all_tools_returns_expected_list():
"""get_all_tools() should return search_dashboards, get_health_summary, etc."""
from src.agent.tools import get_all_tools
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = [t.name for t in tools]
@@ -161,7 +161,7 @@ def test_get_all_tools_returns_expected_list():
def test_get_all_tools_args_schema():
"""Tools with args_schema should expose required fields."""
from src.agent.tools import get_all_tools
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
search_tool = next(t for t in tools if t.name == "search_dashboards")
@@ -181,7 +181,7 @@ def test_get_all_tools_args_schema():
def test_get_all_tools_returns_24_tools():
"""get_all_tools() returns full catalog — all 24 tools registered."""
from src.agent.tools import get_all_tools
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
@@ -203,8 +203,8 @@ def test_get_all_tools_returns_24_tools():
@pytest.mark.anyio
async def test_search_dashboards_correct_url():
"""search_dashboards calls GET /api/dashboards with query params."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import search_dashboards
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
@@ -237,8 +237,8 @@ async def test_search_dashboards_correct_url():
@pytest.mark.anyio
async def test_get_health_summary_calls_correct_url():
"""get_health_summary should call GET /api/health/summary."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import get_health_summary
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import get_health_summary
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
@@ -269,8 +269,8 @@ async def test_get_health_summary_calls_correct_url():
@pytest.mark.anyio
async def test_list_environments_calls_correct_url():
"""list_environments should call GET /api/settings/environments."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import list_environments
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import list_environments
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
@@ -293,8 +293,8 @@ async def test_list_environments_calls_correct_url():
@pytest.mark.anyio
async def test_list_environments_redacts_sensitive_fields():
"""list_environments must not expose backend secrets to chat output."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import list_environments
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import list_environments
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
@@ -323,8 +323,8 @@ async def test_list_environments_redacts_sensitive_fields():
@pytest.mark.anyio
async def test_get_task_status_calls_correct_url():
"""get_task_status should call GET /api/tasks/{task_id}."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import get_task_status
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import get_task_status
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
@@ -350,8 +350,8 @@ async def test_get_task_status_calls_correct_url():
@pytest.mark.anyio
async def test_run_backup_posts_task_payload():
"""run_backup should create a superset-backup task through /api/tasks."""
from src.agent.context import set_service_jwt, set_user_jwt, set_user_role
from src.agent.tools import run_backup
from ss_tools.agent.context import set_service_jwt, set_user_jwt, set_user_role
from ss_tools.agent.tools import run_backup
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
@@ -377,8 +377,8 @@ async def test_run_backup_posts_task_payload():
@pytest.mark.anyio
async def test_deploy_dashboard_posts_git_endpoint():
"""deploy_dashboard should call the native Git deploy API."""
from src.agent.context import set_service_jwt, set_user_jwt, set_user_role
from src.agent.tools import deploy_dashboard
from ss_tools.agent.context import set_service_jwt, set_user_jwt, set_user_role
from ss_tools.agent.tools import deploy_dashboard
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
@@ -404,8 +404,8 @@ async def test_deploy_dashboard_posts_git_endpoint():
def test_dual_auth_headers_with_both_jwts():
"""_dual_auth_headers uses service auth plus user identity when both are set."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import _dual_auth_headers
set_service_jwt("svc-token")
set_user_jwt("user-token")
@@ -417,8 +417,8 @@ def test_dual_auth_headers_with_both_jwts():
def test_dual_auth_headers_no_user_jwt():
"""_dual_auth_headers falls back to service Authorization when no user JWT."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import _dual_auth_headers
set_service_jwt("svc-token")
set_user_jwt("")
@@ -429,8 +429,8 @@ def test_dual_auth_headers_no_user_jwt():
def test_dual_auth_headers_no_jwts():
"""_dual_auth_headers falls back to _SERVICE_JWT when context vars are empty."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import _dual_auth_headers
set_service_jwt("")
set_user_jwt("")

View File

@@ -20,7 +20,7 @@ def anyio_backend():
# @BRIEF Test configure_from_api updates global config.
class TestConfigureFromApi:
def test_sets_llm_config(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None # Reset
ls.configure_from_api({"configured": True, "api_key": "sk-test", "default_model": "gpt-4o"})
assert ls._llm_config is not None
@@ -30,7 +30,7 @@ class TestConfigureFromApi:
ls._llm_config = None
def test_overwrites_previous_config(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None # Reset
ls.configure_from_api({"configured": True, "api_key": "sk-1"})
ls.configure_from_api({"configured": False})
@@ -44,7 +44,7 @@ class TestConfigureFromApi:
class TestCreateAgent:
@pytest.mark.anyio
async def test_creates_agent_with_api_config(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None # Reset
ls.configure_from_api({
"configured": True,
@@ -52,9 +52,9 @@ class TestCreateAgent:
"base_url": "https://custom.api.com/v1",
"default_model": "gpt-4o-mini",
})
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
result = await ls.create_agent([MagicMock()])
assert result is mock_create.return_value
@@ -66,24 +66,24 @@ class TestCreateAgent:
@pytest.mark.anyio
async def test_raises_error_when_no_llm_configured(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None # Reset
with patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)):
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)):
with pytest.raises(RuntimeError, match="No LLM provider configured in backend"):
await ls.create_agent([])
ls._llm_config = None
@pytest.mark.anyio
async def test_creates_agent_with_partial_api_config(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-key-only",
})
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
result = await ls.create_agent([])
assert result is mock_create.return_value
@@ -95,7 +95,7 @@ class TestCreateAgent:
@pytest.mark.anyio
async def test_uses_inmemory_saver(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
@@ -103,9 +103,9 @@ class TestCreateAgent:
"base_url": "",
"default_model": "gpt-4o-mini",
})
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
await ls.create_agent([])
call_kwargs = mock_create.call_args[1]
@@ -115,15 +115,15 @@ class TestCreateAgent:
@pytest.mark.anyio
async def test_uses_empty_interrupt_list_by_default(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI"), \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == []
@@ -131,16 +131,16 @@ class TestCreateAgent:
@pytest.mark.anyio
async def test_confirm_tools_env_interrupts_before_tools_node(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI"), \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("ss_tools.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
mock_create.return_value = MagicMock()
await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
@@ -148,16 +148,16 @@ class TestCreateAgent:
@pytest.mark.anyio
async def test_uses_env_configured_interrupt_nodes(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("src.agent.langgraph_setup._INTERRUPT_BEFORE", "tools"):
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI"), \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("ss_tools.agent.langgraph_setup._INTERRUPT_BEFORE", "tools"):
mock_create.return_value = MagicMock()
await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
@@ -165,16 +165,16 @@ class TestCreateAgent:
@pytest.mark.anyio
async def test_interrupt_override_bypasses_env_guardrail(self):
import src.agent.langgraph_setup as ls
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI"), \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("ss_tools.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
mock_create.return_value = MagicMock()
await ls.create_agent([], interrupt_before=[])
assert mock_create.call_args[1]["interrupt_before"] == []

View File

@@ -16,73 +16,73 @@ import pytest
class TestLogToolEvent:
@pytest.mark.asyncio
async def test_logs_tool_start(self):
from src.agent.middleware import log_tool_event
from ss_tools.agent.middleware import log_tool_event
event = {
"event": "on_tool_start",
"name": "migrate",
"data": {"input": {"dashboard_id": "42"}},
}
with patch("src.agent.middleware.get_user_jwt", return_value="user-token"):
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="user-token"):
await log_tool_event(event, "conv-1")
# No exception = success
@pytest.mark.asyncio
async def test_logs_tool_end(self):
from src.agent.middleware import log_tool_event
from ss_tools.agent.middleware import log_tool_event
event = {
"event": "on_tool_end",
"name": "migrate",
"data": {"output": "success"},
}
with patch("src.agent.middleware.get_user_jwt", return_value="user-token"):
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="user-token"):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_logs_tool_error(self):
from src.agent.middleware import log_tool_event
from ss_tools.agent.middleware import log_tool_event
event = {
"event": "on_tool_error",
"name": "migrate",
"data": {"error": "Connection failed"},
}
with patch("src.agent.middleware.get_user_jwt", return_value="user-token"):
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="user-token"):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_logs_without_user_jwt(self):
from src.agent.middleware import log_tool_event
from ss_tools.agent.middleware import log_tool_event
event = {
"event": "on_tool_start",
"name": "test_tool",
"data": {"input": {}},
}
with patch("src.agent.middleware.get_user_jwt", return_value=None):
with patch("ss_tools.agent.middleware.get_user_jwt", return_value=None):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_handles_missing_data_key(self):
from src.agent.middleware import log_tool_event
from ss_tools.agent.middleware import log_tool_event
event = {"event": "on_tool_start", "name": "test_tool"}
with patch("src.agent.middleware.get_user_jwt", return_value=None):
with patch("ss_tools.agent.middleware.get_user_jwt", return_value=None):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_handles_unknown_event_kind(self):
from src.agent.middleware import log_tool_event
from ss_tools.agent.middleware import log_tool_event
event = {"event": "on_custom_event", "name": "custom"}
with patch("src.agent.middleware.get_user_jwt", return_value=None):
with patch("ss_tools.agent.middleware.get_user_jwt", return_value=None):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_truncates_long_input(self):
from src.agent.middleware import log_tool_event
from ss_tools.agent.middleware import log_tool_event
long_input = "x" * 1000
event = {
"event": "on_tool_start",
"name": "big_tool",
"data": {"input": long_input},
}
with patch("src.agent.middleware.get_user_jwt", return_value="token"):
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="token"):
await log_tool_event(event, "conv-1")
# #endregion test_log_tool_event
# #endregion Test.AgentChat.Middleware

View File

@@ -3,10 +3,6 @@
# @RELATION BINDS_TO -> [AgentChat.Run]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import socket
from unittest.mock import MagicMock, patch
import pytest
@@ -16,7 +12,7 @@ import pytest
# @BRIEF Test _find_free_port for port scanning behavior.
class TestFindFreePort:
def test_returns_free_port(self):
from src.agent.run import _find_free_port
from ss_tools.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
@@ -25,7 +21,7 @@ class TestFindFreePort:
mock_instance.bind.assert_called_once_with(("", 8000))
def test_skips_busy_ports(self):
from src.agent.run import _find_free_port
from ss_tools.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
@@ -41,7 +37,7 @@ class TestFindFreePort:
assert mock_instance.bind.call_count == 4
def test_raises_when_all_busy(self):
from src.agent.run import _find_free_port
from ss_tools.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
@@ -56,8 +52,8 @@ class TestFindFreePort:
# @BRIEF Test _fetch_llm_config with retry and fallback behavior.
class TestFetchLlmConfig:
def test_returns_config_on_success(self):
from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get:
from ss_tools.agent.run import _fetch_llm_config
with patch("ss_tools.agent.run.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"configured": True, "provider_type": "openai", "default_model": "gpt-4o"}
mock_get.return_value = mock_response
@@ -66,8 +62,8 @@ class TestFetchLlmConfig:
assert result["configured"] is True
def test_returns_none_when_not_configured(self):
from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get:
from ss_tools.agent.run import _fetch_llm_config
with patch("ss_tools.agent.run.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"configured": False, "reason": "no provider"}
mock_get.return_value = mock_response
@@ -75,9 +71,9 @@ class TestFetchLlmConfig:
assert result is None
def test_retries_on_failure(self):
from src.agent.run import _fetch_llm_config
from ss_tools.agent.run import _fetch_llm_config
import time as time_module
with patch("src.agent.run.httpx.get") as mock_get, \
with patch("ss_tools.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_get.side_effect = Exception("Connection refused")
result = _fetch_llm_config()
@@ -85,9 +81,9 @@ class TestFetchLlmConfig:
assert mock_get.call_count == 6
def test_retries_then_returns_config(self):
from src.agent.run import _fetch_llm_config
from ss_tools.agent.run import _fetch_llm_config
import time as time_module
with patch("src.agent.run.httpx.get") as mock_get, \
with patch("ss_tools.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_get.side_effect = [
Exception("Timeout"), # Attempt 1
@@ -99,9 +95,9 @@ class TestFetchLlmConfig:
assert result["configured"] is True
def test_returns_none_after_max_retries_with_http_error(self):
from src.agent.run import _fetch_llm_config
from ss_tools.agent.run import _fetch_llm_config
import time as time_module
with patch("src.agent.run.httpx.get") as mock_get, \
with patch("ss_tools.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
@@ -111,9 +107,9 @@ class TestFetchLlmConfig:
assert mock_get.call_count == 6
def test_uses_service_token_header(self):
from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get, \
patch("src.agent.run.SERVICE_JWT", "test-token"):
from ss_tools.agent.run import _fetch_llm_config
with patch("ss_tools.agent.run.httpx.get") as mock_get, \
patch("ss_tools.agent.run.SERVICE_JWT", "test-token"):
mock_response = MagicMock()
mock_response.json.return_value = {"configured": True}
mock_get.return_value = mock_response
@@ -140,7 +136,7 @@ class TestMainBlock:
import os
from pathlib import Path
run_path = Path(__file__).parent.parent.parent / "src" / "agent" / "run.py"
run_path = Path(__file__).parent.parent.parent / "src" / "ss_tools" / "agent" / "run.py"
spec = importlib.util.spec_from_file_location("__main__", str(run_path))
# Apply env overrides
@@ -149,7 +145,7 @@ class TestMainBlock:
monkeypatch.setenv(k, v)
# Reload _config to pick up env var changes (module is cached otherwise)
import src.agent._config as agent_config
import ss_tools.agent._config as agent_config
importlib.reload(agent_config)
svc_jwt = env_overrides.get("SERVICE_JWT", os.environ.get("SERVICE_JWT", ""))
@@ -158,13 +154,13 @@ class TestMainBlock:
with patch('httpx.get') as mock_httpx_get, \
patch('socket.socket') as mock_socket_cls, \
patch('asyncio.run') as mock_asyncio_run, \
patch('src.agent.app.create_chat_interface') as mock_create_ci, \
patch('src.agent.context.set_service_jwt') as mock_set_jwt, \
patch('src.agent.langgraph_setup.configure_from_api') as mock_configure, \
patch('src.agent.langgraph_setup.init_checkpointer'), \
patch('src.agent.run.SERVICE_JWT', svc_jwt), \
patch('src.agent.run.GRADIO_SERVER_PORT', gradio_port), \
patch('src.agent.run.GRADIO_ALLOW_PORT_FALLBACK', gradio_fallback):
patch('ss_tools.agent.app.create_chat_interface') as mock_create_ci, \
patch('ss_tools.agent.context.set_service_jwt') as mock_set_jwt, \
patch('ss_tools.agent.langgraph_setup.configure_from_api') as mock_configure, \
patch('ss_tools.agent.langgraph_setup.init_checkpointer'), \
patch('ss_tools.agent.run.SERVICE_JWT', svc_jwt), \
patch('ss_tools.agent.run.GRADIO_SERVER_PORT', gradio_port), \
patch('ss_tools.agent.run.GRADIO_ALLOW_PORT_FALLBACK', gradio_fallback):
mock_asyncio_run.side_effect = lambda coro: coro.close() if hasattr(coro, "close") else None
# httpx for _fetch_llm_config
@@ -229,7 +225,7 @@ class TestMainBlock:
def test_main_block_port_fallback(self, monkeypatch):
"""Port in use triggers fallback warning (logged but continues)."""
# Ports 27863, 27864 busy → 27865 free
with patch('src.agent.run.logger') as mock_logger:
with patch('ss_tools.agent.run.logger') as mock_logger:
result = self._run_as_main(monkeypatch,
env_overrides={
"GRADIO_SERVER_PORT": "27863",
@@ -240,7 +236,7 @@ class TestMainBlock:
def test_main_block_port_oserror(self, monkeypatch):
"""OSError during port finding raises in main block."""
with patch('src.agent.run.logger') as mock_logger:
with patch('ss_tools.agent.run.logger') as mock_logger:
with pytest.raises(OSError):
self._run_as_main(monkeypatch,
env_overrides={

View File

@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, patch
import httpx
from src.agent.tools import (
from ss_tools.agent.tools import (
_execute_with_timeout,
_retry_read_tool,
drain_tool_retry_events,

View File

@@ -8,7 +8,7 @@
import json
from src.agent.tools import _summarise_response
from ss_tools.agent.tools import _summarise_response
# #region test_summarise_json_array_50_items [C:2] [TYPE Function]

View File

@@ -19,12 +19,12 @@ import pytest
@pytest.mark.asyncio
async def test_completes_under_timeout():
"""Tool returns its result before the timeout expires."""
from src.agent.tools import _execute_with_timeout
from ss_tools.agent.tools import _execute_with_timeout
expected = {"status": "ok", "data": [1, 2, 3]}
fast_fn = AsyncMock(return_value=expected)
with patch("src.agent.tools.logger.explore") as mock_explore:
with patch("ss_tools.agent.tools.logger.explore") as mock_explore:
result = await _execute_with_timeout("fast_tool", fast_fn, is_write=False, timeout_s=30)
assert result == expected
@@ -38,13 +38,13 @@ async def test_completes_under_timeout():
@pytest.mark.asyncio
async def test_read_tool_timeout():
"""Read tool that sleeps longer than timeout_s must raise TimeoutError."""
from src.agent.tools import _execute_with_timeout
from ss_tools.agent.tools import _execute_with_timeout
async def slow_read():
await asyncio.sleep(0.3)
return "never_reached"
with patch("src.agent.tools.logger.explore") as mock_explore:
with patch("ss_tools.agent.tools.logger.explore") as mock_explore:
with pytest.raises(TimeoutError):
await _execute_with_timeout("slow_read", slow_read, is_write=False, timeout_s=0.05)
@@ -64,13 +64,13 @@ async def test_read_tool_timeout():
@pytest.mark.asyncio
async def test_write_tool_timeout():
"""Write tool that sleeps longer than timeout_s must raise TimeoutError."""
from src.agent.tools import _execute_with_timeout
from ss_tools.agent.tools import _execute_with_timeout
async def slow_write():
await asyncio.sleep(0.3)
return "never_reached"
with patch("src.agent.tools.logger.explore") as mock_explore:
with patch("ss_tools.agent.tools.logger.explore") as mock_explore:
with pytest.raises(TimeoutError):
await _execute_with_timeout("slow_write", slow_write, is_write=True, timeout_s=0.05)

View File

@@ -1,4 +0,0 @@
# Embedding model for semantic tool routing (large ML deps: torch, transformers, etc.)
# This file is OPTIONAL — install only if semantic routing is needed.
# See _embedding_router.py: graceful fallback when this package is absent.
sentence-transformers>=3.0

View File

@@ -1,12 +1,11 @@
# Full development requirements — aggregate of role-specific files.
# For production Docker images, use the role-specific files:
# Full development requirements — backend only.
# For production Docker images, use:
# - requirements-backend.txt (FastAPI backend)
# - requirements-agent.txt (Gradio agent, no embedding)
# - requirements-embeddings.txt (optional semantic routing ML deps)
# For development, also install:
# - agent/requirements.txt (Gradio agent, separate project)
# - shared/ (pip install -e shared/) (shared utilities)
-r requirements-backend.txt
-r requirements-agent.txt
-r requirements-embeddings.txt
# Dev/test only — NOT included in any production image
ruff>=0.11.0

View File

@@ -1,162 +0,0 @@
# backend/src/agent/_context.py
# #region AgentChat.Context.Validate [C:2] [TYPE Module] [SEMANTICS agent-chat,context,validate,security]
# @defgroup AgentChat UIContext validation and prompt-injection protection.
# @LAYER Service
import json
ALLOWED_OBJECT_TYPES: frozenset = frozenset({"dashboard", "dataset", "migration"})
class UIContextValidationError(ValueError):
"""Raised when a UIContext payload fails validation.
Provides a descriptive message identifying the specific field and
reason for rejection. Never exposes raw payload values beyond the
field value itself — prevents information leakage into logs.
"""
pass
# ── Internal helpers ─────────────────────────────────────────────────────────
# Each encapsulates one field-level check. Keeps validate_uicontext()
# cyclomatic complexity ≤ 2 (only the ``None`` guard).
# #region AgentChat.Context.Validate.CheckObjectType [C:1] [TYPE Function]
def _check_object_type(value: str | None) -> None:
"""Validate ``objectType`` — one of ALLOWED_OBJECT_TYPES or None."""
if value is not None and value not in ALLOWED_OBJECT_TYPES:
raise UIContextValidationError(
f"UIContext: invalid objectType '{value}'"
f" — must be one of {ALLOWED_OBJECT_TYPES}"
)
# #endregion AgentChat.Context.Validate.CheckObjectType
# #region AgentChat.Context.Validate.CheckObjectId [C:1] [TYPE Function]
def _check_object_id(value: str | None) -> None:
"""Validate ``objectId`` — numeric string or None."""
if value is not None and not (isinstance(value, str) and value.isdigit()):
raise UIContextValidationError(
f"UIContext: invalid objectId '{value}'"
)
# #endregion AgentChat.Context.Validate.CheckObjectId
# #region AgentChat.Context.Validate.CheckObjectName [C:1] [TYPE Function]
def _check_object_name(value: str | None) -> None:
"""Validate ``objectName`` — string ≤ 256 chars or None."""
if value is None:
return
if not isinstance(value, str):
raise UIContextValidationError(
f"UIContext: invalid objectName '{value}'"
)
if len(value) > 256:
raise UIContextValidationError(
"UIContext: objectName exceeds 256 characters"
)
# #endregion AgentChat.Context.Validate.CheckObjectName
# #region AgentChat.Context.Validate.CheckEnvId [C:1] [TYPE Function]
def _check_env_id(value: str | None) -> None:
"""Validate ``envId`` — string or None."""
if value is not None and not isinstance(value, str):
raise UIContextValidationError(
f"UIContext: invalid envId '{value}'"
)
# #endregion AgentChat.Context.Validate.CheckEnvId
# #region AgentChat.Context.Validate.CheckRoute [C:1] [TYPE Function]
def _check_route(value: str) -> None:
"""Validate ``route`` — string, ≤ 512 chars."""
if not isinstance(value, str):
raise UIContextValidationError(
f"UIContext: invalid route '{value}' — must be a string"
)
if len(value) > 512:
raise UIContextValidationError(
"UIContext: route exceeds 512 characters"
)
# #endregion AgentChat.Context.Validate.CheckRoute
# #region AgentChat.Context.Validate.CheckContextVersion [C:1] [TYPE Function]
def _check_context_version(value: int) -> None:
"""Validate ``contextVersion`` — must equal 1."""
if not isinstance(value, int) or value != 1:
raise UIContextValidationError(
f"UIContext: unsupported contextVersion {value}"
)
# #endregion AgentChat.Context.Validate.CheckContextVersion
# #region AgentChat.Context.Validate.CheckPayloadSize [C:1] [TYPE Function]
def _check_payload_size(payload: dict) -> None:
"""Validate serialised JSON payload ≤ 4096 bytes.
Serialises with ``sort_keys=True`` for deterministic sizing.
"""
serialized = json.dumps(payload, sort_keys=True)
if len(serialized.encode("utf-8")) >= 4096:
raise UIContextValidationError(
"UIContext: payload exceeds 4 KB"
)
# #endregion AgentChat.Context.Validate.CheckPayloadSize
# ── Public API ───────────────────────────────────────────────────────────────
# #region AgentChat.Context.Validate.Payload [C:2] [TYPE Function] [SEMANTICS agent-chat,context,validate]
# @ingroup AgentChat
# @BRIEF Validate UIContext payload — enum checks, size limits, field lengths.
# @POST Returns validated dict or raises UIContextValidationError.
# @RATIONALE Trust boundary — UIContext comes from user-controlled URL params.
# Validation prevents prompt injection and malformed context.
def validate_uicontext(payload: dict) -> dict:
"""Validate a UIContext payload against field-level constraints.
Checks performed in order:
1. ``objectType`` — one of {"dashboard", "dataset", "migration"} or None.
2. ``objectId`` — numeric string or None.
3. ``objectName`` — string ≤ 256 chars or None.
4. ``envId`` — string or None.
5. ``route`` — string ≤ 512 chars.
6. ``contextVersion`` — int, must equal 1.
7. Serialised JSON payload ≤ 4096 bytes.
Parameters
----------
payload : dict or None
Raw UIContext dictionary. ``None`` returns ``{}`` immediately.
Returns
-------
dict
The validated payload (unchanged).
Raises
------
UIContextValidationError
If any constraint is violated. The message identifies the field
and reason without leaking internal state.
"""
if payload is None:
return {}
_check_object_type(payload.get("objectType"))
_check_object_id(payload.get("objectId"))
_check_object_name(payload.get("objectName"))
_check_env_id(payload.get("envId"))
_check_route(payload.get("route"))
_check_context_version(payload.get("contextVersion"))
_check_payload_size(payload)
return payload
# #endregion AgentChat.Context.Validate.Payload
# #endregion AgentChat.Context.Validate

View File

@@ -1,154 +0,0 @@
# 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
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 _TOOL_DESCRIPTIONS_OVERRIDES, get_all_tools
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: object | None = None
_tool_embeddings: object | None = 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

View File

@@ -1,50 +0,0 @@
# backend/src/agent/_jwt_decoder.py
# #region AgentChat.JwtDecoder [C:1] [TYPE Module] [SEMANTICS agent-chat,jwt,decode]
# @BRIEF Lightweight JWT decode for agent — uses AUTH_SECRET_KEY env var, avoids
# pulling src.core.auth.jwt which requires AUTH_DATABASE_URL and ORM deps.
# @RATIONALE The agent only needs stateless JWT validation (exp, sub, signature).
# Token blacklisting is only relevant for backend auth flow — the agent
# processes one request at a time and doesn't need DB-backed revocation.
# @INVARIANT AUTH_SECRET_KEY is the ONLY accepted JWT signing key. JWT_SECRET is
# unsupported and triggers a migration message if present without
# AUTH_SECRET_KEY.
# @REJECTED Importing src.core.auth.jwt.decode_token was rejected — it drags in
# AuthConfig -> AUTH_DATABASE_URL/SECRET_KEY validators -> SQLAlchemy models,
# bloating the agent image with backend infrastructure it doesn't need.
# @REJECTED Fallback from JWT_SECRET to AUTH_SECRET_KEY was rejected — ambiguous
# naming caused operator confusion and secret drift between environments.
import os
from jose import JWTError, jwt
def decode_token(token: str) -> dict:
"""Decode and validate a JWT token using AUTH_SECRET_KEY from environment.
Performs stateless validation: signature, expiration, and required claims
(exp, sub). Does NOT check token blacklist or audience/issuer.
Raises JWTError with migration guidance if JWT_SECRET is set but
AUTH_SECRET_KEY is missing (old deployments must rename the variable).
"""
secret = os.getenv("AUTH_SECRET_KEY", "")
jwt_secret_legacy = os.getenv("JWT_SECRET", "")
if not secret:
if jwt_secret_legacy:
raise JWTError("JWT_SECRET is no longer supported. Rename JWT_SECRET to AUTH_SECRET_KEY in your .env / docker-compose and restart the agent.")
raise JWTError("AUTH_SECRET_KEY environment variable is not set")
return jwt.decode(
token,
secret,
algorithms=[os.getenv("JWT_ALGORITHM", "HS256")],
options={
"verify_signature": True,
"verify_exp": True,
"verify_aud": False,
"require": ["exp", "sub"],
},
)
# #endregion AgentChat.JwtDecoder

View File

@@ -1,461 +0,0 @@
# backend/src/agent/_persistence.py
# #region AgentChat.Persistence [C:3] [TYPE Module] [SEMANTICS agent-chat,persistence,save,prefetch,title]
# @defgroup AgentChat Conversation persistence helpers — save, clean titles, LLM title generation, prefetch.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Context]
# @RATIONALE Persistence logic is extracted to keep the handler under 400 lines and avoid
# mixing HTTP concerns with streaming logic.
import asyncio
from datetime import datetime
import os
import re
from typing import Any
import uuid
import httpx
from src.agent._config import AGENT_PREFETCH_DASHBOARD_LIMIT as _PREFETCH_LIMIT, FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT
from src.agent._llm_params import add_temperature_if_supported
from src.core.logger import logger
SAVE_API_URL = FASTAPI_URL + "/api/agent/conversations/save"
TITLE_MAX_LENGTH = 80
# ── Rule-based title cleaning ────────────────────────────────────
# #region AgentChat.Persistence.CleanTitle [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,title]
# @ingroup AgentChat
# @BRIEF Clean raw user text into a readable conversation title — strip file markers,
# pre-fetch blocks, JSON/CSV, URLs; truncate to 80 chars at word boundary.
# @POST Returns cleaned title string; "Новый диалог" for empty/unparseable input.
# @RATIONALE Raw user_text often contains file content, pre-fetched data, or pasted JSON/CSV
# that produces unreadable titles. Rule-based cleaning is instant (no LLM latency)
# and handles 90% of cases. LLM titling is a best-effort async layer on top.
# @REJECTED Truncating raw text without cleaning was rejected — produces titles like
# "--- Uploaded file content --- id,name,status 1,Dashboard A..."
def clean_title(user_text: str) -> str: # noqa: C901
if not user_text or not user_text.strip():
return "Новый диалог"
text = user_text.strip()
# ── Already a HITL title? Skip cleaning ──
if text.startswith("") or text.startswith("⏹️ "):
return text[:TITLE_MAX_LENGTH]
# ── Strip file upload markers (cut before first occurrence) ──
file_markers = [
"\n--- Uploaded file content ---",
"--- Uploaded file content ---", # no leading newline
"\n[PRE-FETCHED DATA",
"[PRE-FETCHED DATA", # no leading newline
"\n[/PRE-FETCHED DATA]",
"[/PRE-FETCHED DATA]", # no leading newline
]
cut_pos = len(text)
for marker in file_markers:
pos = text.find(marker)
if pos != -1 and pos < cut_pos:
cut_pos = pos
if cut_pos < len(text):
text = text[:cut_pos].strip()
if not text:
return "Новый диалог"
# ── Take first meaningful segment ──
# Priority: first sentence ending with .!?, else first line, else full text
sentence_end = -1
for m in re.finditer(r"[.!?]\s", text):
sentence_end = m.start()
break
if sentence_end > 3: # don't cut "Привет." into "Привет"
text = text[: sentence_end + 1].strip()
elif "\n" in text:
text = text.split("\n")[0].strip()
if not text:
return "Новый диалог"
# ── Detect structured data (JSON/CSV/URL) ──
if text.startswith("{") or text.startswith("["):
prefix = "Данные: "
inner = text[1:57].strip().rstrip(",")
return prefix + inner + ("" if len(text) > 60 else "")
if text.startswith("http://") or text.startswith("https://"):
try:
from urllib.parse import urlparse
domain = urlparse(text).netloc or "ссылка"
except Exception:
domain = "ссылка"
return domain
# ── Detect code (starts with def/class/import) ──
if any(text.startswith(kw) for kw in ("def ", "class ", "import ", "from ")):
first_line = text.split("\n")[0].strip()
return first_line[:TITLE_MAX_LENGTH]
# ── Hard truncate at word boundary ──
if len(text) > TITLE_MAX_LENGTH:
cut = text.rfind(" ", 0, TITLE_MAX_LENGTH)
if cut == -1:
cut = TITLE_MAX_LENGTH - 1
text = text[:cut].rstrip(".,;:!?") + ""
# ── Fallback for empty/whitespace ──
if not text.strip():
return "Новый диалог"
return text
# #endregion AgentChat.Persistence.CleanTitle
# #region AgentChat.Persistence.DetectState [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,error-detection]
# @ingroup AgentChat
# @BRIEF Detect error/cancelled state from assistant message text.
# @POST Returns "error", "cancelled", or None.
def detect_message_state(text: str) -> str | None:
t = text.lower() if text else ""
error_markers = ["недоступен", "unavailable", "ошибка", "error", "произошла", "try again"]
cancel_markers = ["отменен", "cancelled", "отклонен", "denied"]
if any(m in t for m in cancel_markers):
return "cancelled"
if any(m in t for m in error_markers):
return "error"
return None
# #endregion AgentChat.Persistence.DetectState
# #region AgentChat.Persistence.ExtractUserId [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,auth]
# @ingroup AgentChat
# @BRIEF Extract user ID from JWT payload.
# @POST Returns user_id string or "unknown".
def extract_user_id(jwt_str: str) -> str:
try:
from src.agent._jwt_decoder import decode_token
payload = decode_token(jwt_str)
return payload.get("sub", payload.get("user_id", "unknown"))
except Exception:
return "unknown"
# #endregion AgentChat.Persistence.ExtractUserId
# ═══════════════════════════════════════════════════════════════════
# LLM title generation (best-effort, async)
# ═══════════════════════════════════════════════════════════════════
# Per-conversation lock to prevent concurrent title generation
_title_locks: dict[str, asyncio.Lock] = {}
async def _get_llm_config() -> dict[str, Any] | None:
"""Fetch LLM provider config from FastAPI for title generation."""
try:
fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000")
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{fastapi_url}/api/agent/llm-config", headers=headers)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
async def _call_llm_for_title(user_text: str) -> str | None:
"""Call LLM to generate a 3-5 word Russian title. Returns title or None on failure."""
from src.core.logger import logger as _logger
try:
config = await _get_llm_config()
if not config or not config.get("configured"):
_logger.explore("LLM title: no provider configured", extra={"src": "AgentChat.Persistence"})
return None
clean_text = clean_title(user_text)[:200]
# Skip LLM for trivial titles
if not clean_text or clean_text in ("Новый диалог",):
return None
prompt = f"Сгенерируй заголовок из 3-5 слов на русском для диалога. Только заголовок, без кавычек и пояснений.\n\nДиалог: {clean_text}"
api_key = config.get("api_key", "")
base_url = config.get("base_url", "")
model = config.get("default_model", "gpt-4o-mini")
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 15,
}
add_temperature_if_supported(payload, model=model)
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
# Normalise base_url: strip trailing /v1 to avoid double /v1
base = base_url.rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
api_url = base + "/v1/chat/completions"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(api_url, json=payload, headers=headers)
if resp.status_code != 200:
_logger.explore(
"LLM title: API error",
payload={"status": resp.status_code, "reason": resp.reason_phrase},
error=f"HTTP {resp.status_code}: {resp.text[:100]}",
extra={"src": "AgentChat.Persistence"},
)
return None
data = resp.json()
title = data.get("choices", [{}])[0].get("message", {}).get("content", "")
if title:
# Strip markdown/formatting
title = re.sub(r'[*_`#"\']', "", title).strip()
title = title[:100] # safety cap
if title:
return title
except Exception as e:
_logger.explore("LLM title generation failed", error=str(e), extra={"src": "AgentChat.Persistence"})
return None
# #region AgentChat.Persistence.GenerateLlmTitle [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,llm,title]
# @ingroup AgentChat
# @BRIEF Async best-effort LLM title generation — patches conversation title via REST.
# @PRE conversation_id exists. LLM provider configured (best-effort, skips otherwise).
# @POST Conversation title updated via PATCH if LLM succeeds; no-op on failure.
# @SIDE_EFFECT HTTP PATCH to FastAPI save endpoint; may call external LLM API.
# @RATIONALE LLM-generated titles are more readable than rule-based ones (e.g. "CSV-файл:
# Анализ дашбордов" vs "Проанализируй CSV"). This is a non-blocking best-effort
# layer — the rule-based title is already saved, LLM just improves it.
async def generate_llm_title(conv_id: str, user_text: str) -> None:
if not conv_id or not user_text:
return
# Per-conversation dedup lock
lock = _title_locks.setdefault(conv_id, asyncio.Lock())
if lock.locked():
return # already generating for this conversation
async with lock:
title = await _call_llm_for_title(user_text)
if not title:
return
# Patch the title via the same save endpoint
try:
headers = {"Content-Type": "application/json"}
if _SERVICE_JWT:
headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
payload = {
"conversation_id": conv_id,
"title": title,
"user_id": "admin",
"messages": [],
}
async with httpx.AsyncClient(timeout=5) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
logger.reflect(
"LLM title updated",
payload={"conv_id": conv_id, "title": title[:40]},
extra={"src": "AgentChat.Persistence"},
)
except Exception as e:
logger.explore(
"LLM title save failed",
payload={"conv_id": conv_id},
error=str(e),
extra={"src": "AgentChat.Persistence"},
)
finally:
_title_locks.pop(conv_id, None)
# #endregion AgentChat.Persistence.GenerateLlmTitle
# ═══════════════════════════════════════════════════════════════════
# Prefetch dashboards
# ═══════════════════════════════════════════════════════════════════
# #region AgentChat.Persistence.PrefetchDashboards [C:3] [TYPE Function] [SEMANTICS agent-chat,persistence,prefetch]
# @ingroup AgentChat
# @BRIEF Pre-fetch dashboard data so the LLM has it in context without needing to call a tool.
# @POST Returns formatted dashboard list string, or empty string on failure.
# @SIDE_EFFECT Makes HTTP GET to FastAPI /api/dashboards.
# @RATIONALE Some LLMs (gemma) don't call tools even when instructed. Pre-fetch ensures
# dashboard data is available in context without requiring a tool call.
async def prefetch_dashboards(env_id: str) -> str:
try:
from src.agent.tools import FASTAPI_URL, _dual_auth_headers
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{FASTAPI_URL}/api/dashboards",
params={"q": "", "env_id": env_id or ""},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
data = resp.json()
dashboards = data.get("dashboards", [])
if not dashboards:
return "No dashboards found."
limit = _PREFETCH_LIMIT
total = len(dashboards)
lines = []
for db in dashboards[:limit]:
title = db.get("title", "Untitled")
dashboard_id = db.get("id") or db.get("dashboard_id")
modified = (db.get("last_modified", "") or "")[:10]
if modified:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
else:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
suffix = ""
if total > limit:
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
except Exception as e:
logger.explore(
"Prefetch dashboards failed",
payload={"env_id": env_id},
error=str(e),
extra={"src": "AgentChat.Persistence.PrefetchDashboards"},
)
return ""
# #endregion AgentChat.Persistence.PrefetchDashboards
# #region AgentChat.Persistence.PrefetchDatabases [C:2] [TYPE Function] [SEMANTICS agent-chat,persistence,prefetch,databases]
# @ingroup AgentChat
# @BRIEF Pre-fetch all databases for an environment into a formatted string for runtime context.
# @SIDE_EFFECT HTTP GET to FastAPI /api/agent/superset/databases.
# @RATIONALE Позволяет LLM видеть database_id для каждой БД, не вызывая отдельный инструмент.
async def prefetch_databases(env_id: str) -> str:
try:
from src.agent.tools import FASTAPI_URL, _dual_auth_headers
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{FASTAPI_URL}/api/agent/superset/databases",
params={"environment_id": env_id or ""},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
databases = resp.json()
if not databases:
return "No databases found."
lines = ["Available databases (use database_id for SQL tools):"]
for db in databases:
db_id = db.get("id", "?")
db_name = db.get("database_name", db.get("name", "?"))
db_engine = db.get("backend", db.get("engine", ""))
if db_engine:
lines.append(f" • DB #{db_id}: {db_name} ({db_engine})")
else:
lines.append(f" • DB #{db_id}: {db_name}")
return "\n".join(lines)
except Exception as e:
logger.explore(
"Prefetch databases failed",
payload={"env_id": env_id},
error=str(e),
extra={"src": "AgentChat.Persistence.PrefetchDatabases"},
)
return ""
# #endregion AgentChat.Persistence.PrefetchDatabases
# #region AgentChat.Persistence.SaveConversation [C:4] [TYPE Function] [SEMANTICS agent-chat,persistence,save]
# @ingroup AgentChat
# @BRIEF Save conversation to DB via FastAPI REST. Cleans title via clean_title().
# @PRE conversation_id is valid. FASTAPI_URL reachable.
# @POST Conversation + messages saved via POST /api/agent/conversations/save.
# @SIDE_EFFECT HTTP POST to FastAPI; writes to AgentConversation and AgentMessage tables.
# @DATA_CONTRACT Input: (conv_id, user_text, user_id, assistant_text) -> Output: None (side-effect only)
# @RELATION DISPATCHES -> [AgentChat.Api.Conversations]
# @RATIONALE Anonymous Gradio sessions (anon_ prefix) default to "admin" because the agent runs in an internal network behind an auth proxy — all users within the network are trusted.
# @REJECTED Requiring explicit authentication for Gradio was rejected — the agent is designed for internal-network use where the auth proxy handles auth; adding a separate auth layer would create unnecessary friction and duplicate the proxy's responsibility.
async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
try:
headers = {"Content-Type": "application/json"}
if _SERVICE_JWT:
headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
# Normalize user_id: anonymous Gradio sessions use "anon_" prefix
if not user_id or user_id.startswith("anon_"):
user_id = "admin"
messages: list[dict[str, Any]] = [
{
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "user",
"text": user_text.strip(),
"state": None,
"created_at": datetime.utcnow().isoformat(),
},
]
if assistant_text.strip():
assistant_state = detect_message_state(assistant_text)
messages.append(
{
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "assistant",
"text": assistant_text.strip(),
"state": assistant_state,
"created_at": datetime.utcnow().isoformat(),
}
)
title = clean_title(user_text)
payload = {
"conversation_id": conv_id,
"title": title,
"user_id": user_id,
"messages": messages,
}
async with httpx.AsyncClient(timeout=10) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
logger.reflect(
"Conversation saved",
payload={"conv_id": conv_id, "msg_count": len(messages), "title": title[:40]},
extra={"src": "AgentChat.Persistence.SaveConversation"},
)
except Exception as e:
logger.explore(
"Failed to save conversation",
payload={"conv_id": conv_id},
error=str(e),
extra={"src": "AgentChat.Persistence"},
)
# #endregion AgentChat.Persistence.SaveConversation
# #endregion AgentChat.Persistence

View File

@@ -1,224 +0,0 @@
# backend/src/agent/_tool_filter.py
# #region AgentChat.ToolFilter [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,filter,context]
# @defgroup AgentChat Context-aware tool filtering + RBAC enforcement.
# @BRIEF Provides a two-stage tool selection pipeline: RBAC role check followed by
# context affinity filtering, plus an invocation-time RBAC guard.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RELATION DEPENDS_ON -> [LoggerModule]
# @INVARIANT show_capabilities is ALWAYS included in the filtered pipeline output.
# @INVARIANT enforce_tool_permission is a pure boolean guard — no SSE logging, no side effects.
# @RATIONALE Ordered pipeline ensures RBAC is enforced before context UX filtering,
# preventing role-restricted tools from appearing even in per-context
# suggestions. Invocation guard adds a second enforcement layer at call time
# for defense-in-depth.
# @REJECTED Embedding-based context filtering — postponed to post-MVP. Current approach
# uses a static affinity map which is simpler, deterministic, and sufficient
# for the initial release.
# @DATA_CONTRACT build_tool_pipeline returns a list — never mutates the input list.
# @DATA_CONTRACT enforce_tool_permission returns bool for any string input.
# ---
from typing import Any
from src.core.logger import logger
# ── Context affinity mapping ───────────────────────────────────────
# Maps object_type keys to the set of tool names relevant in that context.
# Tools not in the set are excluded when the corresponding object_type
# is active. If object_type is None or not present, no context filtering
# is applied.
_CONTEXT_TOOL_AFFINITY: dict[str, set[str]] = {
"dashboard": {
"superset_list_databases",
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"run_llm_validation",
"run_llm_documentation",
"execute_migration",
"create_branch",
"commit_changes",
},
"dataset": {
"superset_list_databases",
"superset_explore_database",
"superset_format_sql",
"superset_audit_permissions",
"superset_execute_sql",
"superset_create_dataset",
"search_dashboards",
"get_task_status",
"list_environments",
},
"migration": {
"superset_list_databases",
"execute_migration",
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"list_environments",
},
}
# ── RBAC permission mapping ────────────────────────────────────────
# Maps tool names to the list of roles allowed to invoke them.
# Tools NOT in this dict are allowed for ALL roles.
_TOOL_PERMISSIONS: dict[str, list[str]] = {
"deploy_dashboard": ["admin"],
"commit_changes": ["admin"],
"create_branch": ["admin"],
"run_backup": ["admin"],
"execute_migration": ["admin"],
"start_maintenance": ["admin"],
"end_maintenance": ["admin"],
}
# ── Tools that must always survive all filtering stages ────────────
# These tools provide introspection / capabilities and must never be
# hidden from the user regardless of role or context.
_MANDATORY_TOOLS: set[str] = {
"show_capabilities",
}
# #region AgentChat.ToolFilter.Pipeline [C:4] [TYPE Function] [SEMANTICS agent-chat,tools,filter,pipeline]
# @ingroup AgentChat
# @BRIEF Two-stage tool filtering: RBAC role check -> context affinity.
# @param tools Iterable of LangChain BaseTool objects (or any object with a .name attribute).
# @param user_role Current user's role string (e.g. "admin", "editor", "viewer").
# @param object_type Optional context object type key ("dashboard", "dataset", "migration", or None).
# @POST Returns filtered list of tool objects. show_capabilities is always included.
# @SIDE_EFFECT Each exclusion is logged via logger.reason with src="AgentChat.ToolFilter".
# @RATIONALE Ordered pipeline ensures RBAC enforced before context UX filtering.
# @REJECTED Embedding-based filtering — postponed to post-MVP.
def build_tool_pipeline(
tools: list[Any],
user_role: str,
object_type: str | None = None,
) -> list[Any]:
"""Filter tools through RBAC then context affinity, always keeping mandatory tools.
Stage 1 — RBAC: Remove tools the user's role is not permitted to use.
Stage 2 — Context: Keep only tools relevant to the current object_type, if specified.
Parameters
----------
tools:
List of LangChain BaseTool instances (must have .name attribute).
user_role:
Role identifier for permission checks (e.g. "admin", "editor").
object_type:
Optional context key that selects a subset of tools. Pass None or an
unmapped key to skip context filtering.
Returns
-------
list[Any]:
Filtered list of tool objects, preserving the original order.
"""
filtered: list[Any] = []
for tool in tools:
name: str = tool.name
# Stage 1: RBAC — skip if role is not permitted
if name in _TOOL_PERMISSIONS:
allowed_roles: list[str] = _TOOL_PERMISSIONS[name]
if user_role not in allowed_roles:
logger.reason(
"Tool excluded by RBAC",
payload={
"tool": name,
"reason": f"role '{user_role}' not in allowed roles {allowed_roles}",
},
extra={"src": "AgentChat.ToolFilter"},
)
continue # skip this tool
# Stage 2: Context affinity — skip if not relevant to current object_type
if (
object_type is not None
and object_type in _CONTEXT_TOOL_AFFINITY
and name not in _CONTEXT_TOOL_AFFINITY[object_type]
and name not in _MANDATORY_TOOLS
):
logger.reason(
"Tool excluded by context",
payload={
"tool": name,
"reason": f"not in context affinity set for object_type '{object_type}'",
},
extra={"src": "AgentChat.ToolFilter"},
)
continue # skip this tool
filtered.append(tool)
# Enforce mandatory tools — ensure show_capabilities is always present
seen_names: set[str] = {t.name for t in filtered}
missing_mandatory: set[str] = _MANDATORY_TOOLS - seen_names
if missing_mandatory:
# Re-scan the original list for any missing mandatory tools
for tool in tools:
if tool.name in missing_mandatory:
filtered.append(tool)
logger.reason(
"Mandatory tool re-included after filtering",
payload={"tool": tool.name},
extra={"src": "AgentChat.ToolFilter"},
)
return filtered
# #endregion AgentChat.ToolFilter.Pipeline
# #region AgentChat.ToolFilter.InvocationGuard [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,security,guard]
# @ingroup AgentChat
# @BRIEF Invocation-time RBAC enforcement — pure boolean guard, no SSE logging.
# @param tool_name Name of the tool being invoked.
# @param user_role Current user's role string.
# @POST Returns True if allowed, False if role insufficient.
# @SIDE_EFFECT None — pure boolean guard. The caller (agent_handler) uses the result to
# decide whether to yield permission_denied SSE.
# @RATIONALE Two-layer enforcement: prompt-level filter (build_tool_pipeline) + invocation
# guard. The invocation guard is defense-in-depth against stale client state
# or bypass attempts.
def enforce_tool_permission(tool_name: str, user_role: str) -> bool:
"""Check whether *user_role* is permitted to invoke *tool_name*.
This is an invocation-time guard called JUST BEFORE executing a tool.
It does NOT log, yield SSE, or produce side effects — it is a pure
boolean predicate. The caller uses the result to decide the response
(e.g. yield a ``permission_denied`` SSE event).
Parameters
----------
tool_name:
The name (string identifier) of the tool to check.
user_role:
The role string of the current user.
Returns
-------
bool
``True`` if the tool may be invoked, ``False`` if the role is
insufficient. Unknown tool names always return ``True`` (the
caller handles errors for unknown tools).
"""
if tool_name in _TOOL_PERMISSIONS:
allowed_roles: list[str] = _TOOL_PERMISSIONS[tool_name]
return user_role in allowed_roles
# Unknown tool or tool without explicit permissions — allowed by default
return True
# #endregion AgentChat.ToolFilter.InvocationGuard
# #endregion AgentChat.ToolFilter

View File

@@ -1,108 +0,0 @@
# backend/src/agent/_tool_resolver.py
# #region AgentChat.ToolResolver [C:2] [TYPE Module] [SEMANTICS agent-chat,tools,resolution]
# @defgroup AgentChat Tool resolution helpers for the LangGraph agent.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE Centralised tool resolution prevents duplication of tool-name matching logic.
# Deterministic intent matching (infer_tool_from_text, fast_confirmation_tool,
# keyword lists, negation guard, classification sets) removed — LLM handles
# all intent detection through LangGraph tool-calling. Only utility helpers
# (tool call coercion, args normalization) remain.
# @REJECTED Deterministic intent matching — fragile substring collisions, maintenance burden
# of 24 keyword lists across 3 files, negation blindness in fast-track, and
# inability to handle synonyms ("панели"≠"дашборды") or typos ("дашборд").
# @REJECTED Fast-track confirmation — bypasses LLM, causing negation blindness.
# @REJECTED Tool risk classification sets — LLM decides which tools to call;
# LangGraph interrupt_before handles HITL for dangerous tools at graph level.
from typing import Any
# ── Graph nodes — used by confirmation subsystem to distinguish tools from infrastructure ──
_GRAPH_NODE_NAMES = {"agent", "tools", "__start__", "__end__"}
# #region AgentChat.ToolResolver.KnownNames [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,catalog]
# @ingroup AgentChat
# @BRIEF Return registered LangChain tool names.
# @POST Returns set of tool name strings; falls back to empty set on failure.
def known_agent_tool_names() -> set[str]:
try:
from src.agent.tools import get_all_tools
return {str(tool_obj.name) for tool_obj in get_all_tools() if getattr(tool_obj, "name", None)}
except Exception:
return set()
# #endregion AgentChat.ToolResolver.KnownNames
# #region AgentChat.ToolResolver.NormalizeArgs [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,args]
# @ingroup AgentChat
# @BRIEF Normalize raw tool arguments into a plain dict regardless of input format.
# @POST Returns dict (empty dict for None/unparseable input).
def normalize_tool_args(raw_args: Any) -> dict[str, Any]:
if raw_args is None:
return {}
if isinstance(raw_args, dict):
return raw_args
if hasattr(raw_args, "model_dump"):
dumped = raw_args.model_dump()
return dumped if isinstance(dumped, dict) else {}
if hasattr(raw_args, "dict"):
dumped = raw_args.dict()
return dumped if isinstance(dumped, dict) else {}
return {}
# #endregion AgentChat.ToolResolver.NormalizeArgs
# #region AgentChat.ToolResolver.CoerceToolCall [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,coerce]
# @ingroup AgentChat
# @BRIEF Extract (tool_name, tool_args) tuple from a dict or object tool call.
# @POST Returns (name, args) tuple; name may be None if unparseable.
def coerce_tool_call(tool_call: Any) -> tuple[str | None, dict[str, Any]]:
if isinstance(tool_call, dict):
return (
tool_call.get("name") or tool_call.get("tool") or tool_call.get("id"),
normalize_tool_args(tool_call.get("args") or tool_call.get("input")),
)
return (
getattr(tool_call, "name", None),
normalize_tool_args(getattr(tool_call, "args", None)),
)
# #endregion AgentChat.ToolResolver.CoerceToolCall
# #region AgentChat.ToolResolver.ExtractFromState [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,checkpoint]
# @ingroup AgentChat
# @BRIEF Extract pending tool name and args from the LangGraph checkpoint.
# @POST Returns (tool_name, args) tuple; (None, {}) if nothing found.
# @RATIONALE LLM handles intent — no fallback to keyword inference.
def extract_tool_call_from_state(state, user_text: str = "") -> tuple[str | None, dict[str, Any]]:
known_tools = known_agent_tool_names()
try:
messages = (state.values.get("messages") if hasattr(state, "values") else []) or []
for msg in reversed(messages[-5:]):
if hasattr(msg, "tool_calls") and msg.tool_calls:
tool_name, tool_args = coerce_tool_call(msg.tool_calls[0])
if tool_name:
return (str(tool_name), tool_args)
except Exception:
pass
if getattr(state, "next", None):
node_or_tool = str(state.next[0])
if node_or_tool in known_tools and node_or_tool not in _GRAPH_NODE_NAMES:
return (node_or_tool, {})
return (None, {})
# #endregion AgentChat.ToolResolver.ExtractFromState
# #region AgentChat.ToolResolver.FindTool [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,lookup]
# @ingroup AgentChat
# @BRIEF Find a registered LangChain tool by name.
# @POST Returns tool object or None if not found.
def find_tool(tool_name: str):
from src.agent.tools import get_all_tools
return next((tool_obj for tool_obj in get_all_tools() if getattr(tool_obj, "name", None) == tool_name), None)
# #endregion AgentChat.ToolResolver.FindTool
# #endregion AgentChat.ToolResolver

View File

@@ -1,40 +0,0 @@
# backend/src/agent/context.py
# #region AgentChat.Context [C:3] [TYPE Module] [SEMANTICS agent-chat,context,auth]
# @defgroup AgentChat JWT context propagation for LangGraph tools.
# @RATIONALE LangGraph tool execution may run in a different async context,
# preventing ContextVar from propagating. Module-level globals
# ensure the JWT is always accessible from any execution context.
# @NOTE NOT thread-safe — each Gradio agent process handles one request at a time
# (enforced by _user_locks in app.py).
_user_jwt: str = ""
_service_jwt: str = ""
_user_role: str = "viewer"
def set_user_jwt(jwt: str) -> None:
global _user_jwt
_user_jwt = jwt
def get_user_jwt() -> str:
return _user_jwt
def set_user_role(role: str) -> None:
global _user_role
_user_role = role or "viewer"
def get_user_role() -> str:
return _user_role
def set_service_jwt(jwt: str) -> None:
global _service_jwt
_service_jwt = jwt
def get_service_jwt() -> str:
return _service_jwt
# #endregion AgentChat.Context

View File

@@ -1,59 +0,0 @@
# backend/src/agent/middleware.py
# #region AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS agent-chat,middleware,logging,audit]
# @defgroup AgentChat Audit logging and confirmation risk middleware for LangGraph agent.
# @BRIEF LoggingMiddleware writes tool-call events to assistant_audit table.
# @RELATION DEPENDS_ON -> [AssistantAuditRecord]
# @RATIONALE FR-024: All agent interactions must be logged for auditability.
# @REJECTED ConfirmationRiskMiddleware rejected — LangGraph interrupt_before=DANGEROUS_TOOLS handles HITL natively.
from datetime import UTC, datetime
from src.agent.context import get_user_jwt
from src.agent.tools import _redact_sensitive_fields
from src.core.logger import logger
# #region AgentChat.Middleware.LoggingMiddleware [C:3] [TYPE Function] [SEMANTICS audit,tool,logging]
# @ingroup AgentChat
# @BRIEF Log every tool-call event to assistant_audit table with user context.
# @PRE agent event has 'event' key with type on_tool_start/on_tool_end/on_tool_error.
# @POST Audit record written to assistant_audit table (async, non-blocking).
# @SIDE_EFFECT Writes to assistant_audit table via FastAPI REST call.
# @RELATION DISPATCHES -> [get_assistant_audit]
async def log_tool_event(event: dict, conversation_id: str) -> None:
"""Log a tool-call event to the audit trail.
Args:
event: LangGraph event dict with 'event', 'name', and 'data' keys.
conversation_id: Current conversation thread ID.
"""
kind = event.get("event", "")
tool_name = event.get("name", "unknown")
user_jwt = get_user_jwt()
audit_payload = {
"event_type": kind,
"tool": tool_name,
"conversation_id": conversation_id,
"user_jwt_present": bool(user_jwt),
"timestamp": datetime.now(UTC).isoformat(),
}
if "data" in event:
data = event["data"]
if kind == "on_tool_start":
raw_input = data.get("input", "")
audit_payload["input"] = str(_redact_sensitive_fields(raw_input))[:500]
elif kind == "on_tool_error":
audit_payload["error"] = str(data.get("error", ""))[:500]
logger.reason(
"Tool audit event",
payload=audit_payload,
extra={"src": "AgentChat.Middleware.LoggingMiddleware"},
)
# TODO: Async write to assistant_audit table via REST call to FastAPI
# This is intentionally fire-and-forget — audit failures must not block tool execution
# #endregion AgentChat.Middleware.LoggingMiddleware
# #endregion AgentChat.Middleware

View File

@@ -22,6 +22,7 @@ __all__ = [
"admin",
"admin_api_keys",
"agent_conversations",
"agent_status",
"agent_superset",
"agent_superset_explore",
"assistant",

View File

@@ -2,7 +2,7 @@
# #region Api.Agent.Status [C:2] [TYPE Module] [SEMANTICS api,agent,llm,status,health]
# @BRIEF Agent LLM provider health status endpoint — used by frontend for provider availability indicator.
# @RATIONALE Frontend performs health check at mount and auto-retries every 30s if provider unavailable.
# @RELATION DEPENDS_ON -> [AgentChat.GradioApp]
# @RELATION DEPENDS_ON -> [ss_tools.shared._llm_health]
from fastapi import APIRouter
router = APIRouter(prefix="/api/agent", tags=["agent-status"])
@@ -13,10 +13,14 @@ router = APIRouter(prefix="/api/agent", tags=["agent-status"])
# @BRIEF Return cached LLM provider health status (or trigger probe if cache expired).
# @POST Returns {"status": "ok"|"unavailable"|"timeout"|"auth_error",
# "last_error": str, "retry_after_s": int}
# @RATIONALE Uses ss_tools.shared._llm_health instead of src.agent._llm_health
# because the agent code has been moved to a separate project. The shared
# module uses only openai+httpx (no gradio, no langchain), so it works in
# both backend and agent containers.
@router.get("/llm-status")
async def get_llm_status():
"""Get cached LLM provider health status. Probes provider if cache expired."""
from src.agent._llm_health import _check_llm_provider_health, _llm_status
from ss_tools.shared._llm_health import _check_llm_provider_health, _llm_status
status = await _check_llm_provider_health()
return {
"status": status,

View File

@@ -1,51 +1,42 @@
# #region docker.agent.Dockerfile [C:3] [TYPE Module] [SEMANTICS docker,agent,build]
# @BRIEF Agent Dockerfile — Python 3.11 slim + Gradio + LangGraph agent.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [EXT:path:backend/requirements.txt]
# @PRE Docker builder context — корень проекта. backend/ и docker/ доступны.
# @POST Образ с Gradio-агентом, LangGraph, минимальной копией backend/src/.
# @RATIONALE Агенту нужен только src.agent.* и src.core.cot_logger (чистый stdlib).
# Полный трассировщик импортов показал:
# src.agent.app → src.agent.{context,document_parser,langgraph_setup,middleware,tools}
# → src.core.cot_logger (stdlib-only, нет бизнес-логики)
# Ни src.models, ни src.api, ни src.services, ни src.plugins НЕ импортируются.
# @REJECTED COPY backend/ /app/backend/ rejected — нарушает принцип изоляции контейнеров.
# COPY только backend/src/agent/ недостаточно — нужен src.core.cot_logger.
# @RELATION DEPENDS_ON -> [EXT:path:agent/requirements.txt]
# @RELATION DEPENDS_ON -> [EXT:path:shared/pyproject.toml]
# @PRE Docker builder context — корень проекта. agent/, shared/, docker/ доступны.
# @POST Образ с Gradio-агентом, LangGraph, shared-утилитами.
# @RATIONALE Агент теперь в отдельном проекте agent/ с собственным pyproject.toml.
# Shared-пакет устанавливается как зависимость.
# @REJECTED COPY backend/ копирование полностью устарело — агент больше не в backend.
FROM python:3.11-slim
WORKDIR /app
# Install system deps for pdfplumber + psycopg (v3 needs libpq) + certs
# System deps for pdfplumber + psycopg + certs
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 libpq5 ca-certificates openssl && rm -rf /var/lib/apt/lists/*
# Python dependencies — agent runtime (gradio, langgraph, httpx, pdfplumber, ...)
# Без sentence-transformers — embedding router безопасно отключается при ImportError.
# Python dependencies — agent runtime
ARG WITH_EMBEDDINGS=false
COPY backend/requirements-agent.txt /app/backend/requirements-agent.txt
RUN pip install --no-cache-dir -r /app/backend/requirements-agent.txt
# Install shared package first (lightweight utilities)
COPY shared/ /app/shared/
RUN pip install --no-cache-dir -e /app/shared/
# Optional: semantic embedding routing (большие ML-зависимости: ~500 MB+)
COPY backend/requirements-embeddings.txt /app/backend/requirements-embeddings.txt
# Install agent requirements
COPY agent/requirements.txt /app/agent/requirements.txt
RUN pip install --no-cache-dir -r /app/agent/requirements.txt
# Optional: semantic embedding routing
COPY agent/requirements-embeddings.txt /app/agent/requirements-embeddings.txt
RUN if [ "$WITH_EMBEDDINGS" = "true" ]; then \
pip install --no-cache-dir -r /app/backend/requirements-embeddings.txt; \
pip install --no-cache-dir -r /app/agent/requirements-embeddings.txt; \
fi
# ── Минимальная копия backend/src/ ──────────────────────────────
# Только то, что реально импортирует агент (трассировка 2026-06-14):
# src/__init__.py — пакетный корень src
# src/core/__init__.py — пакетный корень src.core
# src/core/cot_logger.py — разделяемый логгер (stdlib-only)
# src/agent/*.py — сам агент (8 .py-файлов, плоская структура)
# Ничего из api/, models/, schemas/, services/, plugins/ — не копируем.
COPY backend/src/__init__.py /app/backend/src/__init__.py
COPY backend/src/core/__init__.py /app/backend/src/core/__init__.py
COPY backend/src/core/cot_logger.py /app/backend/src/core/cot_logger.py
COPY backend/src/core/logger.py /app/backend/src/core/logger.py
COPY backend/src/core/ws_log_handler.py /app/backend/src/core/ws_log_handler.py
COPY backend/src/agent/ /app/backend/src/agent/
# Agent source code
COPY agent/ /app/agent/
RUN pip install --no-cache-dir -e /app/agent/
# Entrypoint for corporate CA cert installation
COPY docker/certs.sh /app/docker/certs.sh
@@ -56,6 +47,6 @@ RUN chmod +x /app/docker/agent.entrypoint.sh /app/docker/certs.sh
ENV GRADIO_SERVER_NAME=0.0.0.0
ENV GRADIO_SERVER_PORT=7860
WORKDIR /app/backend
WORKDIR /app/agent
ENTRYPOINT ["/app/docker/agent.entrypoint.sh"]
# #endregion docker.agent.Dockerfile

View File

@@ -15,5 +15,5 @@ else
echo "[agent-entry] certs.sh not found — skipping corporate CA installation"
fi
exec python -m src.agent.run
exec python -m ss_tools.agent.run
# #endregion docker.agent.entrypoint

View File

@@ -2,9 +2,10 @@
# @BRIEF Backend Dockerfile — Python 3.11 slim + Playwright + entrypoint с установкой сертификатов.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [EXT:path:docker/backend.entrypoint.sh]
# @RELATION DEPENDS_ON -> [EXT:path:backend/requirements.txt]
# @PRE Docker builder context — корень проекта. backend/ и docker/ доступны.
# @POST Образ с backend API, Playwright, entrypoint для bootstrap admin и установки сертификатов.
# @RELATION DEPENDS_ON -> [EXT:path:backend/requirements-backend.txt]
# @RELATION DEPENDS_ON -> [EXT:path:shared/pyproject.toml]
# @PRE Docker builder context — корень проекта. backend/, shared/, docker/ доступны.
# @POST Образ с backend API, Playwright, shared-утилитами, entrypoint для bootstrap admin.
FROM python:3.11-slim
@@ -14,8 +15,7 @@ ENV BACKEND_PORT=8000
WORKDIR /app
# Системные зависимости: ca-certificates для корп. сертификатов, libnss3-tools
# для установки CA в NSS базу Chromium, curl, git для Playwright/GitPython
# Системные зависимости
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
@@ -23,15 +23,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3-tools \
&& rm -rf /var/lib/apt/lists/*
# Python зависимости — backend runtime (agent/ML deps excluded)
# Python зависимости — backend runtime
COPY backend/requirements-backend.txt /app/backend/requirements-backend.txt
RUN pip install --no-cache-dir -r /app/backend/requirements-backend.txt
# Install shared package (LLM health, SSL, cot_logger)
COPY shared/ /app/shared/
RUN pip install --no-cache-dir -e /app/shared/
# Install shared LLM deps (openai, httpx for _llm_health)
RUN pip install --no-cache-dir "openai>=1.0.0" "httpx>=0.28.1"
RUN python -m playwright install --with-deps chromium
# Исходный код
COPY backend/ /app/backend/
# Копируем shared certs.sh и entrypoint — установит корп. сертификаты, Playwright и сделает bootstrap admin
# Копируем shared certs.sh и entrypoint
COPY docker/certs.sh /app/certs.sh
COPY docker/backend.entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
@@ -42,4 +50,4 @@ EXPOSE 8000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["python", "-m", "uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000"]
# #endregion docker.backend.Dockerfile
# #endregion docker.backend.Dockerfile

17
run.sh
View File

@@ -375,15 +375,18 @@ start_agent() {
echo -e "\033[0;35m[Agent]\033[0m Starting Gradio agent on port $AGENT_PORT..."
echo -e "\033[0;35m[Agent]\033[0m LLM config will be fetched from FastAPI /api/agent/llm-config at startup."
echo -e "\033[0;35m[Agent]\033[0m Configure LLM providers in Admin → LLM Settings."
cd backend
cd agent
if [ -f ".venv/bin/activate" ]; then
source .venv/bin/activate
elif [ -f "../backend/.venv/bin/activate" ]; then
source "../backend/.venv/bin/activate"
fi
if [ -f ".env" ]; then
# Agent reads backend .env for LLM/DB config
if [ -f "../backend/.env" ]; then
local _saved_dev_mode="$DEV_MODE"
set -a
# shellcheck disable=SC1091
. .env
. "../backend/.env"
set +a
DEV_MODE="${_saved_dev_mode:-$DEV_MODE}"
fi
@@ -392,12 +395,12 @@ start_agent() {
export GRADIO_ALLOW_PORT_FALLBACK="${GRADIO_ALLOW_PORT_FALLBACK:-false}"
export AGENT_CONFIRM_TOOLS="${AGENT_CONFIRM_TOOLS:-true}"
if [ "$DEV_MODE" = "true" ]; then
echo -e "\033[0;35m[Agent]\033[0m Hot-reload enabled via watchfiles (watching src/agent/)"
echo -e "\033[0;35m[Agent]\033[0m Hot-reload enabled via watchfiles (watching ss_tools/agent/)"
PYTHONUNBUFFERED=1 python3 -m watchfiles --filter python \
"python -m src.agent.run" src/agent \
"python -m ss_tools.agent.run" src/ss_tools/agent \
> >(awk -v prefix="$_color " '{print prefix $0; fflush()}') 2>&1 &
else
PYTHONUNBUFFERED=1 python3 -m src.agent.run \
PYTHONUNBUFFERED=1 python3 -m ss_tools.agent.run \
> >(awk -v prefix="$_color " '{print prefix $0; fflush()}') 2>&1 &
fi
AGENT_PID=$!
@@ -411,4 +414,4 @@ start_agent
echo "Services are running. Press Ctrl+C to stop."
wait
# [/DEF:run:Module]
# #endregion run

17
shared/pyproject.toml Normal file
View File

@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "ss-tools-shared"
version = "0.1.0"
description = "Shared lightweight utilities for superset-tools backend and agent"
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
llm = ["openai>=1.0.0", "httpx>=0.28.1"]
[tool.setuptools.packages.find]
where = ["src"]
include = ["ss_tools.shared*"]

View File

@@ -0,0 +1,8 @@
# #region ss_tools.shared [C:1] [TYPE Package] [SEMANTICS shared,package,init]
# @BRIEF Shared lightweight utilities for superset-tools backend and agent.
# Contains: cot_logger (stdlib-only trace propagation),
# ssl (stdlib-only SSL context),
# logger (lightweight JSON logger, no pydantic),
# _llm_health (LLM health probe, openai+httpx).
# @INVARIANT No dependency on FastAPI, SQLAlchemy, Gradio, LangChain.
# #endregion ss_tools.shared

View File

@@ -1,6 +1,5 @@
# backend/src/agent/_llm_health.py
# #region AgentChat.LlmHealth [C:3] [TYPE Module] [SEMANTICS agent-chat,llm,health,status]
# @ingroup AgentChat
# @defgroup Shared Shared lightweight utilities for backend and agent.
# @BRIEF LLM provider health check with in-memory cache (30s TTL).
# Uses openai+httpx only (no langchain) — works in both backend and agent containers.
# @LAYER Infrastructure
@@ -14,6 +13,7 @@
# This module uses only openai+httpx (available in both containers).
# @POST Returns status string: 'ok' | 'unavailable' | 'timeout' | 'auth_error'.
# @SIDE_EFFECT Makes a probe request to the LLM provider; caches result in module memory.
# @INVARIANT No dependency on Gradio, LangChain, FastAPI, SQLAlchemy, pydantic.
import os
import time
@@ -27,7 +27,7 @@ from openai import (
RateLimitError,
)
from src.core.logger import logger
from .logger import logger
# ── LLM provider health cache ─────────────────────────────────────────
_llm_status: dict[str, Any] = {
@@ -41,13 +41,6 @@ _LLM_LAST_ERROR_TS_KEY = "last_llm_error_ts"
# #region AgentChat.LlmHealth.Check [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,health,check]
# @ingroup AgentChat
# @BRIEF Check LLM provider connectivity with in-memory cache (30s TTL).
# @POST Returns status string: 'ok' | 'unavailable' | 'timeout' | 'auth_error'.
# @SIDE_EFFECT Makes a probe request to the LLM provider; caches result in module memory.
# @RATIONALE Prevents sending every user request into a dead LLM backend.
# @REJECTED langchain_openai.ChatOpenAI was rejected — not installed in backend
# container. AsyncOpenAI from the openai package is available in both containers.
async def _check_llm_provider_health() -> str:
"""Check LLM provider connectivity. Cached for _LLM_CHECK_CACHE_TTL seconds."""
now = time.time()
@@ -79,7 +72,7 @@ async def _check_llm_provider_health() -> str:
# Probe LLM API using AsyncOpenAI (available in both backend and agent containers)
try:
from openai import AsyncOpenAI
from src.core.ssl import system_ssl_context
from .ssl import system_ssl_context
api = AsyncOpenAI(
api_key=config.get("api_key", ""),
@@ -126,7 +119,5 @@ async def _check_llm_provider_health() -> str:
return "ok"
logger.explore("LLM health check failed", error=str(exc), extra={"src": "AgentChat.LlmHealth.Check"})
return _llm_status["status"] # return cached status on unexpected errors
# #endregion AgentChat.LlmHealth.Check
# #endregion AgentChat.LlmHealth

View File

@@ -0,0 +1,127 @@
# #region CotLoggerModule [C:4] [TYPE Module] [SEMANTICS logging,cot,json,trace,structured]
# @defgroup Shared Shared lightweight utilities for backend and agent.
# @BRIEF Structured JSON logger implementing the decision audit logging protocol.
# Uses ContextVar for trace_id and span_id propagation across async contexts.
# Provides log(), seed_trace_id(), push_span(), pop_span().
# @LAYER Core
# @PRE Python 3.7+ (ContextVar available).
# @POST JSON log records written to the 'cot' Python logger.
# @SIDE_EFFECT Writes structured JSON to the 'cot' Python logger.
# @DATA_CONTRACT Log call -> Single-line JSON to logging.StreamHandler/file.
# @INVARIANT No dependency on FastAPI, SQLAlchemy, Gradio, LangChain, pydantic.
# Pure stdlib + standard logging.
from contextvars import ContextVar
import logging
from typing import Any
import uuid
# #region cot_trace_context [C:1] [TYPE Data] [SEMANTICS contextvar,trace_id,span_id,propagation]
_trace_id: ContextVar[str] = ContextVar("_trace_id", default="")
_span_id: ContextVar[str] = ContextVar("_span_id", default="")
# #endregion cot_trace_context
# #region cot_logger_instance [C:1] [TYPE Data] [SEMANTICS logger,instance]
cot_logger = logging.getLogger("cot")
# #endregion cot_logger_instance
__all__ = [
"get_trace_id",
"log",
"pop_span",
"push_span",
"seed_trace_id",
"set_trace_id",
]
# #region seed_trace_id [C:1] [TYPE Function] [SEMANTICS trace_id,uuid,contextvar,set]
def seed_trace_id() -> str:
"""Generate a new UUID4 trace ID and store it in the thread-local ContextVar."""
trace_id = str(uuid.uuid4())
_trace_id.set(trace_id)
return trace_id
# #endregion seed_trace_id
# #region set_trace_id [C:1] [TYPE Function] [SEMANTICS trace_id,contextvar,set,public]
def set_trace_id(trace_id: str) -> None:
"""Set a specific trace_id into the ContextVar (e.g. from an incoming header)."""
_trace_id.set(trace_id)
# #endregion set_trace_id
# #region get_trace_id [C:1] [TYPE Function] [SEMANTICS trace_id,contextvar,get,public]
def get_trace_id() -> str:
"""Get the current trace_id from the thread-local ContextVar."""
return _trace_id.get()
# #endregion get_trace_id
# #region push_span [C:1] [TYPE Function] [SEMANTICS span_id,contextvar,stack]
def push_span(span: str) -> str:
"""Push a new span ID onto the context and return the previous span ID."""
prev = _span_id.get()
_span_id.set(span)
return prev
# #endregion push_span
# #region pop_span [C:1] [TYPE Function] [SEMANTICS span_id,contextvar,restore]
def pop_span(prev: str) -> None:
"""Restore a previous span ID into the ContextVar."""
_span_id.set(prev)
# #endregion pop_span
# #region cot_log_function [C:2] [TYPE Function] [SEMANTICS log,json,structured,marker]
def log(
src: str,
marker: str,
intent: str,
payload: dict[str, Any] | None = None,
error: str | None = None,
level: str | None = None,
) -> None:
"""Emit a single-line structured JSON log record through the 'cot' logger."""
if level is None:
level = "WARNING" if marker == "EXPLORE" else "INFO"
extra: dict[str, Any] = {
"marker": marker,
"intent": intent,
"src": src,
"span_id": _span_id.get() or None,
}
if payload is not None:
extra["payload"] = payload
if error is not None:
extra["error"] = error
log_func = {
"WARNING": cot_logger.warning,
"ERROR": cot_logger.error,
"DEBUG": cot_logger.debug,
}.get(level, cot_logger.info)
log_func(intent, extra=extra)
# #endregion cot_log_function
# #region MarkerLogger [C:2] [TYPE Class] [SEMANTICS logger,proxy,marker,syntactic-sugar]
class MarkerLogger:
"""Thin proxy over the cot_logger.log() function."""
def __init__(self, module_name: str) -> None:
self._module_name = module_name
def reason(self, intent: str, *, payload: dict[str, Any] | None = None) -> None:
log(self._module_name, "REASON", intent, payload=payload)
def reflect(self, intent: str, *, payload: dict[str, Any] | None = None) -> None:
log(self._module_name, "REFLECT", intent, payload=payload)
def explore(
self,
intent: str,
*,
payload: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
log(self._module_name, "EXPLORE", intent, payload=payload, error=error)
# #endregion MarkerLogger
# #endregion CotLoggerModule

View File

@@ -0,0 +1,147 @@
# #region SharedLoggerModule [C:3] [TYPE Module] [SEMANTICS logging,json,formatter,structured]
# @BRIEF Lightweight JSON logger for agent container.
# Provides the same .reason()/.reflect()/.explore() API as backend's
# src.core.logger, but without pydantic, WebSocketLogHandler, or
# RotatingFileHandler dependencies.
# @INVARIANT No dependency on FastAPI, SQLAlchemy, Gradio, LangChain, pydantic.
# @INVARIANT Every log.info/error/warning call produces single-line JSON.
# @DATA_CONTRACT Log record format:
# {ts, level, trace_id, src, marker, intent, span_id?, payload?, error?}
import json
import logging
import sys
import types
from datetime import UTC, datetime
from .cot_logger import _span_id, _trace_id
# #region CotJsonFormatter [C:2] [TYPE Class] [SEMANTICS logging,formatter,json,cot]
class CotJsonFormatter(logging.Formatter):
"""JSON formatter matching the backend decision audit protocol.
Reads structured data from the LogRecord's extra attributes.
"""
def format(self, record):
marker = getattr(record, 'marker', None)
intent = getattr(record, 'intent', None)
payload = getattr(record, 'payload', None)
error = getattr(record, 'error', None)
src = getattr(record, 'src', None) or record.name
if not marker:
marker = "REASON"
if not intent:
intent = record.getMessage()
log_obj = {
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
"level": record.levelname,
"trace_id": _trace_id.get() or "no-trace",
"src": src,
"marker": marker,
"intent": intent,
}
span_id = _span_id.get()
if span_id:
log_obj["span_id"] = span_id
if payload is not None:
log_obj["payload"] = payload
if error is not None:
log_obj["error"] = error
return json.dumps(log_obj, ensure_ascii=False, default=str)
# #endregion CotJsonFormatter
# #region shared_logger_setup [C:2] [TYPE Function] [SEMANTICS logging,setup,instance]
def _setup_logger() -> logging.Logger:
"""Create and configure the shared JSON logger.
Guards against duplicate handlers: if the logger already has handlers
(e.g., configured by backend's src.core.logger), skip setup.
"""
log = logging.getLogger("superset_tools_app")
# If already configured (e.g. by backend src.core.logger), skip
if log.handlers:
return log
log.setLevel(logging.INFO)
log.propagate = False
formatter = CotJsonFormatter()
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
log.addHandler(console_handler)
return log
logger = _setup_logger()
# #endregion shared_logger_setup
# #region explore [C:2] [TYPE Function] [SEMANTICS log,explore,marker,warning]
def explore(self, msg, *args, **kwargs):
"""Log an EXPLORE marker (WARNING level) with structured extra data."""
user_extra = kwargs.pop('extra', {})
error_val = kwargs.pop('error', None)
payload = kwargs.pop('payload', None)
extra: dict = {'marker': 'EXPLORE', 'intent': msg}
if error_val is not None:
extra['error'] = error_val
if not payload and len(args) == 1 and isinstance(args[0], dict):
payload = args[0]
args = ()
if payload is not None:
extra['payload'] = payload
extra.update(user_extra)
self.warning(msg, extra=extra, **kwargs)
# #endregion explore
# #region reason [C:2] [TYPE Function] [SEMANTICS log,reason,marker,info]
def reason(self, msg, *args, **kwargs):
"""Log a REASON marker (INFO level) with structured extra data."""
user_extra = kwargs.pop('extra', {})
error_val = kwargs.pop('error', None)
payload = kwargs.pop('payload', None)
extra: dict = {'marker': 'REASON', 'intent': msg}
if error_val is not None:
extra['error'] = error_val
if not payload and len(args) == 1 and isinstance(args[0], dict):
payload = args[0]
args = ()
if payload is not None:
extra['payload'] = payload
extra.update(user_extra)
self.info(msg, extra=extra, **kwargs)
# #endregion reason
# #region reflect [C:2] [TYPE Function] [SEMANTICS log,reflect,marker,info]
def reflect(self, msg, *args, **kwargs):
"""Log a REFLECT marker (INFO level) with structured extra data."""
user_extra = kwargs.pop('extra', {})
error_val = kwargs.pop('error', None)
payload = kwargs.pop('payload', None)
extra: dict = {'marker': 'REFLECT', 'intent': msg}
if error_val is not None:
extra['error'] = error_val
if not payload and len(args) == 1 and isinstance(args[0], dict):
payload = args[0]
args = ()
if payload is not None:
extra['payload'] = payload
extra.update(user_extra)
self.info(msg, extra=extra, **kwargs)
# #endregion reflect
# Monkey-patch the convenience methods onto the logger instance
logger.explore = types.MethodType(explore, logger)
logger.reason = types.MethodType(reason, logger)
logger.reflect = types.MethodType(reflect, logger)
# #endregion SharedLoggerModule

View File

@@ -0,0 +1,87 @@
# #region CoreSslTrust [C:4] [TYPE Module] [SEMANTICS ssl,tls,trust,certs,security]
# @BRIEF Centralized SSL/TLS trust context for all Python HTTP clients.
# @LAYER Infrastructure
# @INVARIANT Never returns verify=False from environment configuration.
# The application does not disable TLS verification via env var.
# All trust anchors come from the system CA store populated
# at container startup via CERTS_PATH (/opt/certs) mount.
# @INVARIANT No dependency on FastAPI, SQLAlchemy, Gradio, LangChain, pydantic.
import os
import ssl
from pathlib import Path
DEFAULT_CAPATH = "/etc/ssl/certs"
# #region CoreSslTrust.SystemContext [C:1] [TYPE Function] [SEMANTICS ssl,context,system]
# @ingroup Shared
# @BRIEF Return an SSLContext that trusts the system CA store.
def system_ssl_context() -> ssl.SSLContext:
"""Return an SSLContext that trusts the system CA store."""
if os.path.isdir(DEFAULT_CAPATH):
return ssl.create_default_context(capath=DEFAULT_CAPATH)
return ssl.create_default_context()
# #endregion CoreSslTrust.SystemContext
# #region CoreSslTrust.HttpxVerify [C:1] [TYPE Function] [SEMANTICS ssl,httpx,verify]
# @ingroup Shared
# @BRIEF Return SSL verification context for httpx clients.
def httpx_verify() -> ssl.SSLContext:
"""Return SSL verification context for httpx clients."""
return system_ssl_context()
# #endregion CoreSslTrust.HttpxVerify
# #region CoreSslTrust.DescribeContext [C:2] [TYPE Function] [SEMANTICS ssl,diagnostics,describe]
# @ingroup Shared
# @BRIEF Return a diagnostic description of the SSL verification state.
def describe_context(ctx: ssl.SSLContext | bool | None) -> str:
"""Return a diagnostic description of the SSL verification state."""
if ctx is False:
return "DISABLED (verify=False — should not happen in production)"
if ctx is None:
return "DEFAULT (certifi)"
if isinstance(ctx, ssl.SSLContext):
bundle = Path(DEFAULT_CAPATH) / "ca-certificates.crt"
cert_count = 0
if bundle.exists():
cert_count = bundle.read_bytes().count(b"-----BEGIN CERTIFICATE-----")
mode = "CERT_REQUIRED" if ctx.verify_mode == ssl.CERT_REQUIRED else str(ctx.verify_mode)
return f"SSLContext(verify_mode={mode}, system_certs={cert_count})"
return f"unknown({type(ctx).__name__})"
# #endregion CoreSslTrust.DescribeContext
# #region CoreSslTrust.CertInventory [C:2] [TYPE Function] [SEMANTICS ssl,certs,inventory,diagnostics]
# @ingroup Shared
# @BRIEF Inventory /opt/certs for operator diagnostics.
def cert_dir_inventory(certs_path: str = "/opt/certs") -> dict:
"""Inventory /opt/certs for operator diagnostics."""
result: dict[str, list[str]] = {
"trust_candidates": [],
"server_files": [],
"invalid": [],
}
base = Path(certs_path)
if not base.is_dir():
return result
for f in sorted(base.iterdir()):
if not f.is_file():
continue
name = f.name.lower()
if name in ("server.crt", "server.key", "server.pem") or any(
name.endswith(ext) for ext in (".key", ".p12", ".pfx")
):
result["server_files"].append(str(f))
continue
if any(name.endswith(ext) for ext in (".crt", ".pem", ".cer", ".der")):
result["trust_candidates"].append(str(f))
else:
result["invalid"].append(str(f))
return result
# #endregion CoreSslTrust.CertInventory
# #endregion CoreSslTrust

View File

@@ -0,0 +1,26 @@
# Requirements Checklist: 036 Agent Test Stabilization
**Purpose**: Validate specification quality and readiness for planning.
**Created**: 2026-07-07
**Feature**: specs/036-agent-test-stabilization/spec.md
## Spec Completeness
- [x] CHK001 User stories are independently testable.
- [x] CHK002 Functional requirements avoid implementation code while defining observable behavior.
- [x] CHK003 No unresolved `[NEEDS CLARIFICATION]` markers remain.
- [x] CHK004 Edge cases include stale context, dropped stream, permission denial, and backwards compatibility.
## Constitution Coverage
- [x] CHK005 Semantic contract and ADR relations are present in spec header.
- [x] CHK006 Decision memory records why stabilization precedes scenario generation.
- [x] CHK007 External orchestrator boundary is preserved.
- [x] CHK008 RBAC/HITL requirements are explicit for writes and approvals.
- [x] CHK009 Svelte UX remains model-driven and structured-event based.
## Readiness for Plan
- [x] CHK010 Required outputs are limited to agent runtime stabilization artifacts.
- [x] CHK011 Feature explicitly excludes Superset query baseline engine and scenario generation logic.
- [x] CHK012 Success criteria are measurable by backend/frontend tests and one live smoke flow.

View File

@@ -0,0 +1,107 @@
#region AgentTestStabilization.Spec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,agent,test-scenarios,stabilization]
@BRIEF Stabilize the existing Gradio/LangGraph agent runtime so it can safely support long-running dashboard test scenario generation.
@RELATION DEPENDS_ON -> [ADR-0001]
@RELATION DEPENDS_ON -> [ADR-0003]
@RELATION DEPENDS_ON -> [ADR-0005]
@RELATION DEPENDS_ON -> [ADR-0006]
@RELATION DEPENDS_ON -> [ADR-0008]
@RATIONALE Dashboard test scenario generation produces durable artifacts and baseline approval flows, so the agent must be a recoverable execution surface rather than a best-effort chat stream.
@REJECTED Starting dashboard scenario generation directly on top of unstabilized chat behavior — rejected because failures would be indistinguishable across Gradio transport, context passing, artifact preview, and HITL gates.
## Navigation (DSA Indexer keywords)
@SEMANTICS: spec, requirements, feature, agent, gradio, langgraph, scenario, artifact, hitl, progress, dashboard-testing
**Feature Branch**: `036-agent-test-stabilization`
**Created**: 2026-07-07 | **Status**: Draft
**Input**: "Stabilize the existing Gradio LangGraph agent runtime so it can support long running dashboard test scenario generation with stable dashboard UIContext, explicit scenario intent, structured progress events, draft artifact preview, recoverable agent run identifiers, and HITL confirmation for repository writes or baseline approvals."
## User Scenarios
### Story 1 — Dashboard Context Run Start (P1)
**Why P1**: Dashboard scenario generation must start from a precise dashboard context, environment, and intent without relying on user prose.
**Independent Test**: Open `/agent` from a dashboard route with query context and verify the agent run exposes the exact dashboard object, env, route, and scenario intent in debug/status metadata.
**Acceptance**:
1. **Given** a user clicks "Создать сценарий тестирования" on a dashboard **When** `/agent` opens **Then** `UIContext` contains `objectType=dashboard`, `objectId`, `objectName`, `envId`, `route`, `contextVersion`, and `intent=build_dashboard_test_scenario`.
2. **Given** the user opens `/agent` from two different dashboards in sequence **When** each run starts **Then** the second run uses the new dashboard context and never reuses stale `objectId` or `envId`.
3. **Given** malformed or oversized context is supplied **When** the agent run starts **Then** invalid context is rejected or discarded with visible recovery and audit metadata, not injected into the agent prompt.
---
### Story 2 — Recoverable Long Agent Runs (P1)
**Why P1**: Scenario generation may inspect Superset, produce draft artifacts, and wait for user approval; it cannot be a fragile one-shot response.
**Independent Test**: Start a simulated long scenario run, disconnect/reload `/agent`, and verify the user can see run status and draft artifact references by `run_id`.
**Acceptance**:
1. **Given** a scenario run starts **When** the backend accepts the request **Then** the UI receives a stable `agent_run_id` correlated with conversation id and dashboard context.
2. **Given** the Gradio stream drops during a run **When** the user reconnects **Then** the latest run status, progress stage, and recoverable draft references are visible.
3. **Given** no first observable event arrives within the configured timeout **When** timeout elapses **Then** the UI shows actionable recovery instead of endless thinking.
---
### Story 3 — Structured Progress and Draft Artifacts (P2)
**Why P2**: The UI must render scenario progress and artifact preview from structured events, not parse natural-language chat text.
**Independent Test**: Run a fixture-based scenario generation stub and verify progress stages and draft artifacts render from metadata events only.
**Acceptance**:
1. **Given** a run progresses through context, inspect, scenario, parameters, generate, validate, and save stages **When** events stream **Then** the UI updates each stage from structured metadata.
2. **Given** draft artifacts are produced **When** the agent reports them **Then** the user can preview artifact names, types, warnings, and validation status before any repository write.
3. **Given** an artifact contains warnings **When** preview renders **Then** warnings are visible and block silent save.
---
### Story 4 — HITL Write and Approval Gates (P2)
**Why P2**: Generated files and approved baselines affect future regression truth and must never be committed by the agent without human approval.
**Independent Test**: Trigger a repository-write or baseline-approval action and verify `confirm_required` appears with target path, operation type, risks, and denial path.
**Acceptance**:
1. **Given** the agent proposes saving generated artifacts **When** save is requested **Then** a confirmation card lists target paths, file count, risk level, and warnings before the write.
2. **Given** the agent proposes approving a baseline candidate **When** approval is requested **Then** confirmation requires a user-visible reason and shows provenance.
3. **Given** the user denies a write or approval **When** denial is submitted **Then** no artifact is written or approved, and the chat records an explicit cancellation outcome.
---
### Edge Cases
- JWT expires mid-run → next protected operation stops with session-expired recovery and does not retry privileged actions.
- User lacks permission to save artifacts or approve baselines → `permission_denied` appears instead of `confirm_required`.
- Multiple tabs start runs for the same dashboard → each run has an independent `agent_run_id`; artifact preview does not cross-contaminate.
- Existing 033/035 agent chat functions remain available → dashboard test intent does not break ordinary chat, tool cards, or confirmation flows.
## Requirements
### Functional
- **AGSTAB-FR-001**: `/agent` MUST accept and validate dashboard `UIContext` with explicit `intent=build_dashboard_test_scenario` while preserving backward compatibility with existing 035 `uicontext` payload position.
- **AGSTAB-FR-002**: Every long-running agent operation for dashboard testing MUST expose a stable `agent_run_id` correlated with conversation id, user id, dashboard context, and current progress stage.
- **AGSTAB-FR-003**: The agent runtime MUST emit structured progress metadata for scenario-oriented stages; UI MUST NOT infer these stages by parsing prose.
- **AGSTAB-FR-004**: Draft artifacts MUST be previewable before repository writes and must include type, name, intended path, validation status, warnings, and producing run id.
- **AGSTAB-FR-005**: Repository writes and baseline approvals MUST require HITL confirmation; approval must capture a user-supplied reason.
- **AGSTAB-FR-006**: Permission-denied operations MUST emit `permission_denied` metadata and never present a confirm button for unauthorized actions.
- **AGSTAB-FR-007**: The existing Gradio/LangGraph chat, streaming, confirmation, context, and guardrail flows from 033/035 MUST remain compatible.
- **AGSTAB-FR-008**: A live smoke test MUST verify context → stream → tool event → draft artifact preview → confirmation/denial recovery.
### Key Entities
- **DashboardScenarioUIContext**: Versioned context payload identifying the dashboard, environment, source route, and scenario-generation intent.
- **AgentRun**: Recoverable execution unit for long-running agent work; owns progress stage, events, draft artifact references, and terminal status.
- **ScenarioProgressEvent**: Structured metadata event representing a stage or step in scenario generation.
- **DraftArtifactRef**: Previewable reference to generated content that is not yet persisted as an approved repository artifact.
- **ApprovalGate**: HITL confirmation envelope for repository writes and baseline approvals.
## Success Criteria
- **SC-001**: 100% of dashboard scenario runs in test fixtures expose a valid `agent_run_id` before the first tool action.
- **SC-002**: Context handoff from dashboard page to `/agent` is verified for at least three dashboard ids with no stale-object reuse.
- **SC-003**: Structured progress events render all required stages without text parsing in frontend tests.
- **SC-004**: Repository write and baseline approval attempts are blocked until confirmation in 100% of permissioned test cases.
- **SC-005**: Existing 035 agent context/guardrails tests continue to pass unchanged or with documented fixture updates only.
#endregion AgentTestStabilization.Spec

View File

@@ -0,0 +1,85 @@
#region AgentTestStabilization.UxReference [C:3] [TYPE ADR] [SEMANTICS ux,reference,agent,dashboard-testing]
@BRIEF UX reference for stabilized agent execution before dashboard test scenario generation.
**Feature Branch**: `036-agent-test-stabilization`
**Created**: 2026-07-07 | **Status**: Draft
## 1. User Persona & Context
* **Who is the user?**: QA engineer, dashboard owner, or analyst preparing an agent-generated dashboard test scenario.
* **What is their goal?**: Start a reliable, recoverable agent run from a dashboard and preview generated outputs before any durable write.
* **Context**: Browser-based Svelte `/agent` workspace launched from a dashboard page with Superset environment selected.
## 2. Happy Path Narrative
The user clicks "Создать сценарий тестирования" on a dashboard. `/agent` opens with the dashboard context already visible: dashboard name, id, environment, and intent. The agent starts a recoverable run, streams structured progress stages, produces draft artifacts, and shows a confirmation card before saving anything. If the user denies, the run ends cleanly with no side effects.
## 3. Interface Mockups
### Agent Context Header
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🧪 Dashboard Test Scenario Agent ● connected │
├─────────────────────────────────────────────────────────────────────────────┤
│ Context: FI-0080 | dashboard_id=42 | env=ss-dev │
│ Intent: build_dashboard_test_scenario | run_id: ag-run-20260707-001 │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Progress Strip
```text
[Context ✓] → [Inspect …] → [Scenario] → [Parameters] → [Generate] → [Validate] → [Save]
```
### Draft Artifact Preview
```text
┌──────────────────────── Draft artifacts ────────────────────────────────────┐
│ Run: ag-run-20260707-001 │
│ ✓ scenario.yaml valid │
│ ⚠ baseline-candidates.yaml needs approval reason │
│ ✓ generated-preview.json valid │
│ │
│ [Preview] [Download draft] [Request save] │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Confirmation Gate
```text
┌──────────────────────── Confirmation required ──────────────────────────────┐
│ Save generated artifacts │
│ Target: tests/generated/dashboards/fi_0080/ │
│ Files: 3 │
│ Risk: repository write │
│ Warnings: 1 baseline candidate remains draft │
│ │
│ [Confirm save] [Deny] │
└─────────────────────────────────────────────────────────────────────────────┘
```
## 4. Error Experience
### Scenario A: Stale or Invalid Context
* **System Response**: Context card turns warning-tone and lists invalid fields.
* **Recovery**: User can return to dashboard, retry context handoff, or continue in non-scenario chat mode.
### Scenario B: Stream Drops During Run
* **System Response**: UI shows `run_id`, last known stage, and reconnect actions.
* **Recovery**: User reconnects and resumes viewing draft status; no write happens during disconnected state.
### Scenario C: Unauthorized Approval
* **System Response**: Permission-denied card with required role and no confirm button.
* **Recovery**: User can download draft only if allowed, or request approval from an authorized role.
## 5. Tone & Voice
* **Style**: Technical, explicit, side-effect aware.
* **Terminology**: Use "run", "draft artifact", "confirmation", "approval reason", and "dashboard context" consistently.
#endregion AgentTestStabilization.UxReference

View File

@@ -0,0 +1,25 @@
# Requirements Checklist: 037 Superset Baseline Engine
**Purpose**: Validate specification quality and no-SQL baseline scope.
**Created**: 2026-07-07
**Feature**: specs/037-superset-baseline-engine/spec.md
## Spec Completeness
- [x] CHK001 User stories cover inspection, Superset-native execution, normalization, comparison, and baseline lifecycle.
- [x] CHK002 Requirements explicitly reject direct SQL execution and SQL-based baseline provenance.
- [x] CHK003 No unresolved `[NEEDS CLARIFICATION]` markers remain.
- [x] CHK004 Edge cases include Superset API errors, stale fingerprints, empty results, and locale formatting.
## Constitution Coverage
- [x] CHK005 ADR relations preserve external orchestrator and RBAC boundaries.
- [x] CHK006 Decision memory explains Superset-native execution over SQL.
- [x] CHK007 Approved baseline lifecycle requires reviewable artifacts and HITL approval.
- [x] CHK008 Requirements are implementation-free but measurable.
## Readiness for Plan
- [x] CHK009 Query model, normalized filters, query context, result, baseline, and comparison entities are defined.
- [x] CHK010 Success criteria can be tested with deterministic fixtures.
- [x] CHK011 Scope excludes scenario graph UI and generated Playwright/XLSX execution details.

View File

@@ -0,0 +1,110 @@
#region SupersetBaselineEngine.Spec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,superset,baseline,dashboard-testing]
@BRIEF Superset-native query and baseline engine for dashboard testing without direct SQL execution.
@RELATION DEPENDS_ON -> [ADR-0001]
@RELATION DEPENDS_ON -> [ADR-0003]
@RELATION DEPENDS_ON -> [ADR-0005]
@RELATION DEPENDS_ON -> [AgentTestStabilization.Spec]
@RATIONALE Dashboard test assertions must validate the same Superset-side chart or dataset execution path that powers dashboards, not a parallel SQL path that can diverge from Superset filter/query semantics.
@REJECTED Direct SQL execution for dashboard test truth — rejected because the target requirement is stable Superset dataset/chart execution and filter fidelity, not separate database querying.
## Navigation (DSA Indexer keywords)
@SEMANTICS: spec, requirements, feature, superset, baseline, chart-data, dataset, filters, normalization, dashboard-testing
**Feature Branch**: `037-superset-baseline-engine`
**Created**: 2026-07-07 | **Status**: Draft
**Input**: "Create a Superset-native query and baseline engine for dashboard testing. The system must inspect dashboard query models, map dashboard native filters into Superset chart or dataset query context, execute Superset-side chart or dataset queries without direct SQL, normalize returned metric and table values, compare them with approved baseline catalog entries, and create draft baseline candidates requiring human approval."
## User Scenarios
### Story 1 — Inspect Dashboard Query Model (P1)
**Why P1**: Scenario generation must know charts, datasets, metrics, filters, and filter scopes before it can build reliable tests.
**Independent Test**: Provide a dashboard fixture and verify the engine returns structured query model data for charts, datasets, metrics, native filters, and capabilities.
**Acceptance**:
1. **Given** a dashboard id and environment **When** the query model is inspected **Then** the result lists dashboard title, charts, datasets, metric labels/keys, native filters, filter targets, and export capabilities.
2. **Given** a native filter applies only to selected charts **When** inspection runs **Then** filter scope is represented so unrelated charts are not queried with invalid filters.
3. **Given** a chart or dataset is inaccessible **When** inspection runs **Then** the engine reports a structured warning or permission error without inventing missing metadata.
---
### Story 2 — Execute Superset Query Context (P1)
**Why P1**: Baseline validation depends on executing Superset chart/dataset queries through Superset-native APIs, never through direct SQL.
**Independent Test**: Execute a fixture chart query with normalized dashboard filters and verify returned data is traceable to chart id, dataset id, metric, filters hash, and environment.
**Acceptance**:
1. **Given** a chart metric and normalized filter context **When** execution is requested **Then** the engine calls the Superset-native chart/dataset execution path and returns structured values.
2. **Given** dashboard filters are applied **When** query context is built **Then** Superset receives semantically equivalent filters to the UI native filter state.
3. **Given** a request would require arbitrary SQL text from the agent **When** execution is attempted **Then** the request is rejected as unsupported.
---
### Story 3 — Normalize and Compare Results (P1)
**Why P1**: UI, Superset API, and XLSX values must be comparable despite formatting differences.
**Independent Test**: Feed formatted decimal, date, percent, big-number, and table-row fixtures and verify normalized values compare consistently with tolerance rules.
**Acceptance**:
1. **Given** Superset returns a metric value **When** normalization runs **Then** the value is represented with type, raw value, normalized value, label, and source metadata.
2. **Given** an approved baseline exists **When** an actual value is compared **Then** exact, absolute tolerance, relative tolerance, range, or row-set comparison rules are applied according to baseline policy.
3. **Given** actual data cannot be normalized **When** comparison is requested **Then** the result is `inconclusive` with an explanation, not a false pass.
---
### Story 4 — Baseline Candidate Lifecycle (P2)
**Why P2**: The agent may discover candidate reference values but must not silently define the truth.
**Independent Test**: Run discovery for a metric without baseline and verify a draft candidate is produced; approving it requires HITL from feature 036.
**Acceptance**:
1. **Given** no approved baseline exists for metric+filters **When** discovery runs **Then** a draft baseline candidate is created with provenance and source values.
2. **Given** a dashboard/chart/dataset/filter fingerprint changes **When** existing baselines are loaded **Then** affected baselines are marked or reported as stale.
3. **Given** UI/API/XLSX sources disagree **When** a candidate is created **Then** candidate approval is blocked or warning-gated until a user reviews the discrepancy.
---
### Edge Cases
- Superset returns empty result → compare result distinguishes expected empty, unexpected empty, and inconclusive.
- Filter value is not available in the target environment → query execution fails with recoverable validation error.
- Dashboard or chart metadata changes after baseline approval → fingerprint mismatch surfaces stale baseline warning.
- Superset API returns 403/404/422/5xx/timeout → taxonomy is preserved in comparison/report output.
- Date and number formats differ between locales → normalization uses canonical decimal/date representation.
## Requirements
### Functional
- **AGBASE-FR-001**: The engine MUST inspect dashboard query models into structured charts, datasets, metrics, native filters, filter scopes, and capabilities.
- **AGBASE-FR-002**: The engine MUST execute Superset-native chart/dataset query contexts without accepting arbitrary SQL text from the agent.
- **AGBASE-FR-003**: Dashboard native filters MUST be normalized into query filters with target columns, operators, values, scopes, and a deterministic filter hash.
- **AGBASE-FR-004**: Superset result normalization MUST support scalar metrics, big-number charts, table rows, dates, decimals, percentages, empty values, and raw/source metadata.
- **AGBASE-FR-005**: Baseline catalog entries MUST identify dashboard id, chart id or dataset id, metric/result key, normalized filters, expected value, tolerance, lifecycle status, fingerprints, and provenance.
- **AGBASE-FR-006**: Approved baselines MUST be stored as reviewable artifacts; observed runtime values and evidence remain separate from approved expectations.
- **AGBASE-FR-007**: The system MUST create draft baseline candidates only; approval is delegated to the HITL gate from 036.
- **AGBASE-FR-008**: Comparison output MUST include source, actual value, expected value, diff, status, tolerance rule, and stale/inconclusive warnings.
- **AGBASE-FR-009**: Direct SQL execution, generated SQL assertions, and SQL-based baseline provenance are explicitly out of scope for this feature.
### Key Entities
- **DashboardQueryModel**: Structured representation of charts, datasets, metrics, native filters, scopes, and execution capabilities for a dashboard.
- **NormalizedFilterContext**: Canonical filter state shared by UI, Superset query execution, scenario graph, XLSX comparison, and baseline lookup.
- **SupersetQueryContext**: Executable Superset-native query request derived from chart/dataset metadata plus normalized filters.
- **NormalizedSupersetResult**: Canonical metric/table result with type metadata and provenance.
- **BaselineEntry**: Approved or draft expected value definition for a metric/table result under a normalized filter context.
- **BaselineCandidate**: Draft expected value discovered from Superset execution and awaiting human approval.
- **ComparisonResult**: Result of comparing actual normalized values to baseline expectations.
## Success Criteria
- **SC-001**: Dashboard query model inspection returns complete chart/filter/dataset mappings for fixture dashboards with 100% deterministic JSON snapshots.
- **SC-002**: Superset-native execution validates at least scalar metric and table chart fixtures without direct SQL.
- **SC-003**: Decimal/date/percent normalization compares equivalent API/UI/XLSX formatted values without false diffs in fixture tests.
- **SC-004**: Baseline candidates are never marked approved without HITL approval metadata.
- **SC-005**: Fingerprint changes surface stale baseline warnings in 100% of changed chart/dataset/filter fixture cases.
#endregion SupersetBaselineEngine.Spec

View File

@@ -0,0 +1,79 @@
#region SupersetBaselineEngine.UxReference [C:3] [TYPE ADR] [SEMANTICS ux,reference,superset,baseline]
@BRIEF UX reference for Superset-native baseline execution and comparison results.
**Feature Branch**: `037-superset-baseline-engine`
**Created**: 2026-07-07 | **Status**: Draft
## 1. User Persona & Context
* **Who is the user?**: QA engineer or dashboard owner validating reference metric values through Superset-native execution.
* **What is their goal?**: See whether a dashboard metric under specific filters matches approved baseline values without running direct SQL.
* **Context**: Agent workspace or later scenario UI displays inspection, execution, normalization, baseline, and comparison states.
## 2. Happy Path Narrative
The user asks the agent to validate a metric for a dashboard and filter set. The system inspects the dashboard query model, executes the related Superset chart or dataset query, normalizes the result, compares it with an approved baseline, and displays a source-aware comparison. The UI explicitly states that no direct SQL was executed.
## 3. Interface Mockups
### Query Model Summary
```text
┌──────────────────── Dashboard query model ──────────────────────────────────┐
│ Dashboard: FI-0080 | env=ss-dev │
│ Charts: 7 | Datasets: 3 | Native filters: 4 │
│ Capabilities: chart_data ✓ dataset_query ✓ xlsx_export ✓ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Metric Comparison
```text
┌──────────────────── Metric validation ───────────────────────────────────────┐
│ Metric: Просроченная ДЗ │
│ Chart: Итого просроченная ДЗ | Dataset: dataset_finance_debt │
│ Filters: Дата=2026-05-29, Контрагент=АСК
│ │
│ Source Value Status │
│ Superset API 1 234 567.89 ✓ matches baseline │
│ Approved baseline 1 234 567.89 approved │
│ │
│ No direct SQL was executed. │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Baseline Candidate
```text
┌──────────────────── Baseline candidate ─────────────────────────────────────┐
│ No approved baseline found. │
│ Candidate from Superset execution: 1 234 567.89 │
│ Provenance: chart_id=128, dataset_id=77, filters_hash=sha256:... │
│ │
│ [Approve baseline] [Keep draft] [Discard] │
└─────────────────────────────────────────────────────────────────────────────┘
```
## 4. Error Experience
### Scenario A: Superset API Permission Denied
* **System Response**: Comparison card shows `403 permission denied`, target chart/dataset, and required action.
* **Recovery**: User can switch environment, request access, or skip that check.
### Scenario B: Stale Baseline
* **System Response**: Baseline status becomes warning-tone with fingerprint diff category.
* **Recovery**: User can run discovery and submit a new baseline candidate for approval.
### Scenario C: Inconclusive Normalization
* **System Response**: UI shows raw value and reason normalization failed.
* **Recovery**: User can mark as manual checkpoint or adjust metric mapping in a later planning phase.
## 5. Tone & Voice
* **Style**: Precise, source-aware, explicit about no direct SQL.
* **Terminology**: Use "Superset API", "query model", "baseline", "candidate", "stale", "fingerprint".
#endregion SupersetBaselineEngine.UxReference

View File

@@ -0,0 +1,25 @@
# Requirements Checklist: 038 Dashboard Scenario Model
**Purpose**: Validate ScenarioGraph specification quality.
**Created**: 2026-07-07
**Feature**: specs/038-dashboard-scenario-model/spec.md
## Spec Completeness
- [x] CHK001 User stories cover graph generation, validation, checklist mapping, and parameter/human checkpoint handling.
- [x] CHK002 Requirements reject direct script generation without validated scenario graph.
- [x] CHK003 No unresolved `[NEEDS CLARIFICATION]` markers remain.
- [x] CHK004 Edge cases include cycles, duplicate refs, stale baselines, unsupported XLSX, and unknown selectors.
## Constitution Coverage
- [x] CHK005 ADR relations include semantic protocol and upstream 036/037 dependencies.
- [x] CHK006 Decision memory records rejection of low-level user tool selection and raw script generation.
- [x] CHK007 Requirements support test-driven validation of C3+ model contracts.
- [x] CHK008 Artifact generation is downstream and not mixed into this model spec.
## Readiness for Plan
- [x] CHK009 Key entities define scenario, steps, refs, parameters, checklist cases, mappings, and validation result.
- [x] CHK010 Success criteria are measurable with deterministic fixtures and snapshots.
- [x] CHK011 Scope excludes UI implementation and Superset query engine internals.

View File

@@ -0,0 +1,111 @@
#region DashboardScenarioModel.Spec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,scenario,dashboard-testing]
@BRIEF Define a validated ScenarioGraph model for unique dashboard test flows generated by agents.
@RELATION DEPENDS_ON -> [ADR-0001]
@RELATION DEPENDS_ON -> [ADR-0002]
@RELATION DEPENDS_ON -> [AgentTestStabilization.Spec]
@RELATION DEPENDS_ON -> [SupersetBaselineEngine.Spec]
@RATIONALE Unique dashboard tests need a stable intermediate model between agent reasoning and generated artifacts; direct LLM-to-code generation is not reviewable or safely composable.
@REJECTED Asking users to choose low-level outputs such as Playwright vs SQL vs XLSX — rejected because each dashboard scenario is a goal-oriented flow whose steps select tools automatically.
@REJECTED Direct generation of executable scripts without a validated scenario graph — rejected because it hides missing selectors, baseline refs, and unsafe steps until runtime.
## Navigation (DSA Indexer keywords)
@SEMANTICS: spec, requirements, feature, scenario, graph, dashboard-testing, checklist, validation, artifacts
**Feature Branch**: `038-dashboard-scenario-model`
**Created**: 2026-07-07 | **Status**: Draft
**Input**: "Define the dashboard test scenario model used by agents to represent unique dashboard test flows as a validated ScenarioGraph. The model must express ordered and dependent steps across browser automation, Superset query execution, XLSX parsing, assertions, screenshots, reports, human checkpoints, baseline references, parameters, warnings, and missing context markers without exposing users to low-level tool selection."
## User Scenarios
### Story 1 — Build Scenario Graph From Dashboard Goal (P1)
**Why P1**: The agent must express a dashboard testing goal as a reviewable graph of steps before generating any executable artifacts.
**Independent Test**: Provide dashboard query model and checklist fixture input and verify a deterministic `DashboardTestScenario` graph is produced.
**Acceptance**:
1. **Given** a dashboard context, query model, and testing objective **When** the agent builds a scenario **Then** the output contains scenario id, dashboard context, parameters, steps, dependencies, expected outputs, warnings, and risk summary.
2. **Given** a scenario needs browser, Superset API, XLSX parsing, screenshots, assertions, and report steps **When** graph is rendered **Then** every step declares its tool and input/output refs.
3. **Given** required metadata is missing **When** graph is generated **Then** missing data is represented as `NEEDS_CONTEXT` or `NEEDS_SELECTOR`, not invented.
---
### Story 2 — Validate Scenario Graph Safety and Completeness (P1)
**Why P1**: Scenario artifacts must be generated only from a graph whose refs, baselines, tools, and risks are internally consistent.
**Independent Test**: Run valid and invalid scenario fixtures through the validator and verify precise errors for broken refs, missing baseline refs, and unsupported steps.
**Acceptance**:
1. **Given** a step consumes an output ref **When** validation runs **Then** the referenced producer must exist and precede or depend correctly.
2. **Given** an assertion compares a metric to expected truth **When** validation runs **Then** it must reference an approved baseline or a draft baseline candidate with explicit approval requirement.
3. **Given** a step uses an unsupported tool or unsafe action **When** validation runs **Then** the graph is rejected or marked blocked with a user-visible reason.
---
### Story 3 — Map Checklist Cases to Scenario Capabilities (P2)
**Why P2**: The PDF checklist should guide scenario coverage without forcing all dashboards into identical scripts.
**Independent Test**: Normalize the research checklist into capability-tagged cases and verify generated scenarios include applicable cases and mark non-applicable ones.
**Acceptance**:
1. **Given** the normalized checklist template contains basic, complex, and technical cases **When** a dashboard capability model is supplied **Then** applicable cases are mapped to scenario steps or human checkpoints.
2. **Given** a checklist case requires capabilities absent from the dashboard **When** mapping runs **Then** the case is marked unsupported or manual-only with rationale.
3. **Given** multiple tool choices could verify a case **When** mapping runs **Then** the scenario selects the tool chain that best matches the business goal and available capabilities.
---
### Story 4 — Represent Parameters and Human Checkpoints (P2)
**Why P2**: Unique dashboard scenarios require business inputs and sometimes cannot be fully automated.
**Independent Test**: Generate a scenario that requires test date, counterparty, and baseline selection; verify parameter prompts and human checkpoint steps are represented structurally.
**Acceptance**:
1. **Given** a scenario needs test data **When** graph is generated **Then** required parameters include name, type, validation rule, default/source, and affected steps.
2. **Given** an action cannot be automated reliably **When** graph is generated **Then** a human checkpoint step describes the manual action and expected evidence.
3. **Given** a user later supplies a parameter **When** graph is resolved **Then** dependent steps reference the resolved value without changing unrelated graph structure.
---
### Edge Cases
- Scenario has a cycle in dependencies → validator rejects with cycle path.
- Two steps produce the same output ref → validator rejects ambiguous ref.
- Baseline is stale → assertion step remains present but blocked or warning-gated.
- XLSX export is unavailable → XLSX-dependent checklist cases become unsupported or manual checkpoints.
- UI selector is unknown → browser step uses `NEEDS_SELECTOR` and blocks executable generation for that step.
## Requirements
### Functional
- **AGSCN-FR-001**: The system MUST define a `DashboardTestScenario` model with dashboard context, objective, parameters, steps, dependencies, outputs, artifacts, risks, and warnings.
- **AGSCN-FR-002**: Every scenario step MUST declare tool category, action, inputs, outputs, expected result, dependencies, and automation status.
- **AGSCN-FR-003**: Supported tool categories MUST include browser automation, Superset API execution, XLSX parsing, assertion, screenshot/evidence, report generation, artifact generation, and human checkpoint.
- **AGSCN-FR-004**: Assertions against reference values MUST use baseline references or baseline candidate references; raw expected numbers MUST NOT be embedded directly in executable steps.
- **AGSCN-FR-005**: Scenario validation MUST detect missing refs, cycles, duplicate outputs, missing baselines, stale baselines, unknown selectors, unsupported tools, and unresolved required parameters.
- **AGSCN-FR-006**: Checklist mapping MUST use normalized checklist cases derived from the research PDF and capability tags, not hardcoded one-size-fits-all scripts.
- **AGSCN-FR-007**: The model MUST allow manual/human checkpoint steps where automation is unsafe, unavailable, or underspecified.
- **AGSCN-FR-008**: Scenario output MUST be deterministic for the same dashboard query model, checklist template, baseline catalog, and user parameters.
- **AGSCN-FR-009**: The scenario model MUST remain implementation-neutral and must not require the user to choose low-level artifacts such as Playwright, XLSX, or API output upfront.
### Key Entities
- **DashboardTestScenario**: Reviewable graph representing a dashboard-specific testing objective and all steps required to validate it.
- **ScenarioStep**: One executable, generated, assertion, evidence, or human checkpoint node in the graph.
- **ScenarioParameter**: User- or context-provided input used by one or more scenario steps.
- **ScenarioRef**: Named output produced by one step and consumed by later steps.
- **ChecklistCase**: Normalized item from the research checklist with capability tags and expected verification semantics.
- **CapabilityMapping**: Decision record mapping dashboard capabilities to applicable checklist cases and step templates.
- **ScenarioValidationResult**: Structured validator output with errors, warnings, blockers, and graph coverage.
## Success Criteria
- **SC-001**: Scenario validator catches 100% of invalid fixture cases for missing refs, cycles, duplicate outputs, and missing baseline refs.
- **SC-002**: At least 80% of normalized PDF checklist cases are classifiable as automated, human checkpoint, unsupported, or needs-context for fixture dashboards.
- **SC-003**: Same inputs produce byte-stable scenario JSON/YAML in deterministic snapshot tests.
- **SC-004**: No generated scenario fixture embeds raw baseline numbers directly in executable steps.
- **SC-005**: Scenario graph preview can display phase order, tools per step, parameters, warnings, and blockers without reading generated code.
#endregion DashboardScenarioModel.Spec

View File

@@ -0,0 +1,80 @@
#region DashboardScenarioModel.UxReference [C:3] [TYPE ADR] [SEMANTICS ux,reference,scenario,dashboard-testing]
@BRIEF UX reference for reviewing a generated DashboardTestScenario graph as a goal-oriented test flow.
**Feature Branch**: `038-dashboard-scenario-model`
**Created**: 2026-07-07 | **Status**: Draft
## 1. User Persona & Context
* **Who is the user?**: QA engineer or dashboard owner reviewing the agent's proposed test scenario before execution or artifact generation.
* **What is their goal?**: Understand what will be tested, which tools each step uses, what parameters are needed, and what cannot be automated.
* **Context**: Agent workspace shows a scenario graph produced from dashboard metadata, checklist template, and baselines.
## 2. Happy Path Narrative
The agent proposes a scenario called "Проверка фильтров, метрик и XLSX выгрузки". The user sees phases, steps, dependencies, tool categories, expected outcomes, and missing parameters. The scenario is a business flow, not a menu of technologies, so the user approves the goal and parameters while the graph records tool selection internally.
## 3. Interface Mockups
### Scenario Summary
```text
┌──────────────────────── Proposed scenario ──────────────────────────────────┐
│ Goal: проверить фильтры, метрику, XLSX выгрузку и baseline │
│ Steps: 18 | Tools: browser, Superset API, XLSX, assertions, report │
│ Parameters required: test_date, counterparty │
│ Blockers: 0 | Warnings: 2 │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Scenario Graph
```text
[open_dashboard]
[apply_filters] ───────► [execute_superset_metric]
│ │
▼ ▼
[download_xlsx] ───────► [parse_xlsx_metric]
│ │
└──────────────► [compare_to_baseline] ──► [generate_report]
```
### Step Table
```text
┌────┬────────────────────────────┬──────────────┬───────────────────────────┐
│ № │ Step │ Tool │ Expected result │
├────┼────────────────────────────┼──────────────┼───────────────────────────┤
│ 1 │ Открыть дашборд │ browser │ dashboard_loaded │
│ 2 │ Применить фильтры │ browser │ filter_state.normalized │
│ 3 │ Выполнить chart query │ Superset API │ metric value returned │
│ 4 │ Скачать XLSX │ browser │ xlsx.file │
│ 5 │ Сравнить с baseline │ assertion │ pass/fail/inconclusive │
└────┴────────────────────────────┴──────────────┴───────────────────────────┘
```
## 4. Error Experience
### Scenario A: Missing Selector
* **System Response**: Step is marked `NEEDS_SELECTOR` and executable generation for that step is blocked.
* **Recovery**: User can provide selector hint, convert to human checkpoint, or remove the step.
### Scenario B: Stale Baseline
* **System Response**: Assertion step shows warning and stale fingerprint category.
* **Recovery**: User can run baseline discovery through 037 or mark the check as pending.
### Scenario C: Unsupported Checklist Case
* **System Response**: Checklist case is listed under unsupported/manual-only with rationale.
* **Recovery**: User can accept partial coverage or add manual checkpoint instructions.
## 5. Tone & Voice
* **Style**: Goal-oriented, explicit about confidence and blockers.
* **Terminology**: Use "scenario", "step", "tool", "parameter", "baseline ref", "human checkpoint".
#endregion DashboardScenarioModel.UxReference

View File

@@ -0,0 +1,25 @@
# Requirements Checklist: 039 Dashboard Scenario UI
**Purpose**: Validate user-facing dashboard scenario generation specification.
**Created**: 2026-07-07
**Feature**: specs/039-dashboard-scenario-ui/spec.md
## Spec Completeness
- [x] CHK001 User stories cover entry point, scenario preview, parameters, artifacts, save, and approval.
- [x] CHK002 Requirements avoid low-level tool dropdowns as the primary UX.
- [x] CHK003 No unresolved `[NEEDS CLARIFICATION]` markers remain.
- [x] CHK004 Edge cases include Superset errors, unresolved blockers, stale baselines, unavailable XLSX, and navigation recovery.
## Constitution Coverage
- [x] CHK005 ADR relations include frontend architecture, RBAC, and upstream specs 036-038.
- [x] CHK006 Decision memory rejects Playwright/SQL/XLSX primary dropdown UX.
- [x] CHK007 Svelte 5 runes/model-first requirement is explicit.
- [x] CHK008 HITL confirmation and RBAC are explicit for durable actions.
## Readiness for Plan
- [x] CHK009 UI entities are defined for entry action, workspace state, preview, parameters, baselines, artifacts, and confirmations.
- [x] CHK010 Success criteria are measurable through model, component, and browser tests.
- [x] CHK011 UX copy explicitly excludes direct SQL as a validation path.

View File

@@ -0,0 +1,127 @@
#region DashboardScenarioUi.Spec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,ux,scenario,dashboard-testing]
@BRIEF User-facing dashboard test scenario generation experience driven by agent analysis, scenario preview, parameter collection, artifact preview, and HITL save/approval.
@RELATION DEPENDS_ON -> [ADR-0001]
@RELATION DEPENDS_ON -> [ADR-0005]
@RELATION DEPENDS_ON -> [ADR-0006]
@RELATION DEPENDS_ON -> [AgentTestStabilization.Spec]
@RELATION DEPENDS_ON -> [SupersetBaselineEngine.Spec]
@RELATION DEPENDS_ON -> [DashboardScenarioModel.Spec]
@RATIONALE Users should approve a business-level dashboard test scenario and required parameters; low-level tool chains are visible for trust but selected by the agent and scenario validator.
@REJECTED Dropdowns such as "Playwright UI tests" vs "SQL checks" vs "XLSX checks" — rejected because each dashboard requires a unique cross-tool scenario, and SQL is explicitly out of scope.
## Navigation (DSA Indexer keywords)
@SEMANTICS: spec, requirements, feature, ux, agent, scenario, dashboard-testing, artifacts, baseline
**Feature Branch**: `039-dashboard-scenario-ui`
**Created**: 2026-07-07 | **Status**: Draft
**Input**: "Provide the user-facing dashboard test scenario generation experience. From a dashboard page the user starts Create test scenario, the agent analyzes the dashboard, proposes a unique scenario graph, collects missing business parameters, previews generated artifacts and baseline impacts, and requires HITL confirmation before saving files or approving baselines."
## User Scenarios
### Story 1 — Start Scenario From Dashboard Page (P1)
**Why P1**: The feature must be discoverable from the dashboard the user wants to test and must carry the correct context into the agent.
**Independent Test**: On a dashboard page, click "Создать сценарий тестирования" and verify `/agent` opens with dashboard context and scenario intent.
**Acceptance**:
1. **Given** a user is viewing a dashboard **When** they click "Создать сценарий тестирования" **Then** `/agent` opens in Dashboard Test Scenario Agent mode with dashboard id, name, env, route, and intent visible.
2. **Given** environment context is missing **When** the user starts the flow **Then** the UI prompts for environment selection before scenario analysis.
3. **Given** the user lacks scenario-generation permission **When** they click the action **Then** a permission-denied recovery path appears and no agent run starts.
---
### Story 2 — Review Proposed Unique Scenario (P1)
**Why P1**: The user must see the proposed business flow and tool chain before artifact generation.
**Independent Test**: Feed fixture scenario graph data and verify the UI renders phases, steps, tools, outputs, warnings, blockers, and coverage.
**Acceptance**:
1. **Given** the agent analyzes a dashboard **When** scenario graph is ready **Then** the UI shows goal, phases, step table, dependency graph, tool categories, expected results, coverage, warnings, and blockers.
2. **Given** some checklist cases are not automatable **When** scenario preview renders **Then** they appear as human checkpoints, unsupported, or needs-context with rationale.
3. **Given** the scenario includes Superset API metric validation **When** preview renders **Then** the UI clearly states Superset-native execution is used and direct SQL is not used.
---
### Story 3 — Collect Business Parameters and Baseline Choices (P1)
**Why P1**: Unique dashboard tests need values such as test date, counterparty, and baseline selection before generation is meaningful.
**Independent Test**: Scenario preview requests parameters; user fills them; dependent step statuses update from unresolved to ready.
**Acceptance**:
1. **Given** a scenario requires parameters **When** preview renders **Then** each parameter has label, type, validation message, default/source if available, and affected steps.
2. **Given** an approved baseline exists for metric+filters **When** the user selects matching parameters **Then** baseline match is shown and assertion steps become ready.
3. **Given** no approved baseline exists **When** the user proceeds **Then** the UI offers discovery candidate flow and marks baseline approval as separate HITL action.
---
### Story 4 — Preview Generated Artifacts (P2)
**Why P2**: Users need to inspect generated files, report templates, and warnings before saving.
**Independent Test**: Generate draft artifacts from a fixture scenario and verify file tree, validation status, warnings, and content preview render.
**Acceptance**:
1. **Given** draft artifacts are generated **When** preview opens **Then** the UI lists scenario file, generated runner/check files, report template, baseline candidate files, and validation status.
2. **Given** an artifact has unresolved markers **When** preview renders **Then** save is warning-gated and unresolved markers are visible.
3. **Given** user downloads draft artifacts **When** download completes **Then** repository state remains unchanged.
---
### Story 5 — Save or Approve With HITL (P2)
**Why P2**: Saving test packs and approving baselines have durable effects and must require explicit user approval.
**Independent Test**: Trigger save and baseline approval actions and verify confirmation cards include target, risk, warnings, and approval reason where required.
**Acceptance**:
1. **Given** the user requests save **When** generated artifacts target repository paths **Then** confirmation shows target paths, files, warnings, and risk before saving.
2. **Given** baseline approval is requested **When** confirmation appears **Then** the user must provide a reason and can inspect provenance/diff.
3. **Given** the user denies either action **When** denial is submitted **Then** no save or approval occurs and the run records cancellation.
---
### Edge Cases
- Agent analysis fails due to Superset API error → UI shows retry, switch environment, or manual-only scenario recovery.
- Scenario has unresolved blockers → generation can continue only for safe preview; executable save is blocked until resolved or converted to human checkpoint.
- Baseline is stale → UI warns and offers discovery candidate flow, not silent update.
- XLSX export is unavailable → UI shows coverage gap and alternate Superset API/UI checks.
- User navigates away during generation → run id allows returning to latest draft state from 036.
## Requirements
### Functional
- **AGUI-FR-001**: Dashboard pages MUST expose a single business-level action labeled "Создать сценарий тестирования"; UI MUST NOT present low-level artifact/tool choices as the primary entry point.
- **AGUI-FR-002**: `/agent` MUST show Dashboard Test Scenario Agent mode when opened with scenario intent from 036.
- **AGUI-FR-003**: The UI MUST render structured progress stages from agent metadata: context, inspect, scenario, parameters, generate, validate, save.
- **AGUI-FR-004**: The scenario preview MUST show business goal, phases, step list, tool category per step, dependency graph, coverage, warnings, blockers, and expected results.
- **AGUI-FR-005**: Parameter collection MUST support typed required parameters and update dependent scenario readiness without restarting the full flow.
- **AGUI-FR-006**: Baseline UI MUST show approved baseline matches, stale warnings, missing baselines, and draft candidate approval paths.
- **AGUI-FR-007**: Generated artifact preview MUST show file tree, file content preview, validation status, unresolved markers, and warnings before save.
- **AGUI-FR-008**: Saving generated files and approving baselines MUST go through HITL confirmation from 036; baseline approval requires a reason.
- **AGUI-FR-009**: The UI MUST explicitly communicate that metric validation uses Superset-native execution and does not execute direct SQL.
- **AGUI-FR-010**: All new UI state MUST follow Svelte 5 runes/model-first conventions and remain accessible by keyboard for confirmations, parameter forms, and previews.
### Key Entities
- **ScenarioEntryAction**: Dashboard-page action that launches `/agent` with dashboard scenario intent.
- **ScenarioWorkspaceState**: Frontend state machine for context, progress, scenario preview, parameter collection, artifact preview, and confirmation states.
- **ScenarioPreviewCard**: UI representation of `DashboardTestScenario` from 038.
- **ParameterPanel**: Form for scenario business parameters and validation feedback.
- **BaselineImpactPanel**: UI section showing approved, stale, missing, and candidate baseline statuses.
- **ArtifactPreviewPanel**: UI file tree/content preview for draft generated artifacts.
- **ScenarioConfirmationCard**: HITL card for save and baseline approval actions.
## Success Criteria
- **SC-001**: Users can launch scenario generation from a dashboard page in one click and see correct context in `/agent` in frontend tests.
- **SC-002**: Scenario preview renders at least 15-step fixture graphs with phases, tools, warnings, and blockers without layout collapse at 1366px width.
- **SC-003**: Parameter updates resolve dependent scenario readiness within 200ms in model tests.
- **SC-004**: Artifact preview blocks save when unresolved markers are present in fixture data.
- **SC-005**: 100% of save and baseline approval flows require confirmation; baseline approval cannot submit without reason.
- **SC-006**: UX copy never presents direct SQL as a validation path for this feature.
#endregion DashboardScenarioUi.Spec

View File

@@ -0,0 +1,128 @@
#region DashboardScenarioUi.UxReference [C:4] [TYPE ADR] [SEMANTICS ux,reference,scenario,dashboard-testing]
@BRIEF UX reference for the Dashboard Test Scenario Agent workspace and dashboard entry point.
@RELATION DEPENDS_ON -> [DashboardScenarioUi.Spec]
@RATIONALE The user experience is centered on approving a business scenario, not selecting implementation technologies.
@REJECTED Primary dropdown options for Playwright, SQL, and XLSX were rejected because they expose implementation details and conflict with unique cross-tool scenario generation.
**Feature Branch**: `039-dashboard-scenario-ui`
**Created**: 2026-07-07 | **Status**: Draft
## 1. User Persona & Context
* **Who is the user?**: QA engineer, dashboard owner, or analyst responsible for repeatable dashboard validation.
* **What is their goal?**: Generate a unique dashboard test scenario, provide business parameters, inspect generated artifacts, and approve durable changes.
* **Context**: Svelte UI in superset-tools; entry from dashboard page into `/agent` with scenario intent.
## 2. Happy Path Narrative
The user opens a dashboard and clicks "Создать сценарий тестирования". The agent analyzes the dashboard, presents a proposed scenario flow with browser, Superset API, XLSX, assertion, evidence, and report steps. The user fills required business parameters, previews generated artifacts and baseline impacts, then confirms save or keeps the artifacts as draft.
## 3. Interface Mockups
### Dashboard Entry
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ Dashboard: FI-0080 Env: ss-dev │
├─────────────────────────────────────────────────────────────────────────────┤
│ [Открыть в Superset] [Валидация] [Документация] │
│ │
│ [Создать сценарий тестирования] │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Agent Workspace
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🧪 Dashboard Test Scenario Agent ● connected │
├─────────────────────────────────────────────────────────────────────────────┤
│ Context: FI-0080 | dashboard_id=42 | env=ss-dev │
│ Progress: [Context ✓] → [Inspect ✓] → [Scenario …] → [Parameters] → [Save] │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Scenario Preview
```text
┌──────────────────────── Предложенный сценарий ──────────────────────────────┐
│ Цель: проверить фильтры, метрику и XLSX выгрузку │
│ Steps: 18 | Tools: browser, Superset API, XLSX, assertions, report │
│ │
│ № Step Tool Expected │
│ 1 Открыть дашборд browser dashboard_loaded │
│ 2 Применить фильтры browser filter_state.normalized │
│ 3 Выполнить chart query Superset API metric returned │
│ 4 Скачать XLSX browser xlsx.file │
│ 5 Сравнить с baseline assertion pass/fail/inconclusive │
│ │
│ [Открыть граф] [Заполнить параметры] [Сгенерировать draft] │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Parameter Panel
```text
┌──────────────────────── Параметры сценария ─────────────────────────────────┐
│ Дата тестирования [2026-05-29____________________] │
│ Контрагент [АСК___________________________] │
│ Baseline mode [использовать approved или создать candidate ▼] │
│ Проверять XLSX [✓] │
│ Делать screenshots [✓] │
│ │
│ [Применить] [Сбросить] │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Artifact Preview
```text
┌──────────────────────── Generated draft ────────────────────────────────────┐
│ dashboard_42_test_scenario/ │
│ ├── scenario.yaml ✓ valid │
│ ├── runner.plan.json ✓ valid │
│ ├── xlsx_assertions.py ⚠ needs review │
│ ├── report_template.md ✓ valid │
│ └── baseline_candidates.yaml ⚠ approval required │
│ │
│ [Preview file] [Download draft] [Save to repository] │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Baseline Approval
```text
┌──────────────────────── Baseline approval ──────────────────────────────────┐
│ Metric: Просроченная ДЗ │
│ Filters: Дата=2026-05-29, Контрагент=АСК
│ Candidate: 1 234 567.89 from Superset API │
│ Existing baseline: none │
│ Reason required: [Initial approved QA baseline________________________] │
│ │
│ [Confirm approval] [Keep draft] [Deny] │
└─────────────────────────────────────────────────────────────────────────────┘
```
## 4. Error Experience
### Scenario A: Superset Analysis Fails
* **System Response**: Error card identifies Superset API status and dashboard context.
* **Recovery**: Retry, switch environment, or continue with manual-only scenario shell.
### Scenario B: Unresolved Blockers
* **System Response**: Save action is disabled for executable artifacts; blockers are grouped by step.
* **Recovery**: Fill parameter, provide selector hint, convert to human checkpoint, or remove step.
### Scenario C: User Leaves Mid-Generation
* **System Response**: On return, `/agent` displays recoverable run id and latest draft state.
* **Recovery**: Resume preview, discard draft, or restart analysis.
## 5. Tone & Voice
* **Style**: Clear, operational, confidence-aware.
* **Terminology**: Use "scenario", "draft", "artifact", "baseline", "Superset API", "human checkpoint". Avoid "SQL check" for this feature.
#endregion DashboardScenarioUi.UxReference