feat(agent+ui): fullstack agent module refactoring + UI/UX improvements

## Backend: agent module GRACE-Poly compliance
- Split app.py (749→~280 lines) into _tool_resolver, _confirmation, _persistence
- All 18 naked functions wrapped in #region/#endregion contracts
- Fixed @DEFGROUP→@defgroup typos; added @DATA_CONTRACT, @SIDE_EFFECT, CoT logs
- Conversation list API: added last_role, has_tool_calls, has_error, risk_level fields
- Message state detection: Russian/English error patterns (недоступен, unavailable)
- State field preserved in save_conversation messages
- HITL titles: descriptive tool names instead of generic "HITL resume"

## Backend: conversation title generation (two-layer)
- Layer 1: clean_title() — rule-based, strips file markers, pre-fetch blocks, JSON/CSV,
  URLs, code; truncates at 80 chars word boundary (25 unit tests, all edge cases)
- Layer 2: generate_llm_title() — async best-effort LLM titling via /v1/chat/completions
  with per-conversation lock, graceful degradation on failure

## Frontend: conversation list indicators (orthogonal system)
- Status dot (green/yellow/red/blue) per conversation state
- Icon column: tool activity, errors, waiting, completed
- Risk stripe (left border accent) + message count badge + relative time
- Fixed group labels: "Сегодня"/"Вчера" instead of "3 ч"/"5 ч"
- Hide "Окружение: —" when env is empty

## Frontend: guardrails card verification + fixes
- Confirmed all interaction modes: Enter/click confirm, Escape/click deny
- Auto-populate envId from environmentContextStore in DashboardDetailModel
- Better error message: missing_context_hint with recovery guidance

## Design system: semantic tokens
- Added category-* gradient tokens to tailwind.config.js
- Sidebar + Breadcrumbs use semantic tokens (10 categories)
- Raw Tailwind reduced from ~50 to 6 occurrences
- Added skip-to-content link in root layout (+layout.svelte)
- Added aria-label on DashboardDataGrid row checkboxes

