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:
@@ -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
|
||||
|
||||
214
backend/src/agent/_confirmation.py
Normal file
214
backend/src/agent/_confirmation.py
Normal 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
|
||||
376
backend/src/agent/_persistence.py
Normal file
376
backend/src/agent/_persistence.py
Normal 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
|
||||
190
backend/src/agent/_tool_resolver.py
Normal file
190
backend/src/agent/_tool_resolver.py
Normal 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
|
||||
@@ -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__":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user