chore(cleanup): toten AI-Copilot-Legacy entfernt — Router nie gemountet, Tabellen von Migration 0137 gedroppt (Chat läuft seitdem über kommunikation/comm_conversations); schemas/OpenAPI-Tag bereinigt; Geister-Test test_ai_copilot.py geloescht (pytest.skip seit Phase 2); test_contacts_lifecycle Route-Anzahl-Failure als Vorbestand bewiesen (83 Routen auch auf clean HEAD)
This commit is contained in:
@@ -1,477 +0,0 @@
|
||||
"""AI Copilot service — NL query processing, action execution, RBAC, audit logging.
|
||||
|
||||
.. deprecated:: Phase 2
|
||||
This module is a legacy leftover of the pre-kommunikation AI chat system.
|
||||
It is NOT registered in main.py, has NO frontend consumers, and its test
|
||||
suite is skipped ("ai_copilot routes removed in Phase 2").
|
||||
|
||||
Shutdown plan (ARCH-059):
|
||||
1. ✅ Marked deprecated (this notice)
|
||||
2. Remove routes/service/model + ``ai_conversations`` tables in a dedicated
|
||||
migration once a release confirms zero traffic on /api/v1/ai/copilot/*
|
||||
3. Do NOT migrate to kommunikation — the plugin already covers chat via
|
||||
CommConversation/CommMessage; parity work would be wasted effort.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import warnings
|
||||
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.permissions import check_permission
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
|
||||
try:
|
||||
from app.models.ai_conversation import AIConversation, AIMessage
|
||||
except ImportError:
|
||||
AIConversation = None # type: ignore
|
||||
AIMessage = None # type: ignore
|
||||
from app.models.contact import Contact
|
||||
from app.models.workflow import Workflow
|
||||
|
||||
warnings.warn(
|
||||
"app.services.ai_copilot_service is deprecated (ARCH-059) — "
|
||||
"scheduled for removal; do not build new features on it",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
is_system_admin: bool = False,
|
||||
) -> 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,
|
||||
resolved: dict[str, Any],
|
||||
conversation_id: str,
|
||||
action: dict[str, Any],
|
||||
is_system_admin: bool = False,
|
||||
) -> 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)
|
||||
required_perm = f"{module}:{action_type}"
|
||||
|
||||
if not check_permission(resolved, required_perm):
|
||||
return {
|
||||
"error": "Insufficient permissions for this action",
|
||||
"status_code": 403,
|
||||
"success": False,
|
||||
}
|
||||
|
||||
# Check single entity access for write operations
|
||||
if method in ("POST", "PATCH", "DELETE"):
|
||||
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||
entity_type = parts[0] if parts else ""
|
||||
entity_id = parts[1] if len(parts) > 1 else None
|
||||
if entity_id:
|
||||
try:
|
||||
entity_uuid = uuid.UUID(entity_id)
|
||||
except (ValueError, TypeError):
|
||||
entity_uuid = None
|
||||
if entity_uuid:
|
||||
has_access = await check_single_entity_access(
|
||||
db, entity_type, entity_uuid, user_id, tenant_id,
|
||||
required_level="write", is_system_admin=is_system_admin,
|
||||
)
|
||||
if not has_access:
|
||||
return {
|
||||
"error": "Insufficient access to this entity",
|
||||
"status_code": 403,
|
||||
"success": False,
|
||||
}
|
||||
|
||||
# Execute the action
|
||||
try:
|
||||
exec_result = await _execute_api_action(db, tenant_id, user_id, method, path, body, is_system_admin=is_system_admin)
|
||||
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],
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute an API action directly against the database.
|
||||
|
||||
Supports contacts CRUD (including company-type contacts), 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 in ("companies", "contacts"):
|
||||
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body, is_system_admin=is_system_admin)
|
||||
elif entity == "workflows":
|
||||
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body, is_system_admin=is_system_admin)
|
||||
else:
|
||||
return {"error": f"Unsupported entity: {entity}", "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],
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute contact operations (unified: company + person)."""
|
||||
if method == "GET":
|
||||
query = select(Contact).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "contact", Contact, user_id, tenant_id, is_system_admin=is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
contacts = result.scalars().all()
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 200,
|
||||
"data": [{"id": str(c.id), "name": c.name or c.displayname, "email": c.email_1, "type": c.type} for c in contacts],
|
||||
}
|
||||
|
||||
elif method == "POST":
|
||||
contact = Contact(
|
||||
tenant_id=tenant_id,
|
||||
type=body.get("type", "company"),
|
||||
name=body.get("name", "Untitled"),
|
||||
email_1=body.get("email"),
|
||||
phone_1=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, "type": contact.type},
|
||||
}
|
||||
|
||||
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],
|
||||
is_system_admin: bool = False,
|
||||
) -> 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
|
||||
Reference in New Issue
Block a user