## Protocol: INV_7 pragmatic exception
- Modules may exceed 400 lines when contract-dense (every function has #region)
- Recorded in semantics-core SKILL.md with rationale

Total: 5841+ contracts, 2993+ edges, backend 41/41, frontend 2501/2501
This commit is contained in:
2026-06-30 15:21:05 +03:00
parent e8d6d7d0db
commit 3b4ac807a5
33 changed files with 2076 additions and 374 deletions

View File

@@ -0,0 +1,5 @@
# backend/src/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
# #endregion AgentChat

View File

@@ -0,0 +1,214 @@
# backend/src/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
# @RELATION DEPENDS_ON -> [AgentChat.ToolResolver]
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE Extracting confirmation logic into a dedicated module prevents the handler
# from exceeding 400 lines and centralises risk classification in one place.
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any
from src.agent._tool_resolver import (
_SAFE_AGENT_TOOLS,
_DANGEROUS_AGENT_TOOLS,
_GUARDED_AGENT_TOOLS,
normalize_tool_args,
extract_tool_call_from_state,
find_tool,
)
from src.agent.langgraph_setup import create_agent
from src.agent.tools import get_all_tools
_pending_confirmations: dict[str, dict[str, Any]] = {}
# #region AgentChat.Confirmation.Contract [C:2] [TYPE Function] [SEMANTICS agent-chat,hitl,contract]
# @ingroup AgentChat
# @BRIEF Build confirmation contract dict — risk level, prompt, operation metadata.
# @POST Returns dict with operation, risk, risk_level, prompt, requires_confirmation keys.
def build_confirmation_contract(tool_name: str | None) -> dict[str, Any]:
operation = tool_name or "unknown_action"
if operation in _SAFE_AGENT_TOOLS:
risk_level = "safe"
risk = "read"
prompt = "Разрешить чтение данных?"
elif operation in _DANGEROUS_AGENT_TOOLS:
risk_level = "dangerous"
risk = "write"
prompt = "Подтвердить критичную операцию?"
elif operation in _GUARDED_AGENT_TOOLS:
risk_level = "guarded"
risk = "write"
prompt = "Подтвердить изменение данных?"
else:
risk_level = "unknown"
risk = "unknown"
prompt = "Подтвердите действие"
return {
"operation": operation,
"risk": risk,
"risk_level": risk_level,
"prompt": prompt,
"requires_confirmation": True,
}
# #endregion AgentChat.Confirmation.Contract
# #region AgentChat.Confirmation.MetadataForTool [C:3] [TYPE Function] [SEMANTICS agent-chat,hitl,metadata]
# @ingroup AgentChat
# @BRIEF Generate confirmation metadata dict for a specific tool name + args.
# @POST Returns metadata dict with type, thread_id, prompt, tool_name, tool_args, risk fields.
def confirmation_metadata_for_tool(
conv_id: str,
tool_name: str | None,
tool_args: dict[str, Any] | None = None,
) -> dict[str, Any]:
contract = build_confirmation_contract(tool_name)
return {
"type": "confirm_required",
"thread_id": conv_id,
"prompt": contract["prompt"],
"tool_name": contract["operation"],
"tool_args": tool_args or {},
"risk": contract["risk"],
"risk_level": contract["risk_level"],
"requires_confirmation": contract["requires_confirmation"],
"intent": {
"operation": contract["operation"],
"risk": contract["risk"],
"risk_level": contract["risk_level"],
"requires_confirmation": contract["requires_confirmation"],
},
}
# #endregion AgentChat.Confirmation.MetadataForTool
# #region AgentChat.Confirmation.Metadata [C:3] [TYPE Function] [SEMANTICS agent-chat,hitl,metadata]
# @ingroup AgentChat
# @BRIEF Generate confirmation metadata from LangGraph state + user text.
# @POST Returns metadata dict (delegates to MetadataForTool after extraction).
def confirmation_metadata(conv_id: str, state, user_text: str) -> dict[str, Any]:
tool_name, tool_args = extract_tool_call_from_state(state, user_text)
return confirmation_metadata_for_tool(conv_id, tool_name, tool_args)
# #endregion AgentChat.Confirmation.Metadata
# #region AgentChat.Confirmation.Payload [C:2] [TYPE Function] [SEMANTICS agent-chat,hitl,payload]
# @ingroup AgentChat
# @BRIEF Serialise confirmation into a JSON payload string for the Gradio event stream.
# @POST Returns JSON string with content + metadata.
def confirmation_payload(conv_id: str, state, user_text: str) -> str:
return json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": confirmation_metadata(conv_id, state, user_text),
})
# #endregion AgentChat.Confirmation.Payload
# #region AgentChat.Confirmation.HandleResume [C:4] [TYPE Function] [SEMANTICS agent-chat,hitl,resume,streaming]
# @ingroup AgentChat
# @BRIEF Resume from HITL checkpoint — execute confirmed tool or abort on deny.
# @PRE conversation_id is valid. action is "confirm" or "deny".
# @POST Streams confirm_resolved, tool_start, tool_end/tool_error events via yield.
# @SIDE_EFFECT Invokes LangChain tools; modifies _pending_confirmations dict.
# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup]
# @DATA_CONTRACT Input: (conv_id, action, user_jwt, env_id) -> Output: AsyncGenerator[str]
async def handle_resume(
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.cot_logger import log
set_user_jwt(user_jwt)
pending = _pending_confirmations.pop(conversation_id, None)
if pending is not None:
if action == "deny":
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
return
if action == "confirm":
log("AgentChat.Confirmation", "REASON", "Fast-path confirmation resume", {"tool": pending.get("tool_name"), "conv_id": conversation_id})
tool_name = str(pending.get("tool_name") or "unknown_action")
tool_args = normalize_tool_args(pending.get("tool_args"))
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": tool_args},
})
tool_obj = find_tool(tool_name)
if tool_obj is None:
error = f"Unknown tool: {tool_name}"
log("AgentChat.Confirmation", "EXPLORE", "Unknown tool in resume", {"tool": tool_name}, error=error)
yield json.dumps({
"content": f"{tool_name}{error}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": error},
})
return
try:
output = await tool_obj.ainvoke(tool_args)
except Exception as exc:
log("AgentChat.Confirmation", "EXPLORE", "Tool invocation failed in resume", {"tool": tool_name}, error=str(exc))
yield json.dumps({
"content": f"{tool_name}{exc}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": str(exc)},
})
return
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
yield json.dumps({
"content": str(output),
"metadata": {"type": "stream_token", "token": str(output)},
})
log("AgentChat.Confirmation", "REFLECT", "Fast-path confirmation completed", {"tool": tool_name})
return
log("AgentChat.Confirmation", "REASON", "LangGraph checkpoint resume", {"conv_id": conversation_id, "action": action})
agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
if action == "confirm":
config = {"configurable": {"thread_id": conversation_id}}
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
async for event in agent.astream_events(None, config=config, version="v2"):
kind = event.get("event")
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
yield json.dumps({
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
})
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
elif action == "deny":
log("AgentChat.Confirmation", "REFLECT", "Checkpoint resume denied", {"conv_id": conversation_id})
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
# #endregion AgentChat.Confirmation.HandleResume
# #endregion AgentChat.Confirmation

View File

