Files
ss-tools/specs/035-agent-chat-context/contracts/ux/model-changes.md
busya ff60865183 feat(agent-chat): 035-agent-chat-context — контекст, guardrails, tools, database discovery
== User stories ==
US1: Контекст с дашборда/датасета → /agent с URL params
US2: Guardrails card — env badge, 7 risk tones, countdown, permission_denied
US3: Tools optimization — retry, timeout, summarise, RBAC + context affinity

== Backend ==
- _context.py (NEW): UIContext validation (7 checks)
- _tool_filter.py (NEW): RBAC + context affinity pipeline
- _confirmation.py: build_confirmation_contract_v2, permission_denied_payload
- tools.py: superset_list_databases, retry/summarise/timeout wrappers
- app.py: _inject_uicontext, _inject_env_id_into_tools, database
  prefetch в runtime context
- _persistence.py: prefetch_databases()
- agent_superset_explore.py: GET /databases endpoint
- _llm_async_http.py, _persistence.py: fix double /v1 в LLM URL
  (LM Studio Unexpected endpoint)

== Frontend ==
- AgentChatModel.svelte.ts: 5 atoms, 3 actions, countdown, context
- AgentChat.svelte: production banner, process steps, debug panel
- ConfirmationCard.svelte: 7 risk tones, permission_denied, countdown
- ToolCallCard.svelte: retrying/timeout/cancelled states
- StreamProcessor.svelte.ts: tool_retry, timeout, permission_denied
- TopNavbar: sparkles icon + Ассистент
- sidebarNavigation: AI section
- DashboardHeader, datasets/+page: contextual AI buttons
- Icon: sparkles, brain, cpu icons
- tailwind: assistant category colors
- i18n: en/ru nav keys

== Tests ==
- 159 backend agent tests (+16 US3: retry, timeout, summarise, contracts)
- 2544 frontend tests (+11 model + component tests)
- 15 JSON fixtures (10 API + 5 model)

== Specs ==
- specs/035-agent-chat-context/: spec, UX, plan, tasks, research,
  data-model, contracts, quickstart, traceability, fixtures, checklists

Closes #035
2026-07-04 22:47:17 +03:00

19 KiB
Raw Blame History

#region ModelChanges [C:3] [TYPE ADR] [SEMANTICS ux,model-changes,agent-chat] @defgroup Ux Precise edit instructions for existing model/component files. Implementation target for /speckit.implement.

File 1: frontend/src/lib/models/AgentChatTypes.ts

ADD after existing types:

// ── UI Context (035-agent-chat-context) ─────────────────────────

/** Structured context about the user's current page — passed from frontend to agent on each message. */
export interface UIContext {
  objectType: "dashboard" | "dataset" | "migration" | null;
  objectId: string | null;
  objectName: string | null;
  envId: string | null;
  route: string;
  contextVersion: number;
}

/** Maps objectType to tool operation names — server-side, shown in debug panel. */
export interface ToolAffinityEntry {
  operation: string;
  active: boolean;       // true = tool available for this context
  reason?: string;       // why excluded (e.g. "dataset tool")
}

/** Extended confirmation metadata from backend SSE. */
export interface ConfirmMetadataV2 {
  type: "confirm_required";
  thread_id: string;
  tool_name: string;
  tool_args: Record<string, unknown>;
  risk: "read" | "write";
  risk_level: "safe" | "guarded" | "dangerous";
  dangerous: boolean;
  env_context: "prod" | "staging" | "dev" | null;
  permission_granted: boolean;
  required_role: string | null;
  alternatives: Array<{ action: string; prompt: string }> | null;
  prompt?: string;
}

EXTEND ToolCall interface — add "retrying" | "timeout" | "cancelled" to status union:

export interface ToolCall {
  id: string;
  name: string;
  status: "executing" | "retrying" | "completed" | "failed" | "timeout" | "cancelled";
  input?: Record<string, unknown>;
  output?: { result: string };
  error?: string;
  retryCount?: number;
  maxRetries?: number;
  timeoutSeconds?: number;
}

