merge: Block H - Agent platform kernel (tools/steps/blocks/tabs plugin-contributable)
This commit is contained in:
@@ -6,9 +6,12 @@ Supports keyword-based intent detection for common CRM operations.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Precompiled patterns for intent detection
|
# Precompiled patterns for intent detection
|
||||||
_PATTERNS = {
|
_PATTERNS = {
|
||||||
"create_contact": re.compile(
|
"create_contact": re.compile(
|
||||||
@@ -27,6 +30,37 @@ _PATTERNS = {
|
|||||||
"help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE),
|
"help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Plugin-contributed intents: pattern -> callable(query, context) -> list[dict] | None.
|
||||||
|
# Registered via ``register_intent_pattern`` so plugins can extend the fallback
|
||||||
|
# mapper without touching core code (Block H / HC-A).
|
||||||
|
_CONTRIBUTED_INTENTS: list[tuple[re.Pattern[str], Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def register_intent_pattern(
|
||||||
|
pattern: str | re.Pattern[str],
|
||||||
|
handler: Any,
|
||||||
|
*,
|
||||||
|
owner: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Register a plugin-contributed intent for the fallback action mapper.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Regex (compiled or raw string) matching the user query.
|
||||||
|
handler: Callable ``(query, context) -> list[dict] | None`` producing
|
||||||
|
proposed actions when the pattern matches.
|
||||||
|
owner: Optional plugin name, used by ``unregister_intent_patterns``.
|
||||||
|
"""
|
||||||
|
compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
|
||||||
|
_CONTRIBUTED_INTENTS.append((compiled, handler))
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_intent_patterns(owner: str) -> None:
|
||||||
|
"""Remove all intents contributed by ``owner`` (plugin deactivation)."""
|
||||||
|
global _CONTRIBUTED_INTENTS
|
||||||
|
_CONTRIBUTED_INTENTS = [
|
||||||
|
entry for entry in _CONTRIBUTED_INTENTS if getattr(entry[1], "owner_tag", None) != owner
|
||||||
|
]
|
||||||
|
|
||||||
# Name extraction patterns - using single-quoted strings to avoid escaping issues
|
# Name extraction patterns - using single-quoted strings to avoid escaping issues
|
||||||
_NAME_PATTERNS = [
|
_NAME_PATTERNS = [
|
||||||
re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||||
@@ -147,6 +181,16 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Plugin-contributed intents (Block H / HC-A) ---
|
||||||
|
for pattern, handler in _CONTRIBUTED_INTENTS:
|
||||||
|
try:
|
||||||
|
if pattern.search(q):
|
||||||
|
contributed = handler(query, context)
|
||||||
|
if contributed:
|
||||||
|
actions.extend(contributed)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Contributed intent handler failed", exc_info=True)
|
||||||
|
|
||||||
# --- Generic fallback ---
|
# --- Generic fallback ---
|
||||||
if not actions:
|
if not actions:
|
||||||
if _PATTERNS["help"].search(q):
|
if _PATTERNS["help"].search(q):
|
||||||
|
|||||||
+8
-14
@@ -50,7 +50,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.plugins.builtins.ai_assistant.tool_registry import ToolRegistry
|
from app.ai.tool_registry import ToolRegistry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -394,27 +394,21 @@ async def run_react_loop(
|
|||||||
if agent_run_id:
|
if agent_run_id:
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.contracts import get_contract_registry
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
|
||||||
from sqlalchemy import select as sa_select
|
|
||||||
komm = get_contract_registry().get("kommunikation")
|
komm = get_contract_registry().get("kommunikation")
|
||||||
if komm:
|
if komm:
|
||||||
agent_id = getattr(agent_definition, "id", uuid.uuid4())
|
agent_id = getattr(agent_definition, "id", uuid.uuid4())
|
||||||
room_title = f"Agent: {getattr(agent_definition, 'name', 'Agent')}"
|
room_title = f"Agent: {getattr(agent_definition, 'name', 'Agent')}"
|
||||||
existing = await db.execute(
|
conv_id = await komm.find_locked_room_id(
|
||||||
sa_select(CommConversation).where(
|
db=db,
|
||||||
CommConversation.tenant_id == tenant_id,
|
tenant_id=tenant_id,
|
||||||
CommConversation.title == room_title,
|
plugin_name="automation",
|
||||||
CommConversation.is_locked.is_(True),
|
title=room_title,
|
||||||
CommConversation.locked_by == "automation",
|
|
||||||
CommConversation.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
conv = existing.scalar_one_or_none()
|
if conv_id:
|
||||||
if conv:
|
|
||||||
await komm.send_message(
|
await komm.send_message(
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
conversation_id=conv.id,
|
conversation_id=conv_id,
|
||||||
sender_id=agent_id,
|
sender_id=agent_id,
|
||||||
sender_type="agent",
|
sender_type="agent",
|
||||||
content=f"Approval required for tool '{tool_name}'",
|
content=f"Approval required for tool '{tool_name}'",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def _resolve_effective_tool_ids(
|
|||||||
orchestrate tools but never grant additional permissions.
|
orchestrate tools but never grant additional permissions.
|
||||||
"""
|
"""
|
||||||
from app.ai.skill_registry import get_skill_registry
|
from app.ai.skill_registry import get_skill_registry
|
||||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
|
||||||
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
|
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||||
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
|
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ async def build_agent_context(
|
|||||||
tool_descriptions: list[dict[str, str]] = []
|
tool_descriptions: list[dict[str, str]] = []
|
||||||
tool_ids = list(getattr(agent_definition, "tool_ids", None) or [])
|
tool_ids = list(getattr(agent_definition, "tool_ids", None) or [])
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
|
||||||
registry = get_tool_registry()
|
registry = get_tool_registry()
|
||||||
if tool_ids:
|
if tool_ids:
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
"""Integration tools — Agent → Workflow + Agent → Knowledge.
|
|
||||||
|
|
||||||
Registers AI tools that allow agents to start workflows and ask knowledge questions.
|
|
||||||
Builds on: workflow_service, knowledge services, tool_registry.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def register_integration_tools() -> None:
|
|
||||||
"""Register workflow + knowledge tools in the AI tool registry."""
|
|
||||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
|
||||||
registry = get_tool_registry()
|
|
||||||
|
|
||||||
# ── I-AW: Agent → Workflow ──
|
|
||||||
async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Start a workflow by ID."""
|
|
||||||
from app.services.workflow_service import create_instance
|
|
||||||
from app.core.db import get_worker_session_factory
|
|
||||||
workflow_id = arguments.get("workflow_id", "")
|
|
||||||
tenant_id = context.get("tenant_id")
|
|
||||||
user_id = context.get("user_id")
|
|
||||||
if not workflow_id or not tenant_id:
|
|
||||||
return {"error": "workflow_id and tenant_id required"}
|
|
||||||
factory = get_worker_session_factory()
|
|
||||||
async with factory() as db:
|
|
||||||
instance = await create_instance(
|
|
||||||
db=db,
|
|
||||||
tenant_id=uuid.UUID(str(tenant_id)),
|
|
||||||
workflow_id=uuid.UUID(workflow_id),
|
|
||||||
initiated_by=uuid.UUID(str(user_id)) if user_id else None,
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return {"instance_id": str(instance.get("id", "")), "status": instance.get("status", "created")}
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
name="start_workflow",
|
|
||||||
description="Start a workflow by its ID. Returns the instance ID and status.",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"workflow_id": {"type": "string", "description": "UUID of the workflow to start"},
|
|
||||||
},
|
|
||||||
"required": ["workflow_id"],
|
|
||||||
},
|
|
||||||
handler=_start_workflow_handler,
|
|
||||||
plugin_name="integration",
|
|
||||||
required_permission="workflows:read",
|
|
||||||
category="workflow",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Check the status of a workflow instance."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
from app.models.workflow import WorkflowInstance
|
|
||||||
from app.core.db import get_worker_session_factory
|
|
||||||
instance_id = arguments.get("instance_id", "")
|
|
||||||
tenant_id = context.get("tenant_id")
|
|
||||||
if not instance_id or not tenant_id:
|
|
||||||
return {"error": "instance_id and tenant_id required"}
|
|
||||||
factory = get_worker_session_factory()
|
|
||||||
async with factory() as db:
|
|
||||||
result = await db.execute(
|
|
||||||
select(WorkflowInstance).where(
|
|
||||||
WorkflowInstance.id == uuid.UUID(instance_id),
|
|
||||||
WorkflowInstance.tenant_id == uuid.UUID(str(tenant_id)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
inst = result.scalar_one_or_none()
|
|
||||||
if not inst:
|
|
||||||
return {"error": "Instance not found"}
|
|
||||||
return {
|
|
||||||
"instance_id": str(inst.id),
|
|
||||||
"status": inst.status,
|
|
||||||
"current_step": inst.current_step_index,
|
|
||||||
"completed_at": inst.completed_at.isoformat() if inst.completed_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
name="check_workflow_status",
|
|
||||||
description="Check the status of a workflow instance by its ID.",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"instance_id": {"type": "string", "description": "UUID of the workflow instance"},
|
|
||||||
},
|
|
||||||
"required": ["instance_id"],
|
|
||||||
},
|
|
||||||
handler=_check_workflow_status_handler,
|
|
||||||
plugin_name="integration",
|
|
||||||
required_permission="workflows:read",
|
|
||||||
category="workflow",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── I-AK: Agent → Knowledge ──
|
|
||||||
async def _ask_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Ask a knowledge question."""
|
|
||||||
from app.plugins.builtins.knowledge.services import ask_knowledge
|
|
||||||
from app.core.db import get_worker_session_factory
|
|
||||||
question = arguments.get("question", "")
|
|
||||||
tenant_id = context.get("tenant_id")
|
|
||||||
if not question or not tenant_id:
|
|
||||||
return {"error": "question and tenant_id required"}
|
|
||||||
factory = get_worker_session_factory()
|
|
||||||
async with factory() as db:
|
|
||||||
result = await ask_knowledge(db=db, tenant_id=uuid.UUID(str(tenant_id)), question=question)
|
|
||||||
return {"answer": result.get("answer", ""), "evidence_count": len(result.get("evidence", []))}
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
name="ask_knowledge",
|
|
||||||
description="Ask a knowledge question. Searches wiki articles and graph relationships for context.",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"question": {"type": "string", "description": "The question to ask"},
|
|
||||||
},
|
|
||||||
"required": ["question"],
|
|
||||||
},
|
|
||||||
handler=_ask_knowledge_handler,
|
|
||||||
plugin_name="integration",
|
|
||||||
required_permission="wiki:read",
|
|
||||||
category="knowledge",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _search_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Search wiki articles via unified search."""
|
|
||||||
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
|
||||||
from app.core.db import get_worker_session_factory
|
|
||||||
query = arguments.get("query", "")
|
|
||||||
tenant_id = context.get("tenant_id")
|
|
||||||
if not query or not tenant_id:
|
|
||||||
return {"error": "query and tenant_id required"}
|
|
||||||
factory = get_worker_session_factory()
|
|
||||||
async with factory() as db:
|
|
||||||
registry = get_search_registry()
|
|
||||||
wiki_provider = registry.get("wiki_article")
|
|
||||||
if not wiki_provider:
|
|
||||||
return {"error": "Wiki search provider not available"}
|
|
||||||
results = await wiki_provider._search_fts_filtered(
|
|
||||||
db=db, tsquery=query, tenant_id=uuid.UUID(str(tenant_id)), limit=5, visible_ids=None
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"results": [
|
|
||||||
{"title": r.get("title", ""), "summary": (r.get("summary") or r.get("content", "")[:200] or "")}
|
|
||||||
for r in results
|
|
||||||
],
|
|
||||||
"count": len(results),
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
name="search_knowledge",
|
|
||||||
description="Search wiki articles by keyword. Returns matching articles with title and summary.",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"query": {"type": "string", "description": "Search query"},
|
|
||||||
},
|
|
||||||
"required": ["query"],
|
|
||||||
},
|
|
||||||
handler=_search_knowledge_handler,
|
|
||||||
plugin_name="integration",
|
|
||||||
required_permission="wiki:read",
|
|
||||||
category="knowledge",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Registered integration tools: start_workflow, check_workflow_status, ask_knowledge, search_knowledge")
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Global tool registry for AI agent tools (core platform service).
|
||||||
|
|
||||||
|
Plugins register tools here so AI agents can call them during chat sessions.
|
||||||
|
Each tool declares a name, description, JSON schema for parameters,
|
||||||
|
and an async handler. Tools can optionally require specific RBAC permissions.
|
||||||
|
|
||||||
|
This registry lives in the core AI layer (not inside a plugin) so that the
|
||||||
|
agent runtime keeps working regardless of which optional plugins are active.
|
||||||
|
Plugins contribute tools via ``register()`` / ``unregister_plugin()`` during
|
||||||
|
their activate/deactivate lifecycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolHandler(Protocol):
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
context: dict[str, Any],
|
||||||
|
) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AITool:
|
||||||
|
"""Represents a tool that an AI agent can call."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: dict[str, Any] # JSON Schema for parameters
|
||||||
|
handler: ToolHandler
|
||||||
|
plugin_name: str = ""
|
||||||
|
required_permission: str | None = None # e.g. "mail:send"
|
||||||
|
category: str = "general"
|
||||||
|
|
||||||
|
def to_openai_schema(self) -> dict[str, Any]:
|
||||||
|
"""Convert to OpenAI function-calling tool schema."""
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"parameters": self.parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ToolRegistry:
|
||||||
|
"""Singleton registry for AI tools."""
|
||||||
|
|
||||||
|
_instance: ToolRegistry | None = None
|
||||||
|
|
||||||
|
def __new__(cls) -> ToolRegistry:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._tools: dict[str, AITool] = {}
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
parameters: dict[str, Any],
|
||||||
|
handler: ToolHandler,
|
||||||
|
plugin_name: str = "",
|
||||||
|
required_permission: str | None = None,
|
||||||
|
category: str = "general",
|
||||||
|
) -> None:
|
||||||
|
"""Register a tool."""
|
||||||
|
tool = AITool(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
parameters=parameters,
|
||||||
|
handler=handler,
|
||||||
|
plugin_name=plugin_name,
|
||||||
|
required_permission=required_permission,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._tools[name] = tool
|
||||||
|
logger.info("AI tool registered: %s (plugin=%s)", name, plugin_name)
|
||||||
|
|
||||||
|
def unregister(self, name: str) -> None:
|
||||||
|
"""Unregister a tool by name."""
|
||||||
|
self._tools.pop(name, None)
|
||||||
|
|
||||||
|
def unregister_plugin(self, plugin_name: str) -> None:
|
||||||
|
"""Unregister all tools from a plugin."""
|
||||||
|
to_remove = [
|
||||||
|
name for name, tool in self._tools.items() if tool.plugin_name == plugin_name
|
||||||
|
]
|
||||||
|
for name in to_remove:
|
||||||
|
self._tools.pop(name, None)
|
||||||
|
|
||||||
|
def get(self, name: str) -> AITool | None:
|
||||||
|
return self._tools.get(name)
|
||||||
|
|
||||||
|
def get_all(self) -> list[AITool]:
|
||||||
|
return list(self._tools.values())
|
||||||
|
|
||||||
|
def get_by_names(self, names: list[str]) -> list[AITool]:
|
||||||
|
return [self._tools[name] for name in names if name in self._tools]
|
||||||
|
|
||||||
|
def list_for_api(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return tool list for API response."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": tool.name,
|
||||||
|
"description": tool.description,
|
||||||
|
"parameters": tool.parameters,
|
||||||
|
"plugin_name": tool.plugin_name,
|
||||||
|
"required_permission": tool.required_permission,
|
||||||
|
"category": tool.category,
|
||||||
|
}
|
||||||
|
for tool in self._tools.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_registry() -> ToolRegistry:
|
||||||
|
"""Get the global tool registry singleton."""
|
||||||
|
return ToolRegistry()
|
||||||
@@ -46,6 +46,15 @@ def get_job(name: str) -> JobFunc | None:
|
|||||||
return _registry.get(name)
|
return _registry.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_job(name: str) -> None:
|
||||||
|
"""Remove a registered job function (plugin deactivation lifecycle).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The job name to remove.
|
||||||
|
"""
|
||||||
|
_registry.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
def get_all_jobs() -> list[JobFunc]:
|
def get_all_jobs() -> list[JobFunc]:
|
||||||
"""Return all registered job functions (order is insertion order).
|
"""Return all registered job functions (order is insertion order).
|
||||||
|
|
||||||
|
|||||||
+6
-47
@@ -445,51 +445,9 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
|
|||||||
register_job("cleanup_trash", cleanup_trash_job)
|
register_job("cleanup_trash", cleanup_trash_job)
|
||||||
|
|
||||||
|
|
||||||
# ── Knowledge retention cleanup job ─────────────────────────────────────────
|
# Note: knowledge retention cleanup ("cleanup_knowledge") lives with the
|
||||||
|
# knowledge plugin (app/plugins/builtins/knowledge/jobs.py) and is discovered
|
||||||
async def cleanup_knowledge_job(ctx: dict[str, Any]) -> None:
|
# via the plugin job-module mechanism — no core→plugin import.
|
||||||
"""Delete old knowledge extractions (rejected or auto_created) older than 90 days.
|
|
||||||
|
|
||||||
Runs daily. Keeps approved extractions indefinitely.
|
|
||||||
Iterates per-tenant for RLS compliance.
|
|
||||||
"""
|
|
||||||
from sqlalchemy import text as sa_text, delete as sa_delete
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from app.core.db import get_worker_session_factory
|
|
||||||
from app.plugins.builtins.knowledge.models import KnowledgeExtraction
|
|
||||||
|
|
||||||
factory = get_worker_session_factory()
|
|
||||||
async with factory() as db:
|
|
||||||
try:
|
|
||||||
tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
|
|
||||||
tenant_ids = [row[0] for row in tenant_result]
|
|
||||||
|
|
||||||
cutoff = datetime.utcnow() - timedelta(days=90)
|
|
||||||
total_deleted = 0
|
|
||||||
for tenant_id in tenant_ids:
|
|
||||||
await db.execute(
|
|
||||||
sa_text("SELECT set_config('app.current_tenant_id', :tid, true)"),
|
|
||||||
{"tid": str(tenant_id)},
|
|
||||||
)
|
|
||||||
# Delete rejected and auto_created extractions older than 90 days
|
|
||||||
result = await db.execute(
|
|
||||||
sa_delete(KnowledgeExtraction).where(
|
|
||||||
KnowledgeExtraction.status.in_(["rejected", "auto_created"]),
|
|
||||||
KnowledgeExtraction.created_at < cutoff,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
total_deleted += result.rowcount
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
if total_deleted:
|
|
||||||
logger.info("Knowledge retention: cleaned up %d old extractions", total_deleted)
|
|
||||||
except Exception:
|
|
||||||
logger.error("Knowledge retention cleanup failed", exc_info=True)
|
|
||||||
await db.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
register_job("cleanup_knowledge", cleanup_knowledge_job)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerSettings:
|
class WorkerSettings:
|
||||||
@@ -531,9 +489,10 @@ class WorkerSettings:
|
|||||||
_wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300),
|
_wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300),
|
||||||
hour=4, minute=0,
|
hour=4, minute=0,
|
||||||
),
|
),
|
||||||
# Knowledge retention cleanup — daily at 05:00 (90 days, keeps approved)
|
# Knowledge retention cleanup — daily at 05:00 (90 days, keeps approved).
|
||||||
|
# Function comes from the knowledge plugin via the job registry.
|
||||||
cron(
|
cron(
|
||||||
_wrap_cron_with_lock("cleanup_knowledge", cleanup_knowledge_job, ttl_seconds=300),
|
_wrap_cron_with_lock("cleanup_knowledge", get_job("cleanup_knowledge"), ttl_seconds=300),
|
||||||
hour=5, minute=0,
|
hour=5, minute=0,
|
||||||
),
|
),
|
||||||
# Scheduled backup — daily at 02:00 (guarded by distributed lock)
|
# Scheduled backup — daily at 02:00 (guarded by distributed lock)
|
||||||
|
|||||||
@@ -1,121 +1,19 @@
|
|||||||
"""Global tool registry for AI Assistant plugin tools.
|
"""Compatibility shim — the tool registry moved to the core AI layer.
|
||||||
|
|
||||||
Plugins can register tools that AI agents can call during chat sessions.
|
The agent runtime must not depend on this optional plugin being active,
|
||||||
Each tool declares a name, description, JSON schema for parameters,
|
so ``ToolRegistry`` / ``AITool`` / ``get_tool_registry`` now live in
|
||||||
and an async handler. Tools can optionally require specific RBAC permissions.
|
``app.ai.tool_registry``. Import from here still works for existing
|
||||||
|
plugin code; new code should import from ``app.ai.tool_registry``
|
||||||
|
directly (or via the ai_assistant contract).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from app.ai.tool_registry import ( # noqa: F401
|
||||||
from dataclasses import dataclass
|
AITool,
|
||||||
from typing import Any, Protocol
|
ToolHandler,
|
||||||
|
ToolRegistry,
|
||||||
|
get_tool_registry,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
__all__ = ["AITool", "ToolHandler", "ToolRegistry", "get_tool_registry"]
|
||||||
|
|
||||||
|
|
||||||
class ToolHandler(Protocol):
|
|
||||||
async def __call__(
|
|
||||||
self,
|
|
||||||
arguments: dict[str, Any],
|
|
||||||
context: dict[str, Any],
|
|
||||||
) -> str: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AITool:
|
|
||||||
"""Represents a tool that an AI agent can call."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
parameters: dict[str, Any] # JSON Schema for parameters
|
|
||||||
handler: ToolHandler
|
|
||||||
plugin_name: str = ""
|
|
||||||
required_permission: str | None = None # e.g. "mail:send"
|
|
||||||
category: str = "general"
|
|
||||||
|
|
||||||
def to_openai_schema(self) -> dict[str, Any]:
|
|
||||||
"""Convert to OpenAI function-calling tool schema."""
|
|
||||||
return {
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": self.name,
|
|
||||||
"description": self.description,
|
|
||||||
"parameters": self.parameters,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ToolRegistry:
|
|
||||||
"""Singleton registry for AI tools."""
|
|
||||||
|
|
||||||
_instance: ToolRegistry | None = None
|
|
||||||
|
|
||||||
def __new__(cls) -> ToolRegistry:
|
|
||||||
if cls._instance is None:
|
|
||||||
cls._instance = super().__new__(cls)
|
|
||||||
cls._instance._tools: dict[str, AITool] = {}
|
|
||||||
return cls._instance
|
|
||||||
|
|
||||||
def register(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
description: str,
|
|
||||||
parameters: dict[str, Any],
|
|
||||||
handler: ToolHandler,
|
|
||||||
plugin_name: str = "",
|
|
||||||
required_permission: str | None = None,
|
|
||||||
category: str = "general",
|
|
||||||
) -> None:
|
|
||||||
"""Register a tool."""
|
|
||||||
tool = AITool(
|
|
||||||
name=name,
|
|
||||||
description=description,
|
|
||||||
parameters=parameters,
|
|
||||||
handler=handler,
|
|
||||||
plugin_name=plugin_name,
|
|
||||||
required_permission=required_permission,
|
|
||||||
category=category,
|
|
||||||
)
|
|
||||||
self._tools[name] = tool
|
|
||||||
logger.info("AI tool registered: %s (plugin=%s)", name, plugin_name)
|
|
||||||
|
|
||||||
def unregister(self, name: str) -> None:
|
|
||||||
"""Unregister a tool by name."""
|
|
||||||
self._tools.pop(name, None)
|
|
||||||
|
|
||||||
def unregister_plugin(self, plugin_name: str) -> None:
|
|
||||||
"""Unregister all tools from a plugin."""
|
|
||||||
to_remove = [
|
|
||||||
name for name, tool in self._tools.items() if tool.plugin_name == plugin_name
|
|
||||||
]
|
|
||||||
for name in to_remove:
|
|
||||||
self._tools.pop(name, None)
|
|
||||||
|
|
||||||
def get(self, name: str) -> AITool | None:
|
|
||||||
return self._tools.get(name)
|
|
||||||
|
|
||||||
def get_all(self) -> list[AITool]:
|
|
||||||
return list(self._tools.values())
|
|
||||||
|
|
||||||
def get_by_names(self, names: list[str]) -> list[AITool]:
|
|
||||||
return [self._tools[name] for name in names if name in self._tools]
|
|
||||||
|
|
||||||
def list_for_api(self) -> list[dict[str, Any]]:
|
|
||||||
"""Return tool list for API response."""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"name": tool.name,
|
|
||||||
"description": tool.description,
|
|
||||||
"parameters": tool.parameters,
|
|
||||||
"plugin_name": tool.plugin_name,
|
|
||||||
"required_permission": tool.required_permission,
|
|
||||||
"category": tool.category,
|
|
||||||
}
|
|
||||||
for tool in self._tools.values()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_tool_registry() -> ToolRegistry:
|
|
||||||
"""Get the global tool registry singleton."""
|
|
||||||
return ToolRegistry()
|
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ class AutomationContract:
|
|||||||
# ─── agent_comm ───
|
# ─── agent_comm ───
|
||||||
send_agent_message = staticmethod(send_agent_message)
|
send_agent_message = staticmethod(send_agent_message)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_function(cls, name: str):
|
||||||
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
return getattr(cls, name, None)
|
||||||
|
|
||||||
|
|
||||||
# ─── self-registration ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
|||||||
@@ -210,12 +210,11 @@ class AutomationPlugin(BasePlugin):
|
|||||||
register_agent_coordinator_tools()
|
register_agent_coordinator_tools()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to register agent coordinator tools")
|
logger.exception("Failed to register agent coordinator tools")
|
||||||
# Register integration tools (I-AW: Agent→Workflow, I-AK: Agent→Knowledge)
|
# Register workflow agent tools (I-AW: Agent→Workflow)
|
||||||
try:
|
try:
|
||||||
from app.ai.integration_tools import register_integration_tools
|
self._register_workflow_agent_tools()
|
||||||
register_integration_tools()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to register integration tools")
|
logger.exception("Failed to register workflow agent tools")
|
||||||
# Register MiniApps from manifest
|
# Register MiniApps from manifest
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
|
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
|
||||||
@@ -286,6 +285,94 @@ class AutomationPlugin(BasePlugin):
|
|||||||
|
|
||||||
logger.info("Automation plugin activated")
|
logger.info("Automation plugin activated")
|
||||||
|
|
||||||
|
def _register_workflow_agent_tools(self) -> None:
|
||||||
|
"""Register I-AW agent tools for starting and inspecting workflows."""
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
registry = get_tool_registry()
|
||||||
|
|
||||||
|
async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Start a workflow by ID."""
|
||||||
|
from app.services.workflow_service import create_instance
|
||||||
|
from app.core.db import get_worker_session_factory
|
||||||
|
workflow_id = arguments.get("workflow_id", "")
|
||||||
|
tenant_id = context.get("tenant_id")
|
||||||
|
user_id = context.get("user_id")
|
||||||
|
if not workflow_id or not tenant_id:
|
||||||
|
return {"error": "workflow_id and tenant_id required"}
|
||||||
|
factory = get_worker_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
instance = await create_instance(
|
||||||
|
db=db,
|
||||||
|
tenant_id=uuid.UUID(str(tenant_id)),
|
||||||
|
workflow_id=uuid.UUID(workflow_id),
|
||||||
|
initiated_by=uuid.UUID(str(user_id)) if user_id else None,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"instance_id": str(instance.get("id", "")), "status": instance.get("status", "created")}
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="start_workflow",
|
||||||
|
description="Start a workflow by its ID. Returns the instance ID and status.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"workflow_id": {"type": "string", "description": "UUID of the workflow to start"},
|
||||||
|
},
|
||||||
|
"required": ["workflow_id"],
|
||||||
|
},
|
||||||
|
handler=_start_workflow_handler,
|
||||||
|
plugin_name=self.manifest.name,
|
||||||
|
required_permission="workflows:read",
|
||||||
|
category="workflow",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Check the status of a workflow instance."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.workflow import WorkflowInstance
|
||||||
|
from app.core.db import get_worker_session_factory
|
||||||
|
instance_id = arguments.get("instance_id", "")
|
||||||
|
tenant_id = context.get("tenant_id")
|
||||||
|
if not instance_id or not tenant_id:
|
||||||
|
return {"error": "instance_id and tenant_id required"}
|
||||||
|
factory = get_worker_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(WorkflowInstance).where(
|
||||||
|
WorkflowInstance.id == uuid.UUID(instance_id),
|
||||||
|
WorkflowInstance.tenant_id == uuid.UUID(str(tenant_id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inst = result.scalar_one_or_none()
|
||||||
|
if not inst:
|
||||||
|
return {"error": "Instance not found"}
|
||||||
|
return {
|
||||||
|
"instance_id": str(inst.id),
|
||||||
|
"status": inst.status,
|
||||||
|
"current_step": inst.current_step_index,
|
||||||
|
"completed_at": inst.completed_at.isoformat() if inst.completed_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="check_workflow_status",
|
||||||
|
description="Check the status of a workflow instance by its ID.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"instance_id": {"type": "string", "description": "UUID of the workflow instance"},
|
||||||
|
},
|
||||||
|
"required": ["instance_id"],
|
||||||
|
},
|
||||||
|
handler=_check_workflow_status_handler,
|
||||||
|
plugin_name=self.manifest.name,
|
||||||
|
required_permission="workflows:read",
|
||||||
|
category="workflow",
|
||||||
|
)
|
||||||
|
logger.info("Registered workflow agent tools: start_workflow, check_workflow_status")
|
||||||
|
|
||||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||||
"""Clean up on deactivation."""
|
"""Clean up on deactivation."""
|
||||||
# Contract abmelden
|
# Contract abmelden
|
||||||
@@ -307,6 +394,13 @@ class AutomationPlugin(BasePlugin):
|
|||||||
unregister_agent_coordinator_tools()
|
unregister_agent_coordinator_tools()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to unregister agent coordinator tools")
|
logger.exception("Failed to unregister agent coordinator tools")
|
||||||
|
# Unregister workflow agent tools from the core AI tool registry
|
||||||
|
try:
|
||||||
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
get_tool_registry().unregister_plugin(self.manifest.name)
|
||||||
|
logger.info("Unregistered AI agent tools for plugin '%s'", self.manifest.name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unregister AI agent tools")
|
||||||
# Unregister MiniApps
|
# Unregister MiniApps
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
|
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class CalendarContract:
|
|||||||
CalendarEntry = CalendarEntry
|
CalendarEntry = CalendarEntry
|
||||||
CalendarEntryLink = CalendarEntryLink
|
CalendarEntryLink = CalendarEntryLink
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_function(cls, name: str):
|
||||||
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
return getattr(cls, name, None)
|
||||||
|
|
||||||
|
|
||||||
# ─── self-registration ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class DmsContract:
|
|||||||
DmsFile = DmsFile
|
DmsFile = DmsFile
|
||||||
Folder = Folder
|
Folder = Folder
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_function(cls, name: str):
|
||||||
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
return getattr(cls, name, None)
|
||||||
|
|
||||||
|
|
||||||
# ─── self-registration ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""ARQ background jobs for the knowledge plugin.
|
||||||
|
|
||||||
|
Registered via ``register_job()`` at import time; the worker discovers this
|
||||||
|
module through ``KnowledgePlugin.get_job_modules()`` — no core imports of
|
||||||
|
plugin models needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.job_registry import register_job
|
||||||
|
from app.plugins.builtins.knowledge.models import KnowledgeExtraction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_KNOWLEDGE_RETENTION_DAYS = 90
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_knowledge_job(ctx: dict[str, Any]) -> None:
|
||||||
|
"""Delete old knowledge extractions (rejected or auto_created) older than 90 days.
|
||||||
|
|
||||||
|
Runs daily. Keeps approved extractions indefinitely.
|
||||||
|
Iterates per-tenant for RLS compliance.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text as sa_text, delete as sa_delete
|
||||||
|
|
||||||
|
from app.core.db import get_worker_session_factory
|
||||||
|
|
||||||
|
factory = get_worker_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
try:
|
||||||
|
tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
|
||||||
|
tenant_ids = [row[0] for row in tenant_result]
|
||||||
|
|
||||||
|
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=_KNOWLEDGE_RETENTION_DAYS)
|
||||||
|
total_deleted = 0
|
||||||
|
for tenant_id in tenant_ids:
|
||||||
|
await db.execute(
|
||||||
|
sa_text("SELECT set_config('app.current_tenant_id', :tid, true)"),
|
||||||
|
{"tid": str(tenant_id)},
|
||||||
|
)
|
||||||
|
# Delete rejected and auto_created extractions older than retention window
|
||||||
|
result = await db.execute(
|
||||||
|
sa_delete(KnowledgeExtraction).where(
|
||||||
|
KnowledgeExtraction.status.in_(["rejected", "auto_created"]),
|
||||||
|
KnowledgeExtraction.created_at < cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_deleted += result.rowcount
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if total_deleted:
|
||||||
|
logger.info("Knowledge retention: cleaned up %d old extractions", total_deleted)
|
||||||
|
except Exception:
|
||||||
|
logger.error("Knowledge retention cleanup failed", exc_info=True)
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
register_job("cleanup_knowledge", cleanup_knowledge_job)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Knowledge plugin — LLM-based entity/relationship extraction, ask-knowledge, review queue."""
|
"""Knowledge plugin — LLM-based entity/relationship extraction, ask-knowledge, review queue."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||||
|
|
||||||
@@ -59,9 +61,101 @@ class KnowledgePlugin(BasePlugin):
|
|||||||
logger.info("Registered knowledge extraction hooks")
|
logger.info("Registered knowledge extraction hooks")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to register knowledge hooks")
|
logger.exception("Failed to register knowledge hooks")
|
||||||
|
# Register knowledge agent tools (I-AK: Agent→Knowledge)
|
||||||
|
try:
|
||||||
|
self._register_knowledge_agent_tools()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to register knowledge agent tools")
|
||||||
|
|
||||||
|
def _register_knowledge_agent_tools(self) -> None:
|
||||||
|
"""Register I-AK agent tools for asking and searching knowledge."""
|
||||||
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
registry = get_tool_registry()
|
||||||
|
|
||||||
|
async def _ask_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Ask a knowledge question."""
|
||||||
|
from app.plugins.builtins.knowledge.services import ask_knowledge
|
||||||
|
from app.core.db import get_worker_session_factory
|
||||||
|
question = arguments.get("question", "")
|
||||||
|
tenant_id = context.get("tenant_id")
|
||||||
|
if not question or not tenant_id:
|
||||||
|
return {"error": "question and tenant_id required"}
|
||||||
|
factory = get_worker_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
result = await ask_knowledge(db=db, tenant_id=uuid.UUID(str(tenant_id)), question=question)
|
||||||
|
return {"answer": result.get("answer", ""), "evidence_count": len(result.get("evidence", []))}
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="ask_knowledge",
|
||||||
|
description="Ask a knowledge question. Searches wiki articles and graph relationships for context.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"question": {"type": "string", "description": "The question to ask"},
|
||||||
|
},
|
||||||
|
"required": ["question"],
|
||||||
|
},
|
||||||
|
handler=_ask_knowledge_handler,
|
||||||
|
plugin_name=self.manifest.name,
|
||||||
|
required_permission="wiki:read",
|
||||||
|
category="knowledge",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _search_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Search wiki articles via unified search."""
|
||||||
|
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||||
|
from app.core.db import get_worker_session_factory
|
||||||
|
query = arguments.get("query", "")
|
||||||
|
tenant_id = context.get("tenant_id")
|
||||||
|
if not query or not tenant_id:
|
||||||
|
return {"error": "query and tenant_id required"}
|
||||||
|
factory = get_worker_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
search_registry = get_search_registry()
|
||||||
|
wiki_provider = search_registry.get("wiki_article")
|
||||||
|
if not wiki_provider:
|
||||||
|
return {"error": "Wiki search provider not available"}
|
||||||
|
results = await wiki_provider._search_fts_filtered(
|
||||||
|
db=db, tsquery=query, tenant_id=uuid.UUID(str(tenant_id)), limit=5, visible_ids=None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{"title": r.get("title", ""), "summary": (r.get("summary") or r.get("content", "")[:200] or "")}
|
||||||
|
for r in results
|
||||||
|
],
|
||||||
|
"count": len(results),
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="search_knowledge",
|
||||||
|
description="Search wiki articles by keyword. Returns matching articles with title and summary.",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Search query"},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
handler=_search_knowledge_handler,
|
||||||
|
plugin_name=self.manifest.name,
|
||||||
|
required_permission="wiki:read",
|
||||||
|
category="knowledge",
|
||||||
|
)
|
||||||
|
logger.info("Registered knowledge agent tools: ask_knowledge, search_knowledge")
|
||||||
|
|
||||||
|
def get_job_modules(self) -> list[str]:
|
||||||
|
"""ARQ job modules — the retention cleanup job lives with this plugin."""
|
||||||
|
return ["app.plugins.builtins.knowledge.jobs"]
|
||||||
|
|
||||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||||
"""Clean up on deactivation."""
|
"""Clean up on deactivation."""
|
||||||
from app.core.hooks import unregister_actions_by_owner
|
from app.core.hooks import unregister_actions_by_owner
|
||||||
unregister_actions_by_owner("knowledge")
|
unregister_actions_by_owner("knowledge")
|
||||||
|
# Unregister knowledge agent tools from the core AI tool registry
|
||||||
|
try:
|
||||||
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
get_tool_registry().unregister_plugin(self.manifest.name)
|
||||||
|
logger.info("Unregistered AI agent tools for plugin '%s'", self.manifest.name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unregister AI agent tools")
|
||||||
await super().on_deactivate(db, service_container, event_bus)
|
await super().on_deactivate(db, service_container, event_bus)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from app.plugins.builtins.kommunikation.participant_registry import (
|
|||||||
)
|
)
|
||||||
from app.plugins.builtins.kommunikation.services import (
|
from app.plugins.builtins.kommunikation.services import (
|
||||||
create_plugin_room,
|
create_plugin_room,
|
||||||
|
find_locked_room_id,
|
||||||
get_conversation,
|
get_conversation,
|
||||||
get_messages,
|
get_messages,
|
||||||
parse_mentions,
|
parse_mentions,
|
||||||
@@ -55,6 +56,7 @@ class KommunikationContract:
|
|||||||
get_messages = staticmethod(get_messages)
|
get_messages = staticmethod(get_messages)
|
||||||
send_message = staticmethod(send_message)
|
send_message = staticmethod(send_message)
|
||||||
create_plugin_room = staticmethod(create_plugin_room)
|
create_plugin_room = staticmethod(create_plugin_room)
|
||||||
|
find_locked_room_id = staticmethod(find_locked_room_id)
|
||||||
|
|
||||||
# ─── participant registry ───
|
# ─── participant registry ───
|
||||||
get_participant_registry = staticmethod(get_participant_registry)
|
get_participant_registry = staticmethod(get_participant_registry)
|
||||||
@@ -92,6 +94,7 @@ __all__ = [
|
|||||||
"get_messages",
|
"get_messages",
|
||||||
"send_message",
|
"send_message",
|
||||||
"create_plugin_room",
|
"create_plugin_room",
|
||||||
|
"find_locked_room_id",
|
||||||
"CommConversation",
|
"CommConversation",
|
||||||
"CommMessage",
|
"CommMessage",
|
||||||
"CommParticipant",
|
"CommParticipant",
|
||||||
|
|||||||
@@ -1047,6 +1047,30 @@ async def _get_unread_count(
|
|||||||
# ─── Plugin Room Creation ───
|
# ─── Plugin Room Creation ───
|
||||||
|
|
||||||
|
|
||||||
|
async def find_locked_room_id(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
plugin_name: str,
|
||||||
|
title: str,
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
"""Find the conversation ID of a locked plugin room by tenant and title.
|
||||||
|
|
||||||
|
Matches the same room semantics as ``create_plugin_room``: locked rooms
|
||||||
|
are owned by the plugin (``locked_by == plugin_name``) and soft-deleted
|
||||||
|
conversations are excluded. Returns ``None`` when no room exists.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(CommConversation.id).where(
|
||||||
|
CommConversation.tenant_id == tenant_id,
|
||||||
|
CommConversation.title == title,
|
||||||
|
CommConversation.is_locked.is_(True),
|
||||||
|
CommConversation.locked_by == plugin_name,
|
||||||
|
CommConversation.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def create_plugin_room(
|
async def create_plugin_room(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class MailContract:
|
|||||||
# ─── models ───
|
# ─── models ───
|
||||||
Mail = Mail
|
Mail = Mail
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_function(cls, name: str):
|
||||||
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
return getattr(cls, name, None)
|
||||||
|
|
||||||
|
|
||||||
# ─── self-registration ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,33 @@ from app.plugins.builtins.unified_search.query_understanding import llm_analyze_
|
|||||||
from app.plugins.builtins.unified_search.search_engine import find_similar_all_types, hybrid_search
|
from app.plugins.builtins.unified_search.search_engine import find_similar_all_types, hybrid_search
|
||||||
|
|
||||||
|
|
||||||
|
async def simple_search(
|
||||||
|
db: Any,
|
||||||
|
query: str,
|
||||||
|
tenant_id: Any,
|
||||||
|
entity_types: list[str] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
user_id: Any | None = None,
|
||||||
|
is_system_admin: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Convenience search entry point: analyze a raw query string and run
|
||||||
|
the hybrid search over all registered providers.
|
||||||
|
|
||||||
|
Falls back to a plain normalized-query analysis when the LLM is
|
||||||
|
unavailable.
|
||||||
|
"""
|
||||||
|
analysis = await llm_analyze_query(query, db=db, tenant_id=tenant_id)
|
||||||
|
return await hybrid_search(
|
||||||
|
db=db,
|
||||||
|
query_analysis=analysis,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
entity_types=entity_types,
|
||||||
|
limit=limit,
|
||||||
|
user_id=user_id,
|
||||||
|
is_system_admin=is_system_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UnifiedSearchContract:
|
class UnifiedSearchContract:
|
||||||
"""Public contract for the unified_search plugin."""
|
"""Public contract for the unified_search plugin."""
|
||||||
|
|
||||||
@@ -20,8 +47,14 @@ class UnifiedSearchContract:
|
|||||||
find_similar_all_types = staticmethod(find_similar_all_types)
|
find_similar_all_types = staticmethod(find_similar_all_types)
|
||||||
get_search_registry = staticmethod(get_search_registry)
|
get_search_registry = staticmethod(get_search_registry)
|
||||||
llm_analyze_query = staticmethod(llm_analyze_query)
|
llm_analyze_query = staticmethod(llm_analyze_query)
|
||||||
|
simple_search = staticmethod(simple_search)
|
||||||
BaseSearchProvider = BaseSearchProvider
|
BaseSearchProvider = BaseSearchProvider
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_function(cls, name: str):
|
||||||
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
return getattr(cls, name, None)
|
||||||
|
|
||||||
|
|
||||||
# ─── self-registration ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,21 @@ from app.core.db import get_db
|
|||||||
from app.deps import require_permission
|
from app.deps import require_permission
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
from app.models.compliance import ComplianceIncident
|
from app.models.compliance import ComplianceIncident
|
||||||
from app.plugins.builtins.automation.models import AgentDefinition
|
from app.plugins.builtins.contracts import get_contract as _get_automation_contract
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/compliance", tags=["compliance"])
|
router = APIRouter(prefix="/api/v1/compliance", tags=["compliance"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_agent_definition_model():
|
||||||
|
"""Resolve the AgentDefinition model via the automation contract.
|
||||||
|
|
||||||
|
Returns ``None`` when the automation plugin is not active — callers
|
||||||
|
respond with 503 instead of failing at import time.
|
||||||
|
"""
|
||||||
|
contract = _get_automation_contract("automation")
|
||||||
|
return getattr(contract, "AgentDefinition", None) if contract else None
|
||||||
|
|
||||||
|
|
||||||
# ─── Schemas ───
|
# ─── Schemas ───
|
||||||
|
|
||||||
|
|
||||||
@@ -160,13 +170,17 @@ async def list_ai_registry(
|
|||||||
"""List all AI agents with their use-case metadata. Admin only."""
|
"""List all AI agents with their use-case metadata. Admin only."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
|
||||||
|
agent_model = _get_agent_definition_model()
|
||||||
|
if agent_model is None:
|
||||||
|
raise HTTPException(503, detail={"detail": "Automation plugin not active", "code": "plugin_inactive"})
|
||||||
|
|
||||||
q = (
|
q = (
|
||||||
select(AgentDefinition)
|
select(agent_model)
|
||||||
.where(
|
.where(
|
||||||
AgentDefinition.tenant_id == tenant_id,
|
agent_model.tenant_id == tenant_id,
|
||||||
AgentDefinition.deleted_at.is_(None),
|
agent_model.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.order_by(AgentDefinition.name)
|
.order_by(agent_model.name)
|
||||||
)
|
)
|
||||||
result = await db.execute(q)
|
result = await db.execute(q)
|
||||||
agents = result.scalars().all()
|
agents = result.scalars().all()
|
||||||
@@ -214,10 +228,14 @@ async def get_dpia_template(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, detail={"detail": "Invalid agent_id", "code": "invalid_id"}) from None
|
raise HTTPException(400, detail={"detail": "Invalid agent_id", "code": "invalid_id"}) from None
|
||||||
|
|
||||||
q = select(AgentDefinition).where(
|
agent_model = _get_agent_definition_model()
|
||||||
AgentDefinition.id == aid,
|
if agent_model is None:
|
||||||
AgentDefinition.tenant_id == tenant_id,
|
raise HTTPException(503, detail={"detail": "Automation plugin not active", "code": "plugin_inactive"})
|
||||||
AgentDefinition.deleted_at.is_(None),
|
|
||||||
|
q = select(agent_model).where(
|
||||||
|
agent_model.id == aid,
|
||||||
|
agent_model.tenant_id == tenant_id,
|
||||||
|
agent_model.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
result = await db.execute(q)
|
result = await db.execute(q)
|
||||||
agent = result.scalar_one_or_none()
|
agent = result.scalar_one_or_none()
|
||||||
|
|||||||
+6
-14
@@ -92,22 +92,16 @@ class WorkflowEngine:
|
|||||||
# Post workflow completion to Communication (G-WORK)
|
# Post workflow completion to Communication (G-WORK)
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.contracts import get_contract_registry
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
|
||||||
from sqlalchemy import select as sa_select
|
|
||||||
komm = get_contract_registry().get("kommunikation")
|
komm = get_contract_registry().get("kommunikation")
|
||||||
if komm and instance.initiated_by:
|
if komm and instance.initiated_by:
|
||||||
room_title = f"Workflow: {workflow.name if hasattr(workflow, 'name') else str(instance.workflow_id)}"
|
room_title = f"Workflow: {workflow.name if hasattr(workflow, 'name') else str(instance.workflow_id)}"
|
||||||
existing = await self.db.execute(
|
conv_id = await komm.find_locked_room_id(
|
||||||
sa_select(CommConversation).where(
|
db=self.db,
|
||||||
CommConversation.tenant_id == self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
CommConversation.title == room_title,
|
plugin_name="workflow",
|
||||||
CommConversation.is_locked.is_(True),
|
title=room_title,
|
||||||
CommConversation.locked_by == "workflow",
|
|
||||||
CommConversation.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
conv = existing.scalar_one_or_none()
|
if not conv_id:
|
||||||
if not conv:
|
|
||||||
room = await komm.create_plugin_room(
|
room = await komm.create_plugin_room(
|
||||||
db=self.db,
|
db=self.db,
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
@@ -117,8 +111,6 @@ class WorkflowEngine:
|
|||||||
participant_type="workflow",
|
participant_type="workflow",
|
||||||
)
|
)
|
||||||
conv_id = uuid.UUID(room["conversation_id"])
|
conv_id = uuid.UUID(room["conversation_id"])
|
||||||
else:
|
|
||||||
conv_id = conv.id
|
|
||||||
await komm.send_message(
|
await komm.send_message(
|
||||||
db=self.db,
|
db=self.db,
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ def register_step_type(step_type: str):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_step_type(step_type: str) -> None:
|
||||||
|
"""Remove a step handler (plugin deactivation lifecycle symmetry)."""
|
||||||
|
_HANDLERS.pop(step_type, None)
|
||||||
|
|
||||||
|
|
||||||
def get_step_handler(step_type: str) -> StepHandler | None:
|
def get_step_handler(step_type: str) -> StepHandler | None:
|
||||||
return _HANDLERS.get(step_type)
|
return _HANDLERS.get(step_type)
|
||||||
|
|
||||||
@@ -218,11 +223,13 @@ async def _handle_mail(
|
|||||||
return StepResult(error="mail step requires to and subject", abort=True)
|
return StepResult(error="mail step requires to and subject", abort=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.mail.contracts import MailContract
|
from app.plugins.builtins.contracts import get_contract
|
||||||
contract = MailContract
|
contract = get_contract("mail")
|
||||||
|
if contract is None:
|
||||||
|
return StepResult(error="mail plugin not available", abort=True)
|
||||||
send_fn = contract.get_function("send_email")
|
send_fn = contract.get_function("send_email")
|
||||||
if send_fn is None:
|
if send_fn is None:
|
||||||
return StepResult(error="mail plugin not available", abort=True)
|
return StepResult(error="mail send_email not exposed via contract yet", abort=True)
|
||||||
|
|
||||||
result = await send_fn(
|
result = await send_fn(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -258,12 +265,14 @@ async def _handle_calendar(
|
|||||||
action = config.get("action", "create")
|
action = config.get("action", "create")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.calendar.contracts import CalendarContract
|
from app.plugins.builtins.contracts import get_contract
|
||||||
contract = CalendarContract
|
contract = get_contract("calendar")
|
||||||
|
if contract is None:
|
||||||
|
return StepResult(error="calendar plugin not available", abort=True)
|
||||||
if action == "create":
|
if action == "create":
|
||||||
fn = contract.get_function("create_event")
|
fn = contract.get_function("create_event")
|
||||||
if fn is None:
|
if fn is None:
|
||||||
return StepResult(error="calendar plugin not available", abort=True)
|
return StepResult(error="calendar create_event not exposed via contract yet", abort=True)
|
||||||
result = await fn(
|
result = await fn(
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -275,7 +284,7 @@ async def _handle_calendar(
|
|||||||
elif action == "delete":
|
elif action == "delete":
|
||||||
fn = contract.get_function("delete_event")
|
fn = contract.get_function("delete_event")
|
||||||
if fn is None:
|
if fn is None:
|
||||||
return StepResult(error="calendar plugin not available", abort=True)
|
return StepResult(error="calendar delete_event not exposed via contract yet", abort=True)
|
||||||
await fn(db=db, tenant_id=tenant_id, event_id=config.get("event_id", ""))
|
await fn(db=db, tenant_id=tenant_id, event_id=config.get("event_id", ""))
|
||||||
return StepResult()
|
return StepResult()
|
||||||
else:
|
else:
|
||||||
@@ -303,19 +312,21 @@ async def _handle_dms(
|
|||||||
action = config.get("action", "search")
|
action = config.get("action", "search")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.dms.contracts import DmsContract
|
from app.plugins.builtins.contracts import get_contract
|
||||||
contract = DmsContract
|
contract = get_contract("dms")
|
||||||
|
if contract is None:
|
||||||
|
return StepResult(error="dms plugin not available", abort=True)
|
||||||
|
|
||||||
if action == "search":
|
if action == "search":
|
||||||
fn = contract.get_function("search_files")
|
fn = contract.get_function("search_files")
|
||||||
if fn is None:
|
if fn is None:
|
||||||
return StepResult(error="dms plugin not available", abort=True)
|
return StepResult(error="dms search_files not exposed via contract yet", abort=True)
|
||||||
results = await fn(db=db, tenant_id=tenant_id, query=config.get("query", ""))
|
results = await fn(db=db, tenant_id=tenant_id, query=config.get("query", ""))
|
||||||
return StepResult(output={"files": results} if results else {})
|
return StepResult(output={"files": results} if results else {})
|
||||||
elif action == "metadata":
|
elif action == "metadata":
|
||||||
fn = contract.get_function("get_file_metadata")
|
fn = contract.get_function("get_file_metadata")
|
||||||
if fn is None:
|
if fn is None:
|
||||||
return StepResult(error="dms plugin not available", abort=True)
|
return StepResult(error="dms get_file_metadata not exposed via contract yet", abort=True)
|
||||||
metadata = await fn(db=db, tenant_id=tenant_id, file_id=config.get("file_id", ""))
|
metadata = await fn(db=db, tenant_id=tenant_id, file_id=config.get("file_id", ""))
|
||||||
return StepResult(output={"metadata": metadata} if metadata else {})
|
return StepResult(output={"metadata": metadata} if metadata else {})
|
||||||
else:
|
else:
|
||||||
@@ -348,17 +359,16 @@ async def _handle_search(
|
|||||||
return StepResult(error="search step requires query", abort=True)
|
return StepResult(error="search step requires query", abort=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.unified_search.contracts import SearchContract
|
from app.plugins.builtins.contracts import get_contract
|
||||||
contract = SearchContract
|
contract = get_contract("unified_search")
|
||||||
fn = contract.get_function("unified_search")
|
if contract is None:
|
||||||
if fn is None:
|
|
||||||
return StepResult(error="search plugin not available", abort=True)
|
return StepResult(error="search plugin not available", abort=True)
|
||||||
results = await fn(
|
results = await contract.simple_search(
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
|
||||||
query=query,
|
query=query,
|
||||||
entity_type=entity_type,
|
tenant_id=tenant_id,
|
||||||
limit=limit,
|
entity_types=[entity_type] if entity_type else None,
|
||||||
|
limit=int(limit),
|
||||||
)
|
)
|
||||||
# Store results in context for later steps
|
# Store results in context for later steps
|
||||||
instance.context["search_results"] = results
|
instance.context["search_results"] = results
|
||||||
@@ -391,17 +401,21 @@ async def _handle_agent(
|
|||||||
return StepResult(error="agent step requires agent_id", abort=True)
|
return StepResult(error="agent step requires agent_id", abort=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.automation.contracts import AutomationContract
|
from app.plugins.builtins.contracts import get_contract
|
||||||
contract = AutomationContract
|
contract = get_contract("automation")
|
||||||
|
if contract is None:
|
||||||
|
return StepResult(error="automation plugin not available", abort=True)
|
||||||
fn = contract.get_function("run_agent")
|
fn = contract.get_function("run_agent")
|
||||||
if fn is None:
|
if fn is None:
|
||||||
return StepResult(error="automation plugin not available", abort=True)
|
return StepResult(error="automation run_agent not exposed via contract yet", abort=True)
|
||||||
|
# run_agent is an ARQ job function: it opens its own DB session and runs
|
||||||
|
# the agent loop to completion, so ``wait`` always ends up true here.
|
||||||
|
logger.debug("agent step wait_for_completion=%s (step always waits)", wait)
|
||||||
result = await fn(
|
result = await fn(
|
||||||
db=db,
|
{},
|
||||||
tenant_id=tenant_id,
|
str(agent_id),
|
||||||
agent_id=uuid.UUID(agent_id),
|
trigger_type="workflow",
|
||||||
input_data=user_input,
|
trigger_data={"input": user_input},
|
||||||
wait_for_completion=wait,
|
|
||||||
)
|
)
|
||||||
instance.context["agent_result"] = result
|
instance.context["agent_result"] = result
|
||||||
return StepResult(output={"agent_result": result} if result else {})
|
return StepResult(output={"agent_result": result} if result else {})
|
||||||
|
|||||||
@@ -6,14 +6,8 @@ import ImageBlock from './ImageBlock';
|
|||||||
import AudioBlock from './AudioBlock';
|
import AudioBlock from './AudioBlock';
|
||||||
import VideoBlock from './VideoBlock';
|
import VideoBlock from './VideoBlock';
|
||||||
import FileBlock from './FileBlock';
|
import FileBlock from './FileBlock';
|
||||||
import ActionCardBlock from './ActionCardBlock';
|
import { getBlockComponent } from './registry';
|
||||||
import ContactCardBlock from './ContactCardBlock';
|
import './registrations'; // plugin-contributed block registrations
|
||||||
import MiniAppBlock from './MiniAppBlock';
|
|
||||||
import AgentResultBlock from './AgentResultBlock';
|
|
||||||
import ApprovalRequestBlock from './ApprovalRequestBlock';
|
|
||||||
import TaskCardBlock from './TaskCardBlock';
|
|
||||||
import WorkflowCardBlock from './WorkflowCardBlock';
|
|
||||||
import KnowledgeCardBlock from './KnowledgeCardBlock';
|
|
||||||
|
|
||||||
interface BlockRendererProps {
|
interface BlockRendererProps {
|
||||||
blocks: MessageBlock[];
|
blocks: MessageBlock[];
|
||||||
@@ -51,29 +45,20 @@ const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
|
|||||||
return <VideoBlock block={block} />;
|
return <VideoBlock block={block} />;
|
||||||
case 'file':
|
case 'file':
|
||||||
return <FileBlock block={block} />;
|
return <FileBlock block={block} />;
|
||||||
case 'action_card':
|
default: {
|
||||||
return <ActionCardBlock block={block} />;
|
// Plugin-contributed block types (Block H / HC-F)
|
||||||
case 'contact_card':
|
const Contributed = getBlockComponent(block.block_type);
|
||||||
return <ContactCardBlock block={block} />;
|
if (Contributed) {
|
||||||
case 'miniapp':
|
const ContributedBlock = Contributed as React.FC<{ block: MessageBlock }>;
|
||||||
return <MiniAppBlock block={block} />;
|
return <ContributedBlock block={block} />;
|
||||||
case 'agent_result':
|
}
|
||||||
return <AgentResultBlock block={block} />;
|
|
||||||
case 'approval_request':
|
|
||||||
return <ApprovalRequestBlock block={block} />;
|
|
||||||
case 'task_card':
|
|
||||||
return <TaskCardBlock block={block} />;
|
|
||||||
case 'workflow_card':
|
|
||||||
return <WorkflowCardBlock block={block} />;
|
|
||||||
case 'knowledge_card':
|
|
||||||
return <KnowledgeCardBlock block={block} />;
|
|
||||||
default:
|
|
||||||
// Fallback for unknown block types
|
// Fallback for unknown block types
|
||||||
return (
|
return (
|
||||||
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
|
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
|
||||||
Unbekannter Block-Typ: {block.block_type}
|
Unbekannter Block-Typ: {block.block_type}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Plugin block registrations.
|
||||||
|
*
|
||||||
|
* Currently a static import list; the dynamic plugin loader (ARCH-019 fix)
|
||||||
|
* will replace this file with manifest-driven registration. Plugins register
|
||||||
|
* their block renderers via ``registerBlockType`` with an owner tag so that
|
||||||
|
* ``unregisterBlockTypes(owner)`` can clean up on deactivation.
|
||||||
|
*/
|
||||||
|
import { registerBlockType } from './registry';
|
||||||
|
import ActionCardBlock from './ActionCardBlock';
|
||||||
|
import ContactCardBlock from './ContactCardBlock';
|
||||||
|
import MiniAppBlock from './MiniAppBlock';
|
||||||
|
import AgentResultBlock from './AgentResultBlock';
|
||||||
|
import ApprovalRequestBlock from './ApprovalRequestBlock';
|
||||||
|
import TaskCardBlock from './TaskCardBlock';
|
||||||
|
import WorkflowCardBlock from './WorkflowCardBlock';
|
||||||
|
import KnowledgeCardBlock from './KnowledgeCardBlock';
|
||||||
|
|
||||||
|
registerBlockType('action_card', ActionCardBlock, 'core-comm');
|
||||||
|
registerBlockType('contact_card', ContactCardBlock, 'contacts');
|
||||||
|
registerBlockType('miniapp', MiniAppBlock, 'kommunikation');
|
||||||
|
registerBlockType('agent_result', AgentResultBlock, 'automation');
|
||||||
|
registerBlockType('approval_request', ApprovalRequestBlock, 'core-approvals');
|
||||||
|
registerBlockType('task_card', TaskCardBlock, 'tasks');
|
||||||
|
registerBlockType('workflow_card', WorkflowCardBlock, 'workflows');
|
||||||
|
registerBlockType('knowledge_card', KnowledgeCardBlock, 'knowledge');
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Message block type registry.
|
||||||
|
*
|
||||||
|
* Core owns the base block types; plugins can contribute additional block
|
||||||
|
* renderers via ``registerBlockType`` / ``unregisterBlockTypes`` during their
|
||||||
|
* frontend activation lifecycle (Block H / HC-F).
|
||||||
|
*/
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { MessageBlock } from '@/store/commStore';
|
||||||
|
|
||||||
|
export type BlockComponent = ComponentType<{ block: MessageBlock }>;
|
||||||
|
|
||||||
|
const _registry = new Map<string, BlockComponent>();
|
||||||
|
|
||||||
|
export function registerBlockType(blockType: string, component: BlockComponent, owner = ''): void {
|
||||||
|
if (_registry.has(blockType)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`[blockRegistry] block type '${blockType}' re-registered by '${owner || 'unknown'}' — overwriting`);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(component as any).__block_owner__ = owner;
|
||||||
|
_registry.set(blockType, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterBlockTypes(owner: string): void {
|
||||||
|
for (const [key, component] of _registry.entries()) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if ((component as any).__block_owner__ === owner) {
|
||||||
|
_registry.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlockComponent(blockType: string): BlockComponent | undefined {
|
||||||
|
return _registry.get(blockType);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { SuggestionList } from '@/components/ai/SuggestionSidebar';
|
|||||||
import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
|
import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
|
||||||
import { createSession, fetchSessions } from '@/api/ai';
|
import { createSession, fetchSessions } from '@/api/ai';
|
||||||
import { useUIStore } from '@/store/uiStore';
|
import { useUIStore } from '@/store/uiStore';
|
||||||
|
import { getContributedTabs } from './sidebarTabs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useUsers, useGroups } from '@/api/hooks';
|
import { useUsers, useGroups } from '@/api/hooks';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
@@ -35,7 +36,7 @@ const chevronRightIcon = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
key: 'proactive' | 'chat' | 'notifications' | 'team' | 'chatroom';
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: (cls: string) => React.ReactNode;
|
icon: (cls: string) => React.ReactNode;
|
||||||
testId: string;
|
testId: string;
|
||||||
@@ -139,6 +140,13 @@ export function AISidebar() {
|
|||||||
{ key: 'notifications', label: t('topbar.notifications'), icon: bellIcon, testId: 'ai-sidebar-tab-notifications' },
|
{ key: 'notifications', label: t('topbar.notifications'), icon: bellIcon, testId: 'ai-sidebar-tab-notifications' },
|
||||||
{ key: 'team', label: 'Team', icon: teamIcon, testId: 'ai-sidebar-tab-team' },
|
{ key: 'team', label: 'Team', icon: teamIcon, testId: 'ai-sidebar-tab-team' },
|
||||||
{ key: 'chatroom', label: 'Chat', icon: chatBubbleIcon, testId: 'ai-sidebar-tab-chatroom' },
|
{ key: 'chatroom', label: 'Chat', icon: chatBubbleIcon, testId: 'ai-sidebar-tab-chatroom' },
|
||||||
|
// Plugin-contributed tabs (Block H / HC-G)
|
||||||
|
...getContributedTabs().map((ct) => ({
|
||||||
|
key: ct.key,
|
||||||
|
label: ct.label,
|
||||||
|
icon: ct.icon,
|
||||||
|
testId: ct.testId,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeTab = tabs.find((tab) => tab.key === aiSidebarTab) || tabs[0];
|
const activeTab = tabs.find((tab) => tab.key === aiSidebarTab) || tabs[0];
|
||||||
@@ -209,6 +217,12 @@ export function AISidebar() {
|
|||||||
if (aiSidebarTab === 'chatroom') {
|
if (aiSidebarTab === 'chatroom') {
|
||||||
return <ChatPanel />;
|
return <ChatPanel />;
|
||||||
}
|
}
|
||||||
|
// Plugin-contributed tabs (Block H / HC-G)
|
||||||
|
const contributed = getContributedTabs().find((ct) => ct.key === aiSidebarTab);
|
||||||
|
if (contributed) {
|
||||||
|
const ContributedComponent = contributed.component;
|
||||||
|
return <ContributedComponent />;
|
||||||
|
}
|
||||||
// chat tab
|
// chat tab
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* AI sidebar tab registry.
|
||||||
|
*
|
||||||
|
* Core owns the base tabs (chat/proactive/notifications/team/chatroom);
|
||||||
|
* plugins contribute additional tabs via ``registerSidebarTab`` during their
|
||||||
|
* frontend activation lifecycle (Block H / HC-G). Contributed tabs render a
|
||||||
|
* generic container component provided by the plugin.
|
||||||
|
*/
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
type IconFn = (className: string) => JSX.Element;
|
||||||
|
|
||||||
|
export interface ContributedTab {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: IconFn;
|
||||||
|
testId: string;
|
||||||
|
component: ComponentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _tabs = new Map<string, ContributedTab>();
|
||||||
|
|
||||||
|
export function registerSidebarTab(tab: ContributedTab): void {
|
||||||
|
if (_tabs.has(tab.key)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`[sidebarTabs] tab '${tab.key}' re-registered — overwriting`);
|
||||||
|
}
|
||||||
|
_tabs.set(tab.key, tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterSidebarTabs(owner: string): void {
|
||||||
|
for (const [key, tab] of _tabs.entries()) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if ((tab.component as any).__sidebar_owner__ === owner) {
|
||||||
|
_tabs.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContributedTabs(): ContributedTab[] {
|
||||||
|
return Array.from(_tabs.values());
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import { create } from 'zustand';
|
|||||||
|
|
||||||
export type Theme = 'light' | 'dark' | 'system';
|
export type Theme = 'light' | 'dark' | 'system';
|
||||||
export type Locale = 'de' | 'en';
|
export type Locale = 'de' | 'en';
|
||||||
export type AISidebarTab = 'proactive' | 'chat' | 'notifications' | 'team' | 'chatroom';
|
// Plugin-contributed tabs add arbitrary keys (Block H / HC-G) — the union
|
||||||
|
// keeps autocomplete for the base tabs while allowing any string key.
|
||||||
|
export type AISidebarTab = 'proactive' | 'chat' | 'notifications' | 'team' | 'chatroom' | (string & {});
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Gate H — prove that a plugin contributes ALL extension points without
|
||||||
|
changing a single core file.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. Agent tool -> app.ai.tool_registry (core layer)
|
||||||
|
2. Workflow step type -> app.workflows.step_handlers
|
||||||
|
3. Fallback intent -> app.ai.action_mapper
|
||||||
|
4. Deactivate symmetry -> everything removed again
|
||||||
|
5. Re-activate -> present again, NO duplicates
|
||||||
|
|
||||||
|
The plugin under test is defined INLINE in this file — if this test passes,
|
||||||
|
no core file had to be touched to extend the platform.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ─── The Gate-H test plugin (inline — zero core changes) ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _build_gate_h_plugin():
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.manifest import PluginManifest
|
||||||
|
|
||||||
|
class GateHTestPlugin(BasePlugin):
|
||||||
|
"""Minimal plugin exercising every Block-H contribution point."""
|
||||||
|
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name="gate_h_test",
|
||||||
|
version="1.0.0",
|
||||||
|
display_name="Gate H Test",
|
||||||
|
description="Proves plugin contributions without core changes.",
|
||||||
|
dependencies=[],
|
||||||
|
routes=[],
|
||||||
|
events=[],
|
||||||
|
migrations=[],
|
||||||
|
permissions=["gate_h_test:read"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||||
|
await super().on_activate(db, service_container, event_bus)
|
||||||
|
|
||||||
|
# 1) Agent tool via CORE registry
|
||||||
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
async def _echo_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||||
|
return f"gate-h says: {arguments.get('text', '')}"
|
||||||
|
|
||||||
|
get_tool_registry().register(
|
||||||
|
name="gate_h_echo",
|
||||||
|
description="Echo text back (Gate H proof).",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
handler=_echo_handler,
|
||||||
|
plugin_name=self.manifest.name,
|
||||||
|
required_permission=None,
|
||||||
|
category="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) Workflow step type
|
||||||
|
from app.workflows.step_handlers import StepResult, register_step_type
|
||||||
|
|
||||||
|
@register_step_type("gate_h_noop")
|
||||||
|
async def _handle_gate_h_noop(db, tenant_id, instance, step): # noqa: ANN001
|
||||||
|
return StepResult(output={"gate_h": True})
|
||||||
|
|
||||||
|
# 3) Fallback intent pattern
|
||||||
|
from app.ai.action_mapper import register_intent_pattern
|
||||||
|
|
||||||
|
def _invoice_intent(query: str, context: dict[str, Any] | None):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/gate-h/invoices",
|
||||||
|
"body": {},
|
||||||
|
"description": "Create invoice (Gate H proof)",
|
||||||
|
"confidence": 0.9,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
_invoice_intent.owner_tag = self.manifest.name
|
||||||
|
register_intent_pattern(r"\b(create|new)\b.*\binvoice\b", _invoice_intent, owner=self.manifest.name)
|
||||||
|
|
||||||
|
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||||
|
# Symmetry: remove everything registered above
|
||||||
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
from app.workflows.step_handlers import unregister_step_type
|
||||||
|
from app.ai.action_mapper import unregister_intent_patterns
|
||||||
|
|
||||||
|
get_tool_registry().unregister_plugin(self.manifest.name)
|
||||||
|
unregister_step_type("gate_h_noop")
|
||||||
|
unregister_intent_patterns(self.manifest.name)
|
||||||
|
await super().on_deactivate(db, service_container, event_bus)
|
||||||
|
|
||||||
|
return GateHTestPlugin
|
||||||
|
|
||||||
|
|
||||||
|
# ─── The proof ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gate_h_full_contribution_lifecycle():
|
||||||
|
"""Activate -> contributed everywhere; deactivate -> gone; reactivate -> back, no dupes."""
|
||||||
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
from app.workflows.step_handlers import get_step_handler
|
||||||
|
from app.ai.action_mapper import map_query_to_actions
|
||||||
|
|
||||||
|
plugin_cls = _build_gate_h_plugin()
|
||||||
|
plugin = plugin_cls()
|
||||||
|
|
||||||
|
# ── Before activation: nothing registered ──
|
||||||
|
assert get_tool_registry().get("gate_h_echo") is None
|
||||||
|
assert get_step_handler("gate_h_noop") is None
|
||||||
|
assert map_query_to_actions("create invoice for Acme") == []
|
||||||
|
|
||||||
|
# ── Activate ──
|
||||||
|
await plugin.on_activate(db=None, service_container=None, event_bus=None)
|
||||||
|
|
||||||
|
# 1) Agent tool present in CORE registry
|
||||||
|
tool = get_tool_registry().get("gate_h_echo")
|
||||||
|
assert tool is not None
|
||||||
|
assert tool.plugin_name == "gate_h_test"
|
||||||
|
result = await tool.handler({"text": "hello"}, {})
|
||||||
|
assert result == "gate-h says: hello"
|
||||||
|
|
||||||
|
# 2) Workflow step type resolvable
|
||||||
|
handler = get_step_handler("gate_h_noop")
|
||||||
|
assert handler is not None
|
||||||
|
|
||||||
|
# 3) Intent contributes actions for a FACHMODUL term unknown to core
|
||||||
|
actions = map_query_to_actions("create invoice for Acme")
|
||||||
|
assert len(actions) == 1
|
||||||
|
assert actions[0]["path"] == "/api/v1/gate-h/invoices"
|
||||||
|
|
||||||
|
# ── Deactivate: full symmetry ──
|
||||||
|
await plugin.on_deactivate(db=None, service_container=None, event_bus=None)
|
||||||
|
assert get_tool_registry().get("gate_h_echo") is None
|
||||||
|
assert get_step_handler("gate_h_noop") is None
|
||||||
|
assert map_query_to_actions("create invoice for Acme") == []
|
||||||
|
|
||||||
|
# ── Re-activate: back AND no duplicates ──
|
||||||
|
await plugin.on_activate(db=None, service_container=None, event_bus=None)
|
||||||
|
tool = get_tool_registry().get("gate_h_echo")
|
||||||
|
assert tool is not None # single entry (dict keyed by name — overwrite, not dupe)
|
||||||
|
assert get_step_handler("gate_h_noop") is not None
|
||||||
|
actions = map_query_to_actions("create invoice for Acme")
|
||||||
|
assert len(actions) == 1 # exactly one contributed intent, not two
|
||||||
|
|
||||||
|
# Cleanup so other tests are unaffected
|
||||||
|
await plugin.on_deactivate(db=None, service_container=None, event_bus=None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gate_h_agent_runtime_survives_ai_assistant_absence():
|
||||||
|
"""The core tool registry works even though ai_assistant plugin module is never imported."""
|
||||||
|
from app.ai.tool_registry import ToolRegistry, get_tool_registry
|
||||||
|
|
||||||
|
reg = get_tool_registry()
|
||||||
|
assert isinstance(reg, ToolRegistry)
|
||||||
|
# And it must be THE same singleton the shim exposes
|
||||||
|
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry as shim_get
|
||||||
|
|
||||||
|
assert shim_get() is reg
|
||||||
Reference in New Issue
Block a user