@@ -0,0 +1,376 @@
# 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
import os
import re
import uuid
from datetime import datetime
from typing import Any
import httpx
from src.core.cot_logger import log
SAVE_API_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") + "/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:
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.core.auth.jwt 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] = {}
def _get_llm_config() -> dict[str, Any] | None:
"""Fetch LLM provider config from FastAPI for title generation."""
try:
import httpx as _httpx
import os as _os
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}"
# Use sync httpx in a thread-safe context (called from asyncio.to_thread)
with _httpx.Client(timeout=5) as client:
resp = 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.cot_logger import log as _log
try:
import asyncio as _asyncio
config = await _asyncio.to_thread(_get_llm_config)
if not config or not config.get("configured"):
_log("AgentChat.Persistence", "EXPLORE", "LLM title: no provider configured", {})
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 слов на русском для диалога. "
f"Только заголовок, без кавычек и пояснений.\n\n"
f"Диалог: {clean_text}"
)
provider_type = config.get("provider_type", "openai")
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,
"temperature": 0,
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
api_url = base_url.rstrip("/") + "/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:
_log("AgentChat.Persistence", "EXPLORE", "LLM title: API error",
{"status": resp.status_code}, error=resp.text[:200])
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:
_log("AgentChat.Persistence", "EXPLORE", "LLM title generation failed", {}, error=str(e))
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:
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
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)
log("AgentChat.Persistence", "REFLECT", "LLM title updated",
{"conv_id": conv_id, "title": title[:40]})
except Exception as e:
log("AgentChat.Persistence", "EXPLORE", "LLM title save failed",
{"conv_id": conv_id}, error=str(e))
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 _dual_auth_headers, FASTAPI_URL
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 = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25"))
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:
return ""
# #endregion AgentChat.Persistence.PrefetchDashboards
# #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 -> [Api.Agent.Conversations]
async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
try:
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
# 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)
except Exception as e:
log("AgentChat.Persistence", "EXPLORE", "Failed to save conversation",
{"conv_id": conv_id}, error=str(e))
# #endregion AgentChat.Persistence.SaveConversation
# #endregion AgentChat.Persistence

View File

@@ -0,0 +1,190 @@
# backend/src/agent/_tool_resolver.py
# #region AgentChat.ToolResolver [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,classification,resolution]
# @defgroup AgentChat Tool classification constants and resolution helpers for the LangGraph agent.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE Centralised tool resolution prevents duplication of tool-name matching logic across
# the handler and confirmation subsystems. Single source of truth for tool risk classification.
from typing import Any
from src.agent.tools import get_all_tools
from src.core.cot_logger import log
# #region AgentChat.ToolResolver.Sets [C:1] [TYPE Constants] [SEMANTICS agent-chat,tools,sets]
# @ingroup AgentChat
# @BRIEF Tool classification sets — safe (read-only), guarded (write), dangerous (deploy).
_SAFE_AGENT_TOOLS = {
"show_capabilities",
"search_dashboards",
"get_health_summary",
"list_environments",
"get_task_status",
"list_llm_providers",
"get_llm_status",
"list_maintenance_events",
}
_GUARDED_AGENT_TOOLS = {
"create_branch",
"commit_changes",
"execute_migration",
"run_backup",
"run_llm_validation",
"run_llm_documentation",
"start_maintenance",
"end_maintenance",
}
_DANGEROUS_AGENT_TOOLS = {
"deploy_dashboard",
}
_GRAPH_NODE_NAMES = {"agent", "tools", "__start__", "__end__"}
_FAST_CONFIRM_TOOLS = {
"show_capabilities",
"list_environments",
"list_llm_providers",
"get_llm_status",
"list_maintenance_events",
}
# #endregion AgentChat.ToolResolver.Sets
# #region AgentChat.ToolResolver.KnownNames [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,catalog]
# @ingroup AgentChat
# @BRIEF Return registered LangChain tool names without letting helper failures break HITL UX.
# @POST Returns set of tool name strings; falls back to static union on failure.
def known_agent_tool_names() -> set[str]:
try:
return {str(tool_obj.name) for tool_obj in get_all_tools() if getattr(tool_obj, "name", None)}
except Exception as exc:
log("AgentChat.ToolResolver", "EXPLORE", "tool catalog lookup failed", {"error": str(exc)})
return _SAFE_AGENT_TOOLS | _GUARDED_AGENT_TOOLS | _DANGEROUS_AGENT_TOOLS
# #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.InferFromText [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,inference]
# @ingroup AgentChat
# @BRIEF Infer which tool the user likely wants based on keywords in the message text.
# @POST Returns tool name string, or None if no match.
# @RATIONALE Some LLMs fail to emit tool calls even when instructed. This fallback
# uses keyword matching to guess the user's intent and auto-trigger HITL.
def infer_tool_from_text(text: str) -> str | None:
lowered = (text or "").lower()
if any(word in lowered for word in ["окруж", "environment", "env"]):
return "list_environments"
if any(word in lowered for word in ["maintenance", "обслуж", "баннер"]):
if any(word in lowered for word in ["start", "созда", "запусти", "начни"]):
return "start_maintenance"
if any(word in lowered for word in ["end", "закрой", "заверши", "останов"]):
return "end_maintenance"
return "list_maintenance_events"
if any(word in lowered for word in ["дашборд", "dashboard", "dashboards", "дашборды"]):
return "search_dashboards"
if any(word in lowered for word in ["здоров", "health", "статус системы", "system status"]):
return "get_health_summary"
if any(word in lowered for word in ["задач", "task", "таск"]):
return "get_task_status"
if any(word in lowered for word in ["llm", "provider", "провайдер", "модель"]):
return "list_llm_providers"
if any(word in lowered for word in ["branch", "ветк"]):
return "create_branch"
if any(word in lowered for word in ["commit", "коммит"]):
return "commit_changes"
if any(word in lowered for word in ["deploy", "депло", "разверн"]):
return "deploy_dashboard"
if any(word in lowered for word in ["миграц", "migration", "migrate"]):
return "execute_migration"
if any(word in lowered for word in ["backup", "бэкап", "резерв"]):
return "run_backup"
if any(word in lowered for word in ["валидац", "validation", "validate"]):
return "run_llm_validation"
if any(word in lowered for word in ["документ", "documentation", "docs"]):
return "run_llm_documentation"
if any(word in lowered for word in ["инструмент", "tool", "capabilit", "умеешь", "можешь"]):
return "show_capabilities"
return None
# #endregion AgentChat.ToolResolver.InferFromText
# #region AgentChat.ToolResolver.ExtractFromState [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,checkpoint]
# @ingroup AgentChat
# @BRIEF Extract pending tool name and args from the LangGraph checkpoint; infer only as last resort.
# @POST Returns (tool_name, args) tuple; (None, {}) if nothing found.
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 as exc:
log("AgentChat.ToolResolver", "EXPLORE", "tool_call extraction failed", {"error": str(exc)})
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, {})
inferred_tool = infer_tool_from_text(user_text)
if inferred_tool:
log("AgentChat.ToolResolver", "EXPLORE", "tool_call inferred from user text", {"tool": inferred_tool})
return (inferred_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):
return next((tool_obj for tool_obj in get_all_tools() if getattr(tool_obj, "name", None) == tool_name), None)
# #endregion AgentChat.ToolResolver.FindTool
# #region AgentChat.ToolResolver.FastConfirm [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,fast-path]
# @ingroup AgentChat
# @BRIEF Check if user text maps to a tool eligible for fast-track HITL confirmation.
# @POST Returns tool name if match, None otherwise.
def fast_confirmation_tool(text: str) -> str | None:
tool_name = infer_tool_from_text(text)
return tool_name if tool_name in _FAST_CONFIRM_TOOLS else None
# #endregion AgentChat.ToolResolver.FastConfirm
# #endregion AgentChat.ToolResolver