EXTEND StreamingState union — add "disconnected_permanent" (already exists in model, ensure type includes it):

export type StreamingState = "idle" | "streaming" | "awaiting_confirmation" | "error" | "disconnected" | "disconnected_permanent";

File 2: frontend/src/lib/models/AgentChatModel.svelte.ts

2a. ADD import (top of file):

import type { UIContext, ToolAffinityEntry, ConfirmMetadataV2 } from "./AgentChatTypes.js";

2b. ADD atoms (after existing envId atom, ~line 113):

// ── UI Context (035-agent-chat-context) ────────────────────────
uiContext: UIContext | null = $state(null);
toolAffinity: ToolAffinityEntry[] = $state([]);
dangerousCountdown: number = $state(0);
dangerousCountdownActive: boolean = $state(false);
/** Tracks env from EnvSelector (may differ from uiContext.envId during dialog). */
_activeEnvId: string | null = $state(null);
/** Alternatives suggested by backend when permission denied. */
permissionAlternatives: Array<{ action: string; prompt: string }> | null = $state(null);

2c. ADD derived (after existing derived block, ~line 317):

// ── Prod context detection ─────────────────────────────────────
/** True when either uiContext or active env selector is prod. */
isProdContext = $derived(
  (this.uiContext?.envId === "prod") || this._activeEnvId === "prod"
);
showProdWarning = $derived(this.isProdContext);

// ── Context pill label (extended, replaces simple envId label) ─
contextPillLabel = $derived.by((): string => {
  const env = this._activeEnvId || this.uiContext?.envId || "";
  if (!this.uiContext || !this.uiContext.objectType) {
    return env ? `🌐 ${env}` : "⚪ Контекст не выбран";
  }
  const typeIcons: Record<string, string> = {
    dashboard: "📋", dataset: "📊", migration: "🔄",
  };
  const icon = typeIcons[this.uiContext.objectType] || "📄";
  const idLabel = this.uiContext.objectId ? ` #${this.uiContext.objectId}` : "";
  return `${icon} ${this.uiContext.objectType}${idLabel} · ${env}`;
});

// ── Process steps (extended — first step uses contextPillLabel) ──
processSteps = $derived.by((): AgentProcessStep[] => {
  const hasTools = this.activeToolCalls.length > 0 || this.messages.some((msg) => (msg.toolCalls || []).length > 0);
  const hasAssistantResult = this.messages.some((msg) => msg.role === "assistant" && msg.text.trim().length > 0);
  const failed = this.streamingState === "error";
  return [
    {
      id: "context",
      label: this.contextPillLabel,
      state: (this.uiContext || this._activeEnvId) ? (failed ? "blocked" : "done") : (failed ? "blocked" : "idle"),
    },
    // ... rest of existing steps unchanged ...
  ];
});

// ── Confirmation risk (extended — use backend metadata) ────────
// НЕ менять существующий $derived — только убедиться что он читает
// pendingConfirmationRisk, pendingRiskLevel из StreamProcessor

2d. ADD actions (before sendMessage, ~line 410):

// ── Context actions (035-agent-chat-context) ────────────────────

/** Set UI context from URL params on page mount. */
setUIContextFromParams(params: URLSearchParams): void {
  const objectType = params.get("objectType") as UIContext["objectType"] || null;
  const objectId = params.get("objectId") || null;
  const objectName = params.get("objectName") || null;
  const envId = params.get("envId") || null;
  const route = params.get("route") || "/agent";
  this.uiContext = {
    objectType, objectId, objectName, envId, route, contextVersion: 1,
  };
  this._activeEnvId = envId;
  log("AgentChat.Model", "REASON", "UI context set from params", {
    objectType, objectId, envId,
  });
}

/** Update environment from EnvSelector during dialog. */
updateActiveEnv(envId: string | null): void {
  this._activeEnvId = envId;
  // Note: envId for tool calls is still passed separately — this tracks for UI only
}

