Files
leocrm/app/services/ai_copilot_service.py
T

516 lines
15 KiB
Python
Raw Normal View History

"""AI Copilot service — NL query processing, action execution, RBAC, audit logging."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import get_llm_client
from app.core.audit import log_audit
from app.core.auth import check_permission
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.contact import Contact as Company
from app.models.contact import Contact
from app.models.workflow import Workflow
def _safe_iso(dt) -> str | None:
if dt is None:
return None
try:
return dt.isoformat() if hasattr(dt, "isoformat") else None
except Exception:
return None
def _get_attr(obj, name, default=None):
try:
val = getattr(obj, name)
return val if val is not None else default
except Exception:
return default
def _conversation_to_dict(c: AIConversation) -> dict[str, Any]:
return {
"id": str(c.id),
"title": c.title,
"context": c.context,
"created_at": _safe_iso(_get_attr(c, "created_at")),
"updated_at": _safe_iso(_get_attr(c, "updated_at")),
}
def _message_to_dict(m: AIMessage) -> dict[str, Any]:
return {
"id": str(m.id),
"role": m.role,
"content": m.content,
"proposed_actions": m.proposed_actions,
"executed_action": m.executed_action,
"execution_result": m.execution_result,
"created_at": _safe_iso(_get_attr(m, "created_at")),
}
async def process_query(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
query: str,
conversation_id: str | None = None,
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Process a natural language query and return proposed actions.
1. Get or create conversation
2. Store user message
3. Call LLM client (mock or real) to get proposed actions
4. Store assistant message with proposed actions
5. Return response with conversation_id and proposed_actions
"""
context = context or {}
# Get or create conversation
if conversation_id:
conv_uuid = uuid.UUID(conversation_id)
result = await db.execute(
select(AIConversation).where(
AIConversation.id == conv_uuid,
AIConversation.tenant_id == tenant_id,
)
)
conversation = result.scalar_one_or_none()
if conversation is None:
return {"error": "Conversation not found", "status_code": 404}
else:
conversation = AIConversation(
tenant_id=tenant_id,
user_id=user_id,
title=query[:100] if query else "Untitled",
context=context,
)
db.add(conversation)
await db.flush()
await db.refresh(conversation)
# Get next message index
count_q = select(func.count()).select_from(
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
)
count_result = await db.execute(count_q)
msg_index = count_result.scalar_one()
# Store user message
user_msg = AIMessage(
tenant_id=tenant_id,
conversation_id=conversation.id,
role="user",
content=query,
message_index=msg_index,
)
db.add(user_msg)
await db.flush()
await db.refresh(user_msg)
# Call LLM client
llm = get_llm_client()
llm_response = await llm.generate(query, context)
# Store assistant message
assistant_msg = AIMessage(
tenant_id=tenant_id,
conversation_id=conversation.id,
role="assistant",
content=llm_response.message,
proposed_actions=llm_response.proposed_actions,
message_index=msg_index + 1,
)
db.add(assistant_msg)
await db.flush()
await db.refresh(assistant_msg)
# Log to audit
await log_audit(
db,
tenant_id,
user_id,
action="query",
entity_type="ai_copilot",
entity_id=conversation.id,
changes={"query": query, "proposed_action_count": len(llm_response.proposed_actions)},
)
return {
"conversation_id": str(conversation.id),
"message": llm_response.message,
"proposed_actions": llm_response.proposed_actions,
}
async def execute_action(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
role: str,
conversation_id: str,
action: dict[str, Any],
) -> dict[str, Any]:
"""Execute a proposed action with RBAC enforcement.
1. Validate conversation belongs to tenant
2. Check RBAC permissions for the action
3. Execute the action (direct DB or API call)
4. Store execution result in message
5. Log to audit
"""
conv_uuid = uuid.UUID(conversation_id)
# Validate conversation ownership
result = await db.execute(
select(AIConversation).where(
AIConversation.id == conv_uuid,
AIConversation.tenant_id == tenant_id,
)
)
conversation = result.scalar_one_or_none()
if conversation is None:
return {"error": "Conversation not found", "status_code": 404}
method = action.get("method", "GET").upper()
path = action.get("path", "")
body = action.get("body") or {}
# Determine module and action_type from path for RBAC
module, action_type = _derive_rbac_from_path(method, path)
if not check_permission(role, module, action_type):
return {
"error": "Insufficient permissions for this action",
"status_code": 403,
"success": False,
}
# Execute the action
try:
exec_result = await _execute_api_action(db, tenant_id, user_id, method, path, body)
except Exception as exc:
exec_result = {"error": str(exc), "status_code": 500}
# Get next message index
count_q = select(func.count()).select_from(
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
)
count_result = await db.execute(count_q)
msg_index = count_result.scalar_one()
# Store execution message
exec_msg = AIMessage(
tenant_id=tenant_id,
conversation_id=conversation.id,
role="assistant",
content=f"Executed {method} {path}",
executed_action=action,
execution_result=exec_result,
message_index=msg_index,
)
db.add(exec_msg)
await db.flush()
await db.refresh(exec_msg)
# Log to audit
await log_audit(
db,
tenant_id,
user_id,
action="execute",
entity_type="ai_copilot",
entity_id=conversation.id,
changes={"action": action, "result": exec_result},
)
return {
"conversation_id": str(conversation.id),
"success": exec_result.get("success", True),
"status_code": exec_result.get("status_code", 200),
"data": exec_result.get("data"),
"error": exec_result.get("error"),
}
async def get_history(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""Get paginated conversation history for the current user."""
page = max(1, page)
page_size = max(1, min(100, page_size))
base = select(AIConversation).where(
AIConversation.tenant_id == tenant_id,
AIConversation.user_id == user_id,
)
count_q = select(func.count()).select_from(base.subquery())
total_result = await db.execute(count_q)
total = total_result.scalar_one()
offset = (page - 1) * page_size
paginated = base.order_by(desc(AIConversation.created_at)).offset(offset).limit(page_size)
result = await db.execute(paginated)
conversations = result.scalars().all()
items: list[dict[str, Any]] = []
for conv in conversations:
# Get messages for each conversation
msg_q = (
select(AIMessage)
.where(
AIMessage.conversation_id == conv.id,
AIMessage.tenant_id == tenant_id,
)
.order_by(AIMessage.message_index)
)
msg_result = await db.execute(msg_q)
messages = msg_result.scalars().all()
items.append(
{
**_conversation_to_dict(conv),
"messages": [_message_to_dict(m) for m in messages],
}
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
async def _execute_api_action(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
method: str,
path: str,
body: dict[str, Any],
) -> dict[str, Any]:
"""Execute an API action directly against the database.
Supports companies and contacts CRUD, plus workflow listing.
"""
# Parse path to determine entity and operation
parts = path.replace("/api/v1/", "").strip("/").split("/")
entity = parts[0] if parts else ""
entity_id = parts[1] if len(parts) > 1 else None
if entity == "companies":
return await _exec_companies(db, tenant_id, user_id, method, entity_id, body)
elif entity == "contacts":
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body)
elif entity == "workflows":
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body)
else:
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
async def _exec_companies(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
method: str,
entity_id: str | None,
body: dict[str, Any],
) -> dict[str, Any]:
"""Execute company operations."""
if method == "GET":
result = await db.execute(
select(Company).where(
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
)
companies = result.scalars().all()
return {
"success": True,
"status_code": 200,
"data": [{"id": str(c.id), "name": c.name, "industry": c.industry} for c in companies],
}
elif method == "POST":
company = Company(
tenant_id=tenant_id,
name=body.get("name", "Untitled"),
industry=body.get("industry"),
phone=body.get("phone"),
email=body.get("email"),
website=body.get("website"),
description=body.get("description"),
created_by=user_id,
updated_by=user_id,
)
db.add(company)
await db.flush()
return {
"success": True,
"status_code": 201,
"data": {"id": str(company.id), "name": company.name},
}
elif method == "DELETE":
if not entity_id or entity_id == "{id}":
return {"error": "Company ID required", "status_code": 400, "success": False}
comp_uuid = uuid.UUID(entity_id)
result = await db.execute(
select(Company).where(
Company.id == comp_uuid,
Company.tenant_id == tenant_id,
)
)
company = result.scalar_one_or_none()
if company is None:
return {"error": "Company not found", "status_code": 404, "success": False}
company.deleted_at = datetime.now(UTC)
await db.flush()
return {
"success": True,
"status_code": 200,
"data": {"id": str(company.id), "deleted": True},
}
elif method == "PATCH":
if not entity_id or entity_id == "{id}":
return {"error": "Company ID required", "status_code": 400, "success": False}
comp_uuid = uuid.UUID(entity_id)
result = await db.execute(
select(Company).where(
Company.id == comp_uuid,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
)
company = result.scalar_one_or_none()
if company is None:
return {"error": "Company not found", "status_code": 404, "success": False}
for key in ("name", "industry", "phone", "email", "website", "description"):
if key in body:
setattr(company, key, body[key])
company.updated_by = user_id
await db.flush()
return {
"success": True,
"status_code": 200,
"data": {"id": str(company.id), "name": company.name, "industry": company.industry},
}
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
async def _exec_contacts(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
method: str,
entity_id: str | None,
body: dict[str, Any],
) -> dict[str, Any]:
"""Execute contact operations."""
if method == "GET":
result = await db.execute(
select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
)
contacts = result.scalars().all()
return {
"success": True,
"status_code": 200,
"data": [{"id": str(c.id), "name": c.name, "email": c.email} for c in contacts],
}
elif method == "POST":
contact = Contact(
tenant_id=tenant_id,
name=body.get("name", "Untitled"),
email=body.get("email"),
phone=body.get("phone"),
created_by=user_id,
updated_by=user_id,
)
db.add(contact)
await db.flush()
return {
"success": True,
"status_code": 201,
"data": {"id": str(contact.id), "name": contact.name},
}
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
async def _exec_workflows(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
method: str,
entity_id: str | None,
body: dict[str, Any],
) -> dict[str, Any]:
"""Execute workflow operations."""
if method == "GET":
result = await db.execute(
select(Workflow).where(
Workflow.tenant_id == tenant_id,
Workflow.is_active.is_(True),
)
)
workflows = result.scalars().all()
return {
"success": True,
"status_code": 200,
"data": [
{"id": str(w.id), "name": w.name, "trigger_event": w.trigger_event}
for w in workflows
],
}
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
def _derive_rbac_from_path(method: str, path: str) -> tuple[str, str]:
"""Derive module and action_type from HTTP method and path for RBAC.
Returns (module, action_type) suitable for check_permission().
"""
parts = path.replace("/api/v1/", "").strip("/").split("/")
entity = parts[0] if parts else ""
method_to_action = {
"GET": "read",
"POST": "create",
"PATCH": "update",
"DELETE": "delete",
}
action_type = method_to_action.get(method, "read")
# Map path entities to permission modules
entity_to_module = {
"companies": "companies",
"contacts": "contacts",
"workflows": "workflows",
"ai": "ai_copilot",
}
module = entity_to_module.get(entity, entity)
return module, action_type