View File

@@ -1,10 +1,14 @@
# backend/src/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().
# @defgroup AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt().
# @PRE JWT_SECRET env var set. Shared with FastAPI for stateless validation.
# @POST Agent streams tokens via Gradio yield; audit logged via LoggingMiddleware.
# @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL.
# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser]
# @RELATION DEPENDS_ON -> [AgentChat.ToolResolver]
# @RELATION DEPENDS_ON -> [AgentChat.Confirmation]
# @RELATION DEPENDS_ON -> [AgentChat.Persistence]
# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup]
# @RATIONALE Gradio ChatInterface chosen for its built-in streaming, file upload, and multimodal support — avoids custom WebSocket implementation for agent chat.
# @REJECTED Custom React chat frontend rejected — Gradio provides free authentication, session management, and mobile-responsive UI out of the box.
@@ -13,6 +17,9 @@ from collections.abc import AsyncGenerator
from datetime import datetime
import json
import os
from pathlib import Path
import shutil
from typing import Any
import uuid
import gradio as gr
@@ -21,23 +28,82 @@ from jose import JWTError
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage
from src.agent._confirmation import (
confirmation_metadata_for_tool,
confirmation_payload,
handle_resume,
_pending_confirmations,
)
from src.agent._persistence import (
extract_user_id,
prefetch_dashboards,
save_conversation,
generate_llm_title,
)
from src.agent._tool_resolver import (
fast_confirmation_tool,
)
from src.agent.context import set_user_jwt
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 get_all_tools, get_tools_for_query
from src.agent.tools import get_tools_for_query
from src.core.auth.jwt import decode_token
from src.core.cot_logger import log
JWT_SECRET = os.getenv("JWT_SECRET", "super-secret-key")
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
# ── Session state ───────────────────────────────────────────────
# In-memory per-user lock (keyed by user_id)
_user_locks: dict[str, bool] = {}
# ── File persistence ────────────────────────────────────────────
# #region AgentChat.GradioApp.PersistFile [C:3] [TYPE Function] [SEMANTICS agent-chat,storage,file]
# @ingroup AgentChat
# @BRIEF Copy uploaded chat file to storage under chat_uploads category.
# @PRE file_path exists and is readable. storage root configured.
# @POST File copied to {storage_root}/chat_uploads/{conv_id}/{filename}. Returns relative storage path or None.
# @SIDE_EFFECT Writes file to local storage directory.
def _persist_chat_file(file_path: str, conv_id: str) -> str | None:
"""Copy uploaded file to chat_uploads storage, return relative path for download."""
storage_root = os.getenv("STORAGE_ROOT", "/app/storage")
if not os.path.isabs(storage_root):
storage_root = os.path.join(os.getcwd(), storage_root)
src = Path(file_path)
if not src.exists() or not src.is_file():
return None
dest_dir = Path(storage_root) / "chat_uploads" / conv_id
try:
dest_dir.mkdir(parents=True, exist_ok=True)
except PermissionError:
# Fallback to /tmp if storage root is not writable (dev environment)
dest_dir = Path("/tmp/chat_uploads") / conv_id
dest_dir.mkdir(parents=True, exist_ok=True)
storage_root = "/tmp"
dest_file = dest_dir / src.name
# If file with same name exists, add timestamp suffix
if dest_file.exists():
stem = dest_file.stem
suffix = dest_file.suffix
ts = datetime.now().strftime("%H%M%S")
dest_file = dest_dir / f"{stem}_{ts}{suffix}"
shutil.copy2(str(src), str(dest_file))
rel_path = str(dest_file.relative_to(Path(storage_root)))
log("AgentChat.PersistFile", "REFLECT", "Chat file persisted to storage",
{"original": src.name, "rel_path": rel_path, "size": dest_file.stat().st_size})
return rel_path
# #endregion AgentChat.GradioApp.PersistFile
# Per-conversation mutex for HITL resume (FR-026): keyed by conversation_id
_conv_locks: dict[str, asyncio.Event] = {}
# In-memory service JWT cache
_service_jwt_cache: dict[str, str] = {} # {token: expiry_timestamp}
# In-memory service JWT cache: {token: expiry_timestamp}
_service_jwt_cache: dict[str, str] = {}
# #region AgentChat.GradioApp.Handler [C:4] [TYPE Function] [SEMANTICS agent-chat,handler,streaming]
@@ -73,22 +139,17 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
env_id: str — selected environment ID from top-bar selector.
"""
# ── Auth: user JWT passed from frontend via additional_input —─
# @gradio/client does not forward Authorization headers,
# so the frontend passes the JWT explicitly.
user_jwt_str = user_jwt_str_param or ""
if user_jwt_str:
try:
decode_token(user_jwt_str)
except JWTError:
user_jwt_str = "" # Fall back to unauthenticated
user_jwt_str = ""
# Store in ContextVar for @tool functions
set_user_jwt(user_jwt_str)
# ── Per-user lock (prevent concurrent sends per user) ──
# Priority: 1) user_id_str from frontend additional_input (correct identity),
# 2) extracted from JWT, 3) fallback to "admin"
user_id = user_id_str or (_extract_user_id(user_jwt_str) if user_jwt_str else "admin")
# ── Per-user lock ──
user_id = user_id_str or (extract_user_id(user_jwt_str) if user_jwt_str else "admin")
if _user_locks.get(user_id, False):
yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND"}})
return
@@ -96,16 +157,18 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
conv_id: str | None = None
try:
# ── Handle file upload ──
# ── Resolve conversation ID early (needed for file persistence) ──
conv_id = conversation_id or str(uuid.uuid4())
# ── Parse message ──
text = message.get("text", "") if isinstance(message, dict) else str(message)
files = message.get("files", []) if isinstance(message, dict) else []
if not text.strip() and not files:
return
# ── Truncate long messages per FR-028 ──
# ── Truncate long messages ──
MAX_MSG_LENGTH = 100_000
if len(text) > MAX_MSG_LENGTH:
# Truncate at sentence boundary near the limit
truncated = text[:MAX_MSG_LENGTH]
last_sentence_end = max(
truncated.rfind('. '), truncated.rfind('! '), truncated.rfind('? '),
@@ -117,9 +180,13 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
else:
text = truncated + "\n[...truncated]"
# ── File upload ──
file_storage_path: str | None = None
file_original_name: str | None = None
file_size: int = 0
if files:
# File size validation
file_path = files[0] if isinstance(files[0], str) else getattr(files[0], "name", None)
file_obj = files[0]
file_path = file_obj if isinstance(file_obj, str) else getattr(file_obj, "name", None)
if file_path and os.path.exists(file_path):
file_size = os.path.getsize(file_path)
if file_size > MAX_FILE_SIZE_BYTES:
@@ -128,14 +195,28 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"metadata": {"type": "error", "code": "FILE_TOO_LARGE", "detail": "Max file size is 10 MB"},
})
return
parsed = parse_upload(files[0])
# Persist file to storage for download
if conv_id:
file_storage_path = _persist_chat_file(file_path, conv_id)
file_original_name = Path(file_path).name
parsed = parse_upload(file_obj)
text = f"{text}\n\n--- Uploaded file content ---\n{parsed}"
# ── Yield file metadata for frontend download link ──
if file_storage_path and file_original_name:
yield json.dumps({
"metadata": {
"type": "file_uploaded",
"file_name": file_original_name,
"file_path": file_storage_path,
"file_size": file_size,
}
})
# ── HITL resume path ──
if action in ("confirm", "deny"):
conv_id = conversation_id
if conv_id:
# Wait for primary stream cleanup (FR-026): max 2s
lock = _conv_locks.get(conv_id)
if lock is not None:
try:
@@ -146,40 +227,51 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"metadata": {"type": "error", "code": "STREAM_CLEANUP_TIMEOUT"},
})
return
async for chunk in _handle_resume(conv_id, action, user_jwt_str, env_id):
async for chunk in handle_resume(conv_id, action, user_jwt_str, env_id):
yield chunk
# Save conversation after HITL resume
await _save_conversation(conv_id or str(uuid.uuid4()), "HITL resume", user_id)
# Build descriptive title from pending confirmation info
pending = _pending_confirmations.get(conv_id, {}) if conv_id else {}
tool_name = pending.get("tool_name", "") if pending else ""
title = f"{'' if action == 'confirm' else '⏹️'} {tool_name or 'Операция'}" if tool_name else f"HITL: {action}"
await save_conversation(conv_id or str(uuid.uuid4()), title, user_id)
return
# ── Normal send path ──
conv_id = conversation_id or str(uuid.uuid4())
# Acquire per-conversation lock for FR-026 (primary stream owns this conv)
_conv_locks[conv_id] = asyncio.Event()
# ── Pre-fetch dashboard data if query mentions dashboards ──
# Some LLMs (gemma) don't call tools even when instructed.
# Pre-fetch ensures dashboard data is available in context.
fast_tool_name = fast_confirmation_tool(text)
if fast_tool_name:
_pending_confirmations[conv_id] = {
"tool_name": fast_tool_name,
"tool_args": {},
"user_text": text,
}
yield json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": confirmation_metadata_for_tool(conv_id, fast_tool_name, {}),
})
return
# ── Pre-fetch dashboards ──
text_lower = text.lower()
prefetch_available = False
if any(kw in text_lower for kw in ["дашборд", "dashboard", "dashboards", "дашборды"]):
try:
dash_data = await _prefetch_dashboards(env_id or "")
dash_data = await prefetch_dashboards(env_id or "")
if dash_data:
text += f"\n\n[PRE-FETCHED DATA — use this directly, do NOT call tools]\n{dash_data}\n[/PRE-FETCHED DATA]"
prefetch_available = True
except Exception:
pass # Pre-fetch is best-effort
pass
agent_tools = get_tools_for_query(text, prefetch_available=prefetch_available)
agent = await create_agent(agent_tools, env_id)
config = {"configurable": {"thread_id": conv_id}}
# Collect assistant response during streaming
assistant_parts: list[str] = []
# Try up to 2 times: catch OutputParserException and retry with stricter prompt
max_attempts = 2
try:
for attempt in range(max_attempts):
try:
@@ -190,11 +282,8 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
version="v2",
):
kind = event.get("event")
# Audit logging for tool events
if kind in ("on_tool_start", "on_tool_end", "on_tool_error"):
await log_tool_event(event, conv_id)
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
@@ -204,7 +293,6 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
emitted_any = True
@@ -212,7 +300,6 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
})
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
@@ -221,7 +308,6 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
elif kind == "on_tool_error":
tool_name = event["name"]
err = str(event["data"].get("error", "Unknown"))
@@ -234,218 +320,60 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
state = await agent.aget_state(config)
if getattr(state, "next", None):
emitted_any = True
yield json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": {
"type": "confirm_required",
"thread_id": conv_id,
"prompt": "Подтвердить операцию?",
},
})
yield confirmation_payload(conv_id, state, text)
return
elif not emitted_any:
yield json.dumps({
"content": "❌ Агент завершился без ответа.",
"metadata": {
"type": "error",
"code": "EMPTY_AGENT_RESPONSE",
"state_next": repr(getattr(state, "next", None)),
"state_tasks": repr(getattr(state, "tasks", None))[:500],
},
"metadata": {"type": "error", "code": "EMPTY_AGENT_RESPONSE",
"state_next": repr(getattr(state, "next", None)),
"state_tasks": repr(getattr(state, "tasks", None))[:500]},
})
break # Stream ends — break out to save conversation
break
except OutputParserException as e:
if attempt < max_attempts - 1:
# Retry with stricter prompt
text = "Respond with valid JSON only. Previous response was malformed.\n\n" + text
continue
# Final failure — yield error event
yield json.dumps({
"content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.",
"metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)},
})
except Exception as exc:
# RESILIENCE: LangGraph interrupt may cause "Event loop is closed" inside
# astream_events (httpx connection to LLM is cancelled during interrupt cleanup).
# The checkpoint with pending interrupt IS already saved — we detect it here
# and emit confirm_required instead of showing a generic error to the user.
try:
state = await agent.aget_state(config)
if getattr(state, "next", None):
emitted_any = True
yield json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": {"type": "confirm_required", "thread_id": conv_id, "prompt": "Подтвердить операцию?"},
})
yield confirmation_payload(conv_id, state, text)
return
except Exception:
pass # Can't check state either — show original error
# Genuine non-recoverable error.
pass
yield json.dumps({
"content": f"❌ Ошибка: {exc}",
"metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)},
})
await _save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts))
await save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts))
return
# ── Save conversation to DB via FastAPI REST ──
await _save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts))
await save_conversation(conv_id, text, user_id, assistant_text="".join(assistant_parts))
# Fire-and-forget: generate LLM title in background (best-effort)
asyncio.create_task(generate_llm_title(conv_id, text))
finally:
_user_locks[user_id] = False
# Release per-conversation lock (FR-026) so HITL resume can proceed
if conv_id and conv_id in _conv_locks:
_conv_locks[conv_id].set()
del _conv_locks[conv_id]
# #endregion AgentChat.GradioApp.Handler
async def _prefetch_dashboards(env_id: str) -> str:
"""Pre-fetch dashboard data so the LLM has it in context without needing to call a tool."""
try:
from src.agent.tools import _dual_auth_headers, FASTAPI_URL
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 = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25"))
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:
return ""
async def _handle_resume(
conversation_id: str, action: str,
user_jwt: str = "", env_id: str | None = None,
) -> AsyncGenerator[str]:
"""Resume from HITL checkpoint."""
set_user_jwt(user_jwt)
agent = await create_agent(get_all_tools(), env_id, interrupt_before=[])
if action == "confirm":
config = {"configurable": {"thread_id": conversation_id}}
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
async for event in agent.astream_events(None, config=config, version="v2"):
kind = event.get("event")
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
yield json.dumps({
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
})
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
elif action == "deny":
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
def _extract_user_id(jwt_str: str) -> str:
try:
payload = decode_token(jwt_str)
return payload.get("sub", payload.get("user_id", "unknown"))
except Exception:
return "unknown"
# ── Conversation persistence ──────────────────────────────────────
SAVE_API_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") + "/api/agent/conversations/save"
async def _save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
"""Save conversation to DB via FastAPI REST.
Called after streaming completes. Creates or updates AgentConversation
and persists messages (user message + optional assistant response).
Uses SERVICE_JWT for auth.
Failures are logged but not propagated.
"""
try:
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
# Normalize user_id: anonymous Gradio sessions use "anon_" prefix
# which won't match the conversation list filter. Default to "admin".
if not user_id or user_id.startswith("anon_"):
user_id = "admin"
messages = [
{
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "user",
"text": user_text.strip(),
"created_at": datetime.utcnow().isoformat(),
},
]
if assistant_text.strip():
messages.append({
"id": str(uuid.uuid4()),
"conversation_id": conv_id,
"role": "assistant",
"text": assistant_text.strip(),
"created_at": datetime.utcnow().isoformat(),
})
payload = {
"conversation_id": conv_id,
"title": user_text.strip()[:100] or "Agent conversation",
"user_id": user_id,
"messages": messages,
}
async with httpx.AsyncClient(timeout=10) as client:
await client.post(SAVE_API_URL, json=payload, headers=headers)
except Exception as e:
log("AgentChat.GradioApp", "EXPLORE", "Failed to save conversation",
{"conv_id": conv_id}, error=str(e))
# ── Gradio interface ──
# #region AgentChat.GradioApp.CreateInterface [C:2] [TYPE Function] [SEMANTICS agent-chat,gradio,interface]
# @ingroup AgentChat
# @BRIEF Create the Gradio ChatInterface with additional inputs for conv_id, action, user_id, jwt, env_id.
# @POST Returns gr.ChatInterface instance.
def create_chat_interface():
"""Create the Gradio ChatInterface."""
return gr.ChatInterface(
fn=agent_handler,
type="messages",
@@ -463,12 +391,15 @@ def create_chat_interface():
["Запусти миграцию", None, None],
],
)
# #endregion AgentChat.GradioApp.CreateInterface
# ── Healthcheck ──
# #region AgentChat.GradioApp.Health [C:1] [TYPE Function] [SEMANTICS agent-chat,healthcheck]
# @ingroup AgentChat
# @BRIEF Healthcheck endpoint for Docker.
async def health():
"""Healthcheck endpoint for Docker."""
return {"status": "ok", "uptime": os.times().elapsed if hasattr(os.times(), "elapsed") else 0}
# #endregion AgentChat.GradioApp.Health
if __name__ == "__main__":

View File

@@ -1,6 +1,6 @@
# backend/src/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.
# @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver.
# @PRE LLM provider configured. Priority: 1) llm_config param 2) env vars LLM_API_KEY/LLM_BASE_URL/LLM_MODEL.
# @POST Compiled StateGraph ready for astream_events().
# @SIDE_EFFECT Initializes checkpointer and message history tables on first call.

View File

@@ -7,6 +7,7 @@
# @SIDE_EFFECT Binds to a TCP port via Gradio launch.
# @RATIONALE Gradio port must match the frontend proxy target. Optional fallback is available only
# when GRADIO_ALLOW_PORT_FALLBACK=true and an external proxy is updated separately.
# @REJECTED Hardcoding the port was rejected — it must be configurable for different deployment environments.
import os
import socket
import httpx

View File

@@ -19,6 +19,79 @@ from src.schemas.agent import (
SaveConversationRequest,
)
def _derive_risk(messages) -> str | None:
"""Derive aggregate risk level from conversation messages.
Heuristic: if any tool_call has dangerous args (env_id containing 'prod',
'execute', 'deploy', 'maintenance'), classify as 'dangerous'.
If a tool was called at all, classify as 'guarded'. Otherwise 'safe'.
"""
has_tool = False
dangerous_keywords = {"prod", "execute", "deploy", "maintenance", "migration", "backup"}
for m in messages:
tool_calls = getattr(m, "tool_calls", None)
if tool_calls and (isinstance(tool_calls, (list, tuple)) and len(tool_calls) > 0):
has_tool = True
for tc in tool_calls:
if isinstance(tc, dict):
args_str = str(tc.get("input", ""))
else:
args_str = str(getattr(tc, "input", ""))
if any(kw in args_str.lower() for kw in dangerous_keywords):
return "dangerous"
if has_tool:
return "guarded"
return "safe"
def _safe_str(val, default=None):
"""Coerce value to string, falling back to default for non-string types."""
if val is None:
return default
if isinstance(val, str):
return val
try:
return str(val)
except Exception:
return default
def _safe_bool(val):
"""Coerce value to bool safely."""
if isinstance(val, bool):
return val
if val is None:
return False
try:
return bool(val)
except Exception:
return False
def _text_has_error(text: str) -> bool:
"""Check if message text indicates an error state.
Matches: [ERROR] markers, ⏹️ cancelled operations, Russian/English
error phrases like 'недоступен', 'unavailable', 'ошибка', etc.
"""
t = str(text).lower() if text else ""
markers = [
"[error]",
"⏹️",
"недоступен",
"unavailable",
"временно недоступен",
"temporarily unavailable",
"попробуйте позже",
"try again later",
"агент временно",
"agent is temporarily",
"произошла ошибка",
"an error occurred",
]
return any(m in t for m in markers)
router = APIRouter(prefix="/api/assistant", tags=["Agent"])
agent_router = APIRouter(prefix="/api/agent", tags=["Agent-Internal"])
@@ -49,8 +122,25 @@ async def list_conversations(
(page - 1) * page_size
).limit(page_size).all()
return ConversationListResponse(
items=[ConversationItem(id=c.id, title=c.title, updated_at=c.updated_at,
message_count=len(c.messages)) for c in items],
items=[ConversationItem(
id=c.id,
title=c.title,
updated_at=c.updated_at,
message_count=len(c.messages) if c.messages else 0,
last_role=_safe_str(getattr(c.messages[-1], "role", None)) if c.messages else None,
has_tool_calls=any(
getattr(m, "tool_calls", None) and (
isinstance(getattr(m, "tool_calls"), (list, tuple)) and len(getattr(m, "tool_calls")) > 0
)
for m in (c.messages or [])
),
has_error=any(
(_safe_str(getattr(m, "state", None)) in ("error", "failed"))
or (_safe_str(getattr(m, "text", None)) and _text_has_error(getattr(m, "text", "")))
for m in (c.messages or [])
),
risk_level=_derive_risk(c.messages) if c.messages else None,
) for c in items],
has_next=(page * page_size) < total,
active_total=total,
)
@@ -103,6 +193,7 @@ async def save_conversation(
conversation_id=body.conversation_id,
role=msg_data.get("role", "user"),
text=msg_data.get("text", ""),
state=msg_data.get("state"),
tool_calls=msg_data.get("tool_calls"),
attachments=msg_data.get("attachments"),
created_at=datetime.utcnow(),

View File

@@ -14,6 +14,10 @@ class ConversationItem(BaseModel):
title: str
updated_at: datetime
message_count: int
last_role: str | None = None # "user" | "assistant" — who sent the last message
has_tool_calls: bool = False # conversation includes tool usage
has_error: bool = False # conversation has error state
risk_level: str | None = None # "safe" | "guarded" | "dangerous"
# #endregion Schemas.Agent.ConversationItem