/** Start dangerous operation countdown. Called by StreamProcessor. */
startDangerousCountdown(): void {
  this.dangerousCountdown = 10;
  this.dangerousCountdownActive = true;
  const interval = setInterval(() => {
    this.dangerousCountdown--;
    if (this.dangerousCountdown <= 0) {
      this.dangerousCountdownActive = false;
      clearInterval(interval);
    }
  }, 1000);
}

2e. UPDATE resetStreamingState (internal helper) — add context reset:

// В методе, который сбрасывает состояние перед новым стримом:
private _resetForStream(): void {
  // ... existing resets ...
  this.dangerousCountdown = 0;
  this.dangerousCountdownActive = false;
  this.permissionAlternatives = null;
}

File 3: frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts

3a. ADD new metadata handlers (in _handleMetadata switch):

// После существующих case "tool_error":
case "tool_retry": {
  const toolCall = host.activeToolCalls.find(t => t.name === meta.tool);
  if (toolCall) {
    toolCall.status = "retrying";
    toolCall.retryCount = meta.attempt;
    toolCall.maxRetries = meta.max_attempts;
  }
  break;
}

case "tool_timeout": {
  const toolCall = host.activeToolCalls.find(t => t.name === meta.tool);
  if (toolCall) {
    toolCall.status = "timeout";
    toolCall.timeoutSeconds = meta.timeout_seconds;
  }
  break;
}

// Расширить существующий case "confirm_required":
case "confirm_required": {
  host.pendingToolName = meta.tool_name;
  host.pendingToolArgs = meta.tool_args || {};
  host.pendingConfirmationRisk = meta.risk === "read" ? "read" : "write";
  host.pendingRiskLevel = meta.risk_level;
  host.confirmationMessage = meta.prompt || null;
  host.streamingState = "awaiting_confirmation";
  // NEW fields:
  if (meta.dangerous) {
    host.startDangerousCountdown?.();
  }
  if (!meta.permission_granted) {
    host.permissionAlternatives = meta.alternatives;
    host.pendingRiskLevel = "permission_denied";
  }
  break;
}

3b. EXTEND handleResumeResponse — cancelled tool card:

// В обработчике deny/confirm_resolved с result "denied":
// Добавить ToolCall в activeToolCalls со статусом "cancelled"
// чтобы карточка «Отменено» появилась в чате
if (meta.type === "confirm_resolved" && meta.result === "denied") {
  const toolName = host.pendingToolName || "unknown_action";
  host.activeToolCalls = [...host.activeToolCalls, {
    id: `cancelled-${Date.now()}`,
    name: toolName,
    status: "cancelled",
    input: host.pendingToolArgs as Record<string, unknown> | undefined,
  }];
  host.pendingToolName = null;
  host.pendingToolArgs = {};
}

File 4: frontend/src/routes/agent/+page.svelte

4a. ADD onMount context init:

<script lang="ts">
  import { onMount } from "svelte";
  import { page } from "$app/stores";

  // ... existing imports ...

  onMount(() => {
    // Read context from URL params
    const params = new URLSearchParams($page.url.search);
    model.setUIContextFromParams(params);
    model.checkLlmStatus();
  });
</script>

4b. ADD EnvSelector integration:

<!-- Если EnvSelector присутствует на странице агента: -->
<EnvSelector
  selectedEnvId={model._activeEnvId}
  onEnvChange={(envId) => model.updateActiveEnv(envId)}
/>

File 5: frontend/src/lib/components/agent/AgentChat.svelte

5a. ADD prod background binding (корневой div):

<div
  class="h-full flex flex-col min-h-0 {model.showProdWarning ? 'bg-warning-light' : 'bg-surface-page'}"
>

5b. ADD prod banner (перед header):

