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:
19
agent/pyproject.toml
Normal file
19
agent/pyproject.toml
Normal 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*"]
|
||||
3
agent/requirements-embeddings.txt
Normal file
3
agent/requirements-embeddings.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# Optional: semantic embedding routing for agent tool selection
|
||||
sentence-transformers>=2.2.0
|
||||
torch>=2.0.0
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
132
agent/src/ss_tools/agent/_context.py
Normal file
132
agent/src/ss_tools/agent/_context.py
Normal 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
|
||||
95
agent/src/ss_tools/agent/_embedding_router.py
Normal file
95
agent/src/ss_tools/agent/_embedding_router.py
Normal 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
|
||||
30
agent/src/ss_tools/agent/_jwt_decoder.py
Normal file
30
agent/src/ss_tools/agent/_jwt_decoder.py
Normal 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
|
||||
@@ -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
|
||||
292
agent/src/ss_tools/agent/_persistence.py
Normal file
292
agent/src/ss_tools/agent/_persistence.py
Normal 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
|
||||
100
agent/src/ss_tools/agent/_tool_filter.py
Normal file
100
agent/src/ss_tools/agent/_tool_filter.py
Normal 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
|
||||
86
agent/src/ss_tools/agent/_tool_resolver.py
Normal file
86
agent/src/ss_tools/agent/_tool_resolver.py
Normal 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
|
||||
@@ -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,
|
||||
56
agent/src/ss_tools/agent/context.py
Normal file
56
agent/src/ss_tools/agent/context.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
36
agent/src/ss_tools/agent/middleware.py
Normal file
36
agent/src/ss_tools/agent/middleware.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
@@ -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] = {
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
@@ -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,
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
@@ -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]:
|
||||
@@ -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
|
||||
@@ -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("")
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
@@ -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={
|
||||
@@ -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,
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -22,6 +22,7 @@ __all__ = [
|
||||
"admin",
|
||||
"admin_api_keys",
|
||||
"agent_conversations",
|
||||
"agent_status",
|
||||
"agent_superset",
|
||||
"agent_superset_explore",
|
||||
"assistant",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
17
run.sh
@@ -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
17
shared/pyproject.toml
Normal 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*"]
|
||||
8
shared/src/ss_tools/shared/__init__.py
Normal file
8
shared/src/ss_tools/shared/__init__.py
Normal 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
|
||||
@@ -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
|
||||
127
shared/src/ss_tools/shared/cot_logger.py
Normal file
127
shared/src/ss_tools/shared/cot_logger.py
Normal 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
|
||||
147
shared/src/ss_tools/shared/logger.py
Normal file
147
shared/src/ss_tools/shared/logger.py
Normal 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
|
||||
87
shared/src/ss_tools/shared/ssl.py
Normal file
87
shared/src/ss_tools/shared/ssl.py
Normal 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
|
||||
@@ -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.
|
||||
107
specs/036-agent-test-stabilization/spec.md
Normal file
107
specs/036-agent-test-stabilization/spec.md
Normal 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
|
||||
85
specs/036-agent-test-stabilization/ux_reference.md
Normal file
85
specs/036-agent-test-stabilization/ux_reference.md
Normal 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
|
||||
@@ -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.
|
||||
110
specs/037-superset-baseline-engine/spec.md
Normal file
110
specs/037-superset-baseline-engine/spec.md
Normal 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
|
||||
79
specs/037-superset-baseline-engine/ux_reference.md
Normal file
79
specs/037-superset-baseline-engine/ux_reference.md
Normal 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
|
||||
@@ -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.
|
||||
111
specs/038-dashboard-scenario-model/spec.md
Normal file
111
specs/038-dashboard-scenario-model/spec.md
Normal 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
|
||||
80
specs/038-dashboard-scenario-model/ux_reference.md
Normal file
80
specs/038-dashboard-scenario-model/ux_reference.md
Normal 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
|
||||
25
specs/039-dashboard-scenario-ui/checklists/requirements.md
Normal file
25
specs/039-dashboard-scenario-ui/checklists/requirements.md
Normal 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.
|
||||
127
specs/039-dashboard-scenario-ui/spec.md
Normal file
127
specs/039-dashboard-scenario-ui/spec.md
Normal 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
|
||||
128
specs/039-dashboard-scenario-ui/ux_reference.md
Normal file
128
specs/039-dashboard-scenario-ui/ux_reference.md
Normal 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
|
||||
Reference in New Issue
Block a user