"""AI Copilot service — NL query processing, action execution, RBAC, audit logging.""" from __future__ import annotations import uuid from datetime import datetime, timezone from typing import Any from sqlalchemy import select, func, desc, and_ 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, filter_fields_by_permission from app.models.ai_conversation import AIConversation, AIMessage from app.models.company import 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(timezone.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