{#if model.showProdWarning}
  <div class="bg-destructive text-white text-xs font-bold text-center py-0.5 shrink-0" role="status" aria-live="polite">
    <Icon name="warning" size={12} class="inline mr-1" />
    PRODUCTION
  </div>
{/if}

5c. UPDATE process steps rendering — первый шаг использует contextPillLabel:

<!-- Вместо хардкода "Контекст: ss-dev" использовать model.contextPillLabel -->
{#each model.processSteps as step}
  <span class="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium {processStepClasses(step.state)}">
    {step.label}
  </span>
{/each}

File 6: frontend/src/lib/components/assistant/ConfirmationCard.svelte

6a. ADD env badge to header:

{#if meta.env_context}
  <span class="rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold
    {meta.env_context === 'prod' ? 'bg-destructive-light text-destructive' : ''}
    {meta.env_context === 'staging' ? 'bg-warning-light text-warning' : ''}
    {meta.env_context === 'dev' ? 'bg-info-light text-info' : ''}
  ">
    {meta.env_context === 'prod' ? '👑 PRODUCTION' : meta.env_context}
  </span>
{/if}

6b. ADD dangerous countdown logic:

<script lang="ts">
  // ... existing ...

  let dangerousTimer = $state(10);
  let timerInterval: ReturnType<typeof setInterval> | null = null;

  $effect(() => {
    if (model.streamingState === "awaiting_confirmation" && model.pendingRiskLevel === "dangerous") {
      dangerousTimer = 10;
      timerInterval = setInterval(() => {
        dangerousTimer--;
        if (dangerousTimer <= 0) {
          if (timerInterval) clearInterval(timerInterval);
          timerInterval = null;
        }
      }, 1000);
    }
    return () => {
      if (timerInterval) clearInterval(timerInterval);
    };
  });
</script>

<!-- Кнопка подтверждения для dangerous: -->
<Button
  variant="destructive"
  size="sm"
  isLoading={loading === "confirming"}
  disabled={loading !== "idle" || dangerousTimer > 0}
  onclick={handleConfirm}
>
  {dangerousTimer > 0
    ? `💀 Удалить (${dangerousTimer})`
    : loading === "confirming"
      ? "Подтверждение…"
      : "💀 Удалить"}
</Button>

6c. ADD permission_denied state (alternatives list):

{#if !meta.permission_granted}
  <div class="mb-3 rounded-lg border border-border bg-surface-page px-3.5 py-2.5">
    <p class="text-sm font-medium text-text mb-1">
      🔒 Недостаточно прав
    </p>
    <p class="text-xs text-text-muted mb-2">
      Требуется роль: <code class="rounded bg-surface-muted px-1 py-0.5 font-mono text-destructive">{meta.required_role}</code>
    </p>
    {#if meta.alternatives?.length}
      <p class="text-xs font-medium text-text mb-1">💡 Альтернативы:</p>
      <ul class="space-y-1">
        {#each meta.alternatives as alt}
          <li class="text-xs text-text-muted">{alt.prompt}</li>
        {/each}
      </ul>
    {/if}
  </div>
  <Button variant="ghost" size="sm" onclick={handleClosePermissionDenied}>Закрыть</Button>
{/if}

File 7: frontend/src/lib/components/assistant/ToolCallCard.svelte

7a. ADD new states:

<script lang="ts">
  export let onRetry: (() => void) | undefined = undefined;
  export let onCancelTimeout: (() => void) | undefined = undefined;
</script>

{#if tool.status === "retrying"}
  <div class="flex items-center gap-2">
    <span class="animate-spin"></span>
    <span class="text-xs text-text-muted">
      Повторная попытка... ({tool.retryCount}/{tool.maxRetries})
    </span>
  </div>
{:else if tool.status === "timeout"}
  <div class="flex items-center gap-2">
    <span>⏱️</span>
    <span class="text-xs text-warning">Превышено время ожидания ({tool.timeoutSeconds}с)</span>
  </div>
  {#if onRetry}
    <Button variant="secondary" size="xs" onclick={onRetry} class="mt-1">
      🔄 Повторить
    </Button>
  {/if}
{:else if tool.status === "cancelled"}
  <div class="flex items-center gap-2">
    <span>🚫</span>
    <span class="text-xs text-text-muted">Отменено пользователем</span>
  </div>
{/if}

File 8: frontend/src/lib/stores/assistantChat.svelte.ts

8a. ADD initialContext field:

// Добавить в существующий объект assistantChatStore:
initialContext: UIContext | null = $state(null);

// Добавить action:
setInitialContext(ctx: UIContext | null) {
  this.initialContext = ctx;
}

File 9: backend/src/agent/app.py

9a. ADD uicontext parameter to agent_handler:

# В сигнатуре agent_handler:
async def agent_handler(
    message: dict,
    history: list,
    conversation_id: str | None = None,
    uicontext_str: str | None = None,  # NEW — JSON string from frontend
    user_id: str | None = None,
    user_jwt: str | None = None,
    env_id: str | None = None,
):
    # Parse uicontext:
    uicontext = None
    if uicontext_str:
        try:
            uicontext = json.loads(uicontext_str)
        except json.JSONDecodeError:
            pass
    
    # Inject into runtime context:
    if uicontext:
        runtime_parts = _build_agent_context(env_id)  # existing
        runtime_parts = _inject_uicontext(runtime_parts, uicontext)  # NEW
    
    # Filter tools by objectType:
    tools = get_all_tools()
    if uicontext and uicontext.get("objectType"):
        tools = _filter_tools_by_context(tools, uicontext["objectType"])

9b. NEW helper function:

def _inject_uicontext(runtime_context: str, uicontext: dict) -> str:
    """Add user-facing context to the runtime context block."""
    lines = [runtime_context]
    lines.append("\n[USER CONTEXT]")
    lines.append(f"User is currently on page: {uicontext.get('route', 'unknown')}")
    if uicontext.get("objectType") and uicontext.get("objectId"):
        lines.append(f"Active object: {uicontext['objectType']} id={uicontext['objectId']}")
        if uicontext.get("objectName"):
            lines.append(f"Object name: {uicontext['objectName']}")
    if uicontext.get("envId"):
        lines.append(f"Current environment: {uicontext['envId']}")
    lines.append("[/USER CONTEXT]")
    return "\n".join(lines)

File 10: backend/src/agent/_tool_filter.py (NEW)

# 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.

from typing import Any

# Mapping: objectType → set of relevant tool operations
_CONTEXT_TOOL_AFFINITY: dict[str, set[str]] = {
    "dashboard": {
        "search_dashboards", "get_health_summary", "deploy_dashboard",
        "run_llm_validation", "run_llm_documentation", "execute_migration",
        "create_branch", "commit_changes", "show_capabilities",
        "get_task_status", "list_environments",
    },
    "dataset": {
        "superset_explore_database", "superset_format_sql",
        "superset_audit_permissions", "superset_execute_sql",
        "superset_create_dataset", "search_dashboards",
        "show_capabilities", "get_task_status", "list_environments",
    },
    "migration": {
        "execute_migration", "search_dashboards", "get_health_summary",
        "deploy_dashboard", "show_capabilities", "list_environments",
    },
}

def filter_tools_by_context(tools: list[Any], object_type: str | None) -> list[Any]:
    """Filter tool list to context-relevant tools only."""
    if not object_type or object_type not in _CONTEXT_TOOL_AFFINITY:
        return tools  # no filter — return all tools

    allowed = _CONTEXT_TOOL_AFFINITY[object_type]
    # Always include show_capabilities
    allowed.add("show_capabilities")
    return [t for t in tools if t.name in allowed]

def get_tool_affinity(object_type: str | None) -> list[dict[str, Any]]:
    """Return affinity list for debug panel (all tools, marked active/inactive)."""
    # Used by debug endpoint
    ...
# #endregion AgentChat.ToolFilter

Итого по изменениям

Файл Тип изменения Строк (±)
AgentChatTypes.ts Расширение типов +35
AgentChatModel.svelte.ts Добавление атомов/derived/actions +60
AgentChat.StreamProcessor.svelte.ts Новые handler'ы +35
AgentChat.svelte Prod bg + banner + pill +15
ConfirmationCard.svelte Новые состояния + countdown +50
ToolCallCard.svelte Новые состояния + retry button +25
assistantChat.svelte.ts Поле initialContext +5
agent/+page.svelte onMount context init +10
backend: app.py uicontext parsing +25
backend: _tool_filter.py Новый файл +40
Всего ~300 строк

#endregion ModelChanges