## 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
351 lines
14 KiB
Python
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
|