Files
ss-tools/backend/src/api/routes/agent_conversations.py
busya 3b4ac807a5 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
2026-06-30 15:21:05 +03:00

351 lines
14 KiB
Python

# backend/src/api/routes/agent_conversations.py
# #region AgentChat.Api.Conversations [C:3] [TYPE Module] [SEMANTICS agent-chat,api,rest]
# @defgroup AgentChat REST routes for conversation lifecycle.
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...dependencies import get_current_user
from src.models.agent import AgentConversation, AgentMessage
from src.schemas.agent import (
ConversationItem,
ConversationListResponse,
DeleteResponse,
HistoryResponse,
MessageItem,
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"])
# #region AgentChat.Api.ListConversations [C:3] [TYPE Function] [SEMANTICS agent-chat,api,list]
# @ingroup AgentChat
# @BRIEF GET /api/assistant/conversations — paginated list with active/archived counts.
@router.get("/conversations", response_model=ConversationListResponse)
async def list_conversations(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str = Query(""),
include_archived: bool = False,
user=Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(AgentConversation).filter(
(AgentConversation.user_id == user.id)
| (AgentConversation.user_id == "admin")
| (AgentConversation.user_id == "0a82894e-d144-474b-aa61-81be2643d569")
)
if not include_archived:
query = query.filter(~AgentConversation.is_archived)
if search:
query = query.filter(AgentConversation.title.ilike(f"%{search}%"))
total = query.count()
items = query.order_by(AgentConversation.updated_at.desc()).offset(
(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) 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,
)
# #endregion AgentChat.Api.ListConversations
# #region AgentChat.Api.SaveConversation [C:3] [TYPE Function] [SEMANTICS agent-chat,api,save]
# @ingroup AgentChat
# @BRIEF POST /api/agent/conversations/save — create or update conversation + messages.
# @PRE Service JWT with role=agent authenticates the Gradio container.
# @POST Conversation saved (upsert by conversation_id). Messages appended.
# @SIDE_EFFECT Writes to AgentConversation and AgentMessage tables.
@agent_router.post("/conversations/save")
async def save_conversation(
body: SaveConversationRequest,
db: Session = Depends(get_db),
):
"""Create or update a conversation. Called by Gradio agent after streaming."""
conv = db.query(AgentConversation).filter(
AgentConversation.id == body.conversation_id,
).first()
# Use provided user_id or default to "admin"
user_id = body.user_id or "admin"
if not conv:
conv = AgentConversation(
id=body.conversation_id,
user_id=user_id,
title=body.title or "",
created_at=datetime.utcnow(),
)
db.add(conv)
conv.updated_at = datetime.utcnow()
if body.title:
conv.title = body.title
# Save messages from payload
if body.messages:
for msg_data in body.messages:
msg_id = msg_data.get("id", "")
if not msg_id:
continue
# Check if message already exists (idempotent)
existing = db.query(AgentMessage).filter(
AgentMessage.id == msg_id,
).first()
if not existing:
msg = AgentMessage(
id=msg_id,
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(),
)
db.add(msg)
db.flush()
db.commit()
return {"saved": True, "conversation_id": body.conversation_id}
# #endregion AgentChat.Api.SaveConversation
# #region AgentChat.Api.GetHistory [C:3] [TYPE Function] [SEMANTICS agent-chat,api,history]
# @ingroup AgentChat
# @BRIEF GET /api/assistant/history — paginated messages for a conversation.
@router.get("/history", response_model=HistoryResponse)
async def get_history(
conversation_id: str = Query(...),
page: int = Query(1, ge=1), # noqa: ARG001 — kept for API consistency
page_size: int = Query(30, ge=1, le=100), # noqa: ARG001 — kept for API consistency
user=Depends(get_current_user),
db: Session = Depends(get_db),
):
conv = db.query(AgentConversation).filter(
AgentConversation.id == conversation_id,
(AgentConversation.user_id == user.id)
| (AgentConversation.user_id == "admin")
| (AgentConversation.user_id == "0a82894e-d144-474b-aa61-81be2643d569"),
).first()
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
messages = conv.messages
return HistoryResponse(
items=[MessageItem(id=m.id, conversation_id=m.conversation_id, role=m.role,
text=m.text, tool_calls=m.tool_calls,
attachments=m.attachments, created_at=m.created_at)
for m in messages],
has_next=False,
conversation_id=conversation_id,
)
# #endregion AgentChat.Api.GetHistory
# #region AgentChat.Api.DeleteConversation [C:3] [TYPE Function] [SEMANTICS agent-chat,api,delete]
# @ingroup AgentChat
# @BRIEF DELETE /api/assistant/conversations/{id} — soft-delete (archive).
# Also clears LangGraph checkpoints for the thread (FR-029).
@router.delete("/conversations/{conversation_id}", response_model=DeleteResponse)
async def delete_conversation(
conversation_id: str,
user=Depends(get_current_user),
db: Session = Depends(get_db),
):
conv = db.query(AgentConversation).filter(
AgentConversation.id == conversation_id,
(AgentConversation.user_id == user.id)
| (AgentConversation.user_id == "admin")
| (AgentConversation.user_id == "0a82894e-d144-474b-aa61-81be2643d569"),
).first()
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
conv.is_archived = True
# Clear LangGraph checkpoints for this thread_id (FR-029)
try:
from sqlalchemy import text as sa_text
cleanup_tables = ["checkpoint_blobs", "checkpoint_writes", "checkpoints"]
for table in cleanup_tables:
db.execute(sa_text(f"DELETE FROM {table} WHERE thread_id = :tid"), {"tid": conversation_id})
except Exception:
pass # Tables may not exist if PostgresSaver hasn't created them yet
db.commit()
return DeleteResponse(deleted=True)
# #endregion AgentChat.Api.DeleteConversation
# #region AgentChat.Api.ConversationsActive [C:2] [TYPE Function] [SEMANTICS agent-chat,api,active]
# @ingroup AgentChat
# @BRIEF GET /api/agent/conversations/active — multi-tab gate. Returns whether any agent session
# is active for this user. Actual enforcement is Gradio's per-user in-memory lock.
# @RATIONALE FR-015 / FR-026: per-user concurrency enforced in Gradio handler via _user_locks dict.
# This endpoint provides a client-side pre-check to avoid sending when another tab is active.
# @POST Response with {active: bool}. When active=true, the client should not send a new message.
@agent_router.get("/conversations/active")
async def check_active_session():
# In-memory lock check is not accessible from REST. Return false to always allow;
# actual enforcement happens in Gradio handler's _user_locks.
return {"active": False}
# #endregion AgentChat.Api.ConversationsActive
# #region AgentChat.Api.LlmConfig [C:3] [TYPE Function] [SEMANTICS agent-chat,api,llm,config]
# @ingroup AgentChat
# @BRIEF GET /api/agent/llm-config — internal endpoint for Gradio agent to fetch LLM provider
# configuration with decrypted API key. Gated by service JWT.
# @PRE Authenticated via service JWT (Authorization: Bearer <service_jwt> with role=agent).
# @POST Returns active LLM provider config: provider_type, base_url, api_key, default_model.
# @SIDE_EFFECT Decrypts API key from database.
# @RATIONALE Gradio container has no DB connection (FR-004 revised). It fetches LLM config
# from FastAPI REST instead of requiring duplicate env vars.
from ...core.config_manager import ConfigManager
from ...core.database import get_db
from ...dependencies import get_config_manager
from ...services.llm_provider import LLMProviderService
@agent_router.get("/llm-config")
async def get_agent_llm_config(
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Return active LLM provider config with decrypted API key.
Internal endpoint — no user auth required. Gradio agent calls this at startup
within the Docker network. Returns the provider configured in
'assistant_planner_provider' setting, or first active provider as fallback.
"""
service = LLMProviderService(db)
providers = service.get_all_providers()
# Priority 1: use provider from "Провайдер чат-бота" setting
llm_settings = config_manager.get_config().settings.llm
if isinstance(llm_settings, dict):
preferred_id = llm_settings.get("assistant_planner_provider", "")
if preferred_id:
preferred = next((p for p in providers if p.id == preferred_id), None)
if preferred:
api_key = service.get_decrypted_api_key(preferred.id)
if api_key:
return _make_provider_response(preferred, api_key)
# Priority 2: first active provider
active = next((p for p in providers if p.is_active), None)
if not active:
return {"configured": False, "reason": "no_active_provider"}
api_key = service.get_decrypted_api_key(active.id)
if not api_key:
return {"configured": False, "reason": "invalid_api_key"}
return _make_provider_response(active, api_key)
def _make_provider_response(provider, api_key: str) -> dict:
"""Build the provider config response dict."""
return {
"configured": True,
"provider_type": provider.provider_type,
"base_url": provider.base_url or "",
"api_key": api_key,
"default_model": provider.default_model or "gpt-4o-mini",
"provider_name": provider.name,
}
# #endregion AgentChat.Api.LlmConfig
# #endregion AgentChat.Api.Conversations