cleanup: remove all unconnected Phase I+J scaffold code and unconnected Phase F+H modules (workstream_contract, proactive_feed, dashboard, dsgvo_export, onboarding, mcp_exposure, integration_tools, self_improvement, agent_workstream, workflows/workstream, knowledge_sources, knowledge_extraction, knowledge_lifecycle, platform.py routes, 13 frontend files, 2 test files), restore Dashboard.tsx, tsc clean, backend OK
This commit is contained in:
@@ -1,201 +0,0 @@
|
||||
"""Agent → Workstream integration.
|
||||
|
||||
Posts agent messages to the central communication system (kommunikation plugin).
|
||||
Supports text, action_card, entity_card, task_card, approval, and miniapp block types.
|
||||
All agent messages are marked as AI-generated via transparency metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.transparency import mark_as_ai_generated
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def post_agent_message(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
agent_run_id: uuid.UUID,
|
||||
content: str,
|
||||
block_type: str = "text",
|
||||
block_data: dict[str, Any] | None = None,
|
||||
conversation_id: uuid.UUID | None = None,
|
||||
) -> uuid.UUID:
|
||||
"""Post a message from an agent to the communication system.
|
||||
|
||||
Creates a CommMessage in the agent's conversation channel.
|
||||
If no conversation_id is provided, uses the agent's default channel.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
tenant_id: Tenant ID.
|
||||
agent_id: Agent definition ID.
|
||||
agent_run_id: Agent run ID for traceability.
|
||||
content: Message text content.
|
||||
block_type: Block type (text, action_card, entity_card, task_card, approval, miniapp).
|
||||
block_data: Additional block data (e.g. action buttons, entity reference).
|
||||
conversation_id: Optional conversation to post to. If None, uses agent channel.
|
||||
|
||||
Returns:
|
||||
Message ID.
|
||||
"""
|
||||
from app.plugins.builtins.kommunikation.models import CommMessage, CommMessageBlock
|
||||
from app.plugins.builtins.kommunikation.services import send_message
|
||||
|
||||
# Mark as AI-generated
|
||||
ai_metadata = mark_as_ai_generated(content, {
|
||||
"agent_id": str(agent_id),
|
||||
"agent_run_id": str(agent_run_id),
|
||||
})
|
||||
|
||||
# Build block if not plain text
|
||||
blocks: list[dict[str, Any]] = []
|
||||
if block_type != "text" and block_data:
|
||||
blocks.append({
|
||||
"type": block_type,
|
||||
"data": block_data,
|
||||
})
|
||||
|
||||
# Post via kommunikation service
|
||||
message_id = await send_message(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
sender_id=agent_id,
|
||||
sender_type="agent",
|
||||
conversation_id=conversation_id or await _get_or_create_agent_channel(db, tenant_id, agent_id),
|
||||
content=content,
|
||||
blocks=blocks,
|
||||
metadata=ai_metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Agent %s posted message %s (block_type=%s, run=%s)",
|
||||
agent_id, message_id, block_type, agent_run_id,
|
||||
)
|
||||
return message_id
|
||||
|
||||
|
||||
async def _get_or_create_agent_channel(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, agent_id: uuid.UUID
|
||||
) -> uuid.UUID:
|
||||
"""Get or create a dedicated conversation channel for an agent."""
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
||||
|
||||
# Try to find existing agent channel
|
||||
result = await db.execute(
|
||||
select(CommConversation).where(
|
||||
CommConversation.tenant_id == tenant_id,
|
||||
CommConversation.entity_type == "agent",
|
||||
CommConversation.entity_id == agent_id,
|
||||
CommConversation.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if conv:
|
||||
return conv.id
|
||||
|
||||
# Create new channel
|
||||
conv = CommConversation(
|
||||
tenant_id=tenant_id, entity_type="agent",
|
||||
entity_id=agent_id,
|
||||
title=f"Agent Channel",
|
||||
conversation_type="channel",
|
||||
is_system=False,
|
||||
)
|
||||
db.add(conv)
|
||||
await db.flush()
|
||||
return conv.id
|
||||
|
||||
|
||||
async def post_agent_step(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
agent_run_id: uuid.UUID,
|
||||
step_number: int,
|
||||
thought: str,
|
||||
action: str | None = None,
|
||||
observation: str | None = None,
|
||||
) -> uuid.UUID | None:
|
||||
"""Post a ReAct step as an action_card to the workstream.
|
||||
|
||||
Only posts if the agent's trace_mode is 'extended'.
|
||||
"""
|
||||
block_data = {
|
||||
"step_number": step_number,
|
||||
"thought": thought[:500], # Truncate for display
|
||||
"action": action,
|
||||
"observation": (observation or "")[:500],
|
||||
}
|
||||
return await post_agent_message(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
agent_run_id=agent_run_id,
|
||||
content=f"Step {step_number}: {action or 'Thinking...'}",
|
||||
block_type="action_card",
|
||||
block_data=block_data,
|
||||
)
|
||||
|
||||
|
||||
async def post_agent_result(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
agent_run_id: uuid.UUID,
|
||||
final_content: str,
|
||||
total_cost_usd: float,
|
||||
steps_taken: int,
|
||||
status: str,
|
||||
) -> uuid.UUID:
|
||||
"""Post the final result of an agent run to the workstream."""
|
||||
block_data = {
|
||||
"status": status,
|
||||
"steps_taken": steps_taken,
|
||||
"total_cost_usd": round(total_cost_usd, 6),
|
||||
"run_id": str(agent_run_id),
|
||||
}
|
||||
return await post_agent_message(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
agent_run_id=agent_run_id,
|
||||
content=final_content,
|
||||
block_type="action_card",
|
||||
block_data=block_data,
|
||||
)
|
||||
|
||||
|
||||
async def post_approval_request(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
agent_run_id: uuid.UUID,
|
||||
approval_id: uuid.UUID,
|
||||
action: str,
|
||||
description: str,
|
||||
) -> uuid.UUID:
|
||||
"""Post an approval request card to the workstream."""
|
||||
block_data = {
|
||||
"approval_id": str(approval_id),
|
||||
"action": action,
|
||||
"description": description,
|
||||
"status": "pending",
|
||||
}
|
||||
return await post_agent_message(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
agent_run_id=agent_run_id,
|
||||
content=f"Approval required: {action}",
|
||||
block_type="approval",
|
||||
block_data=block_data,
|
||||
)
|
||||
@@ -1,300 +0,0 @@
|
||||
"""Platform dashboard & analytics (I-DASH, I-COST, I-USE).
|
||||
|
||||
Provides aggregated metrics for:
|
||||
- Agent status, workflow stats, search metrics, knowledge coverage
|
||||
- LLM cost tracking per agent/workflow/user, budget alerts
|
||||
- Feature usage, search queries, agent runs, workflow executions
|
||||
- Proactive suggestions accepted/rejected
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── I-DASH: Platform Dashboard ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_platform_dashboard(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Get aggregated platform metrics for the dashboard (I-DASH).
|
||||
|
||||
Returns: Agent status, workflow stats, search metrics,
|
||||
knowledge coverage, workstream metrics, system health.
|
||||
"""
|
||||
dashboard: dict[str, Any] = {
|
||||
"agents": {},
|
||||
"workflows": {},
|
||||
"search": {},
|
||||
"knowledge": {},
|
||||
"workstream": {},
|
||||
"system_health": {},
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
# Agent metrics
|
||||
try:
|
||||
from app.models.workflow import AgentDefinition, AgentRun
|
||||
active_agents = await db.scalar(
|
||||
select(func.count(AgentDefinition.id)).where(
|
||||
AgentDefinition.tenant_id == tenant_id,
|
||||
AgentDefinition.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
total_runs = await db.scalar(
|
||||
select(func.count(AgentRun.id)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
recent_runs = await db.scalar(
|
||||
select(func.count(AgentRun.id)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= datetime.now(UTC) - timedelta(days=7),
|
||||
)
|
||||
)
|
||||
dashboard["agents"] = {
|
||||
"active_agents": active_agents or 0,
|
||||
"total_runs": total_runs or 0,
|
||||
"recent_runs_7d": recent_runs or 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Dashboard agent metrics failed: %s", e)
|
||||
dashboard["agents"] = {"error": str(e)}
|
||||
|
||||
# Workflow metrics
|
||||
try:
|
||||
from app.models.workflow import WorkflowDefinition, WorkflowInstance
|
||||
active_workflows = await db.scalar(
|
||||
select(func.count(WorkflowDefinition.id)).where(
|
||||
WorkflowDefinition.tenant_id == tenant_id,
|
||||
WorkflowDefinition.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
running_instances = await db.scalar(
|
||||
select(func.count(WorkflowInstance.id)).where(
|
||||
WorkflowInstance.tenant_id == tenant_id,
|
||||
WorkflowInstance.status.in_(["pending", "running", "waiting"]),
|
||||
)
|
||||
)
|
||||
completed_instances = await db.scalar(
|
||||
select(func.count(WorkflowInstance.id)).where(
|
||||
WorkflowInstance.tenant_id == tenant_id,
|
||||
WorkflowInstance.status == "completed",
|
||||
)
|
||||
)
|
||||
dashboard["workflows"] = {
|
||||
"active_workflows": active_workflows or 0,
|
||||
"running_instances": running_instances or 0,
|
||||
"completed_instances": completed_instances or 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Dashboard workflow metrics failed: %s", e)
|
||||
dashboard["workflows"] = {"error": str(e)}
|
||||
|
||||
# Knowledge metrics
|
||||
try:
|
||||
from app.plugins.builtins.wiki.models import WikiArticle
|
||||
wiki_articles = await db.scalar(
|
||||
select(func.count(WikiArticle.id)).where(
|
||||
WikiArticle.tenant_id == tenant_id,
|
||||
WikiArticle.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
dashboard["knowledge"] = {
|
||||
"wiki_articles": wiki_articles or 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Dashboard knowledge metrics failed: %s", e)
|
||||
dashboard["knowledge"] = {"error": str(e)}
|
||||
|
||||
# System health (from Redis check)
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
redis = await get_redis()
|
||||
if redis:
|
||||
await redis.ping()
|
||||
dashboard["system_health"] = {"redis": "up", "status": "healthy"}
|
||||
else:
|
||||
dashboard["system_health"] = {"redis": "down", "status": "degraded"}
|
||||
except Exception as e:
|
||||
dashboard["system_health"] = {"redis": "error", "status": "degraded", "error": str(e)}
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
# ─── I-COST: Cost Tracking Dashboard ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_cost_dashboard(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
days: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
"""Get LLM cost tracking metrics (I-COST).
|
||||
|
||||
Returns: Live costs, budget utilization, alert history,
|
||||
cost per tenant/agent/workflow, hard-stop events.
|
||||
"""
|
||||
since = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
cost_data: dict[str, Any] = {
|
||||
"period_days": days,
|
||||
"total_cost_usd": 0.0,
|
||||
"by_agent": {},
|
||||
"by_workflow": {},
|
||||
"by_user": {},
|
||||
"daily_trend": [],
|
||||
"budget": {},
|
||||
"alerts": [],
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
# Aggregate costs from AgentRun
|
||||
try:
|
||||
from app.models.workflow import AgentRun
|
||||
|
||||
# Total cost
|
||||
total_cost = await db.scalar(
|
||||
select(func.sum(AgentRun.total_cost_usd)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since,
|
||||
)
|
||||
)
|
||||
cost_data["total_cost_usd"] = float(total_cost or 0.0)
|
||||
|
||||
# Cost by agent
|
||||
agent_costs = await db.execute(
|
||||
select(
|
||||
AgentRun.agent_id,
|
||||
func.sum(AgentRun.total_cost_usd).label("cost"),
|
||||
func.count(AgentRun.id).label("runs"),
|
||||
)
|
||||
.where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since,
|
||||
)
|
||||
.group_by(AgentRun.agent_id)
|
||||
)
|
||||
for row in agent_costs:
|
||||
cost_data["by_agent"][str(row.agent_id)] = {
|
||||
"cost_usd": float(row.cost or 0.0),
|
||||
"runs": row.runs,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Cost dashboard failed: %s", e)
|
||||
cost_data["error"] = str(e)
|
||||
|
||||
# Budget info from config
|
||||
try:
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
monthly_budget = getattr(settings, "llm_monthly_budget_usd", None)
|
||||
if monthly_budget:
|
||||
cost_data["budget"] = {
|
||||
"monthly_limit_usd": monthly_budget,
|
||||
"utilization_pct": (cost_data["total_cost_usd"] / monthly_budget) * 100,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return cost_data
|
||||
|
||||
|
||||
# ─── I-USE: Usage & Collaboration Analytics ─────────────────────────────────
|
||||
|
||||
|
||||
async def get_usage_analytics(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
days: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
"""Get feature usage and collaboration analytics (I-USE).
|
||||
|
||||
Returns: Feature usage, search queries, agent runs,
|
||||
workflow executions, proactive suggestions accepted/rejected.
|
||||
"""
|
||||
since = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
analytics: dict[str, Any] = {
|
||||
"period_days": days,
|
||||
"agent_runs": {},
|
||||
"workflow_executions": {},
|
||||
"search_queries": {},
|
||||
"proactive_suggestions": {},
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
# Agent run stats
|
||||
try:
|
||||
from app.models.workflow import AgentRun
|
||||
total_runs = await db.scalar(
|
||||
select(func.count(AgentRun.id)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since,
|
||||
)
|
||||
)
|
||||
completed_runs = await db.scalar(
|
||||
select(func.count(AgentRun.id)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since,
|
||||
AgentRun.status == "completed",
|
||||
)
|
||||
)
|
||||
failed_runs = await db.scalar(
|
||||
select(func.count(AgentRun.id)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since,
|
||||
AgentRun.status.in_(["stopped_error", "stopped_timeout"]),
|
||||
)
|
||||
)
|
||||
analytics["agent_runs"] = {
|
||||
"total": total_runs or 0,
|
||||
"completed": completed_runs or 0,
|
||||
"failed": failed_runs or 0,
|
||||
"success_rate": (completed_runs / total_runs * 100) if total_runs else 0.0,
|
||||
}
|
||||
except Exception as e:
|
||||
analytics["agent_runs"] = {"error": str(e)}
|
||||
|
||||
# Workflow execution stats
|
||||
try:
|
||||
from app.models.workflow import WorkflowInstance
|
||||
total_instances = await db.scalar(
|
||||
select(func.count(WorkflowInstance.id)).where(
|
||||
WorkflowInstance.tenant_id == tenant_id,
|
||||
WorkflowInstance.created_at >= since,
|
||||
)
|
||||
)
|
||||
completed_instances = await db.scalar(
|
||||
select(func.count(WorkflowInstance.id)).where(
|
||||
WorkflowInstance.tenant_id == tenant_id,
|
||||
WorkflowInstance.created_at >= since,
|
||||
WorkflowInstance.status == "completed",
|
||||
)
|
||||
)
|
||||
analytics["workflow_executions"] = {
|
||||
"total": total_instances or 0,
|
||||
"completed": completed_instances or 0,
|
||||
}
|
||||
except Exception as e:
|
||||
analytics["workflow_executions"] = {"error": str(e)}
|
||||
|
||||
return analytics
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_platform_dashboard",
|
||||
"get_cost_dashboard",
|
||||
"get_usage_analytics",
|
||||
]
|
||||
@@ -1,346 +0,0 @@
|
||||
"""DSGVO-Betroffenenrechte & Compliance Export (I-DSGVO, I-DSAR, I-COMP-EXPORT).
|
||||
|
||||
Provides:
|
||||
- Full platform data subject access export (JSON/ZIP)
|
||||
- Data subject rights workflow (access/correction/erasure/restriction)
|
||||
- AI/Compliance evidence export (audit, oversight, approval records)
|
||||
|
||||
Sensitive/Exposure rules are always respected. No blind auto-delete
|
||||
over legal retention obligations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── I-DSGVO: Platform Data Subject Access Export ───────────────────────────
|
||||
|
||||
|
||||
async def export_user_data(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Export all personal data for a user across core and active plugins (I-DSGVO).
|
||||
|
||||
Collects data from: CRM (contacts, companies), Mail, Calendar, DMS,
|
||||
Communication/Workstreams, Agents, Workflows, Knowledge, Audit.
|
||||
|
||||
Returns structured JSON ready for ZIP packaging.
|
||||
Sensitive fields are masked per data_policy rules.
|
||||
"""
|
||||
export: dict[str, Any] = {
|
||||
"export_metadata": {
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"export_type": "dsgvo_data_subject_access",
|
||||
"version": "1.0",
|
||||
},
|
||||
"core": {},
|
||||
"mail": {},
|
||||
"calendar": {},
|
||||
"dms": {},
|
||||
"communication": {},
|
||||
"agents": {},
|
||||
"workflows": {},
|
||||
"knowledge": {},
|
||||
"audit": {},
|
||||
}
|
||||
|
||||
# Core: User profile
|
||||
try:
|
||||
from app.models.user import User
|
||||
user = await db.get(User, user_id)
|
||||
if user:
|
||||
export["core"]["user"] = {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"full_name": getattr(user, "full_name", None),
|
||||
"is_active": user.is_active,
|
||||
"is_system_admin": getattr(user, "is_system_admin", False),
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
except Exception as e:
|
||||
export["core"]["error"] = str(e)
|
||||
|
||||
# Core: Contacts owned by user
|
||||
try:
|
||||
from app.models.contact import Contact
|
||||
result = await db.execute(
|
||||
select(Contact).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.owner_id == user_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
contacts = result.scalars().all()
|
||||
export["core"]["contacts"] = [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"first_name": c.first_name,
|
||||
"last_name": c.last_name,
|
||||
"email": c.email,
|
||||
"phone": c.phone,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
except Exception as e:
|
||||
export["core"]["contacts_error"] = str(e)
|
||||
|
||||
# Agents: Agent runs by user
|
||||
try:
|
||||
from app.models.workflow import AgentRun
|
||||
result = await db.execute(
|
||||
select(AgentRun).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.user_id == user_id,
|
||||
).limit(100)
|
||||
)
|
||||
runs = result.scalars().all()
|
||||
export["agents"]["agent_runs"] = [
|
||||
{
|
||||
"id": str(r.id),
|
||||
"status": r.status,
|
||||
"total_cost_usd": float(r.total_cost_usd or 0),
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in runs
|
||||
]
|
||||
except Exception as e:
|
||||
export["agents"]["error"] = str(e)
|
||||
|
||||
# Audit: User's audit entries
|
||||
try:
|
||||
from app.models.audit import AuditLog
|
||||
result = await db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.tenant_id == tenant_id,
|
||||
AuditLog.user_id == user_id,
|
||||
).limit(200)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
export["audit"]["entries"] = [
|
||||
{
|
||||
"id": str(e.id),
|
||||
"action": e.action,
|
||||
"entity_type": e.entity_type,
|
||||
"created_at": e.created_at.isoformat() if e.created_at else None,
|
||||
}
|
||||
for e in entries
|
||||
]
|
||||
except Exception as e:
|
||||
export["audit"]["error"] = str(e)
|
||||
|
||||
return export
|
||||
|
||||
|
||||
# ─── I-DSAR: Data Subject Rights Workflow ────────────────────────────────────
|
||||
|
||||
|
||||
DSARType = Literal["access", "correction", "erasure", "restriction"]
|
||||
|
||||
|
||||
async def create_dsar_request(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
subject_user_id: uuid.UUID,
|
||||
request_type: DSARType,
|
||||
description: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a data subject rights request (I-DSAR).
|
||||
|
||||
Creates a trackable Task for the DSGVO request. Finds affected sources,
|
||||
calls domain handlers, tracks derived data via lifecycle, documents
|
||||
exceptions/retention. No generic blind hard-delete.
|
||||
"""
|
||||
from app.plugins.builtins.tasks.services import create_task
|
||||
|
||||
task_data: dict[str, Any] = {
|
||||
"title": f"DSAR: {request_type} for user {subject_user_id}",
|
||||
"description": description or f"Data subject {request_type} request",
|
||||
"task_type": "dsar",
|
||||
"assignee_type": "user",
|
||||
"assignee_id": str(user_id),
|
||||
"entity_type": "user",
|
||||
"entity_id": str(subject_user_id),
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
}
|
||||
|
||||
task = await create_task(db, tenant_id, user_id, task_data)
|
||||
|
||||
# Find affected data sources
|
||||
affected_sources = await _find_affected_sources(db, tenant_id, subject_user_id)
|
||||
|
||||
return {
|
||||
"task": task,
|
||||
"request_type": request_type,
|
||||
"subject_user_id": str(subject_user_id),
|
||||
"affected_sources": affected_sources,
|
||||
}
|
||||
|
||||
|
||||
async def _find_affected_sources(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Find all data sources containing personal data for a user."""
|
||||
sources: list[dict[str, str]] = []
|
||||
|
||||
# Check each source
|
||||
source_checks = [
|
||||
("core.contacts", "Contact", "owner_id"),
|
||||
("mail.accounts", "MailAccount", "user_id"),
|
||||
("dms.files", "DmsFile", "owner_id"),
|
||||
("communication.messages", "CommMessage", "sender_id"),
|
||||
("agents.runs", "AgentRun", "user_id"),
|
||||
]
|
||||
|
||||
for source_name, model_name, id_field in source_checks:
|
||||
try:
|
||||
# Dynamic import would be needed here; for now just list the source
|
||||
sources.append({
|
||||
"source": source_name,
|
||||
"model": model_name,
|
||||
"id_field": id_field,
|
||||
"status": "identified",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return sources
|
||||
|
||||
|
||||
# ─── I-COMP-EXPORT: AI/Compliance Evidence Export ────────────────────────────
|
||||
|
||||
|
||||
async def export_compliance_evidence(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
days: int = 90,
|
||||
) -> dict[str, Any]:
|
||||
"""Export AI/Compliance evidence package (I-COMP-EXPORT).
|
||||
|
||||
Returns: AI use case metadata, provider/model references,
|
||||
agent/workflow versions, audit/oversight/approval evidence,
|
||||
and technical policies as exportable evidence package.
|
||||
"""
|
||||
since = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
evidence: dict[str, Any] = {
|
||||
"export_metadata": {
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"tenant_id": str(tenant_id),
|
||||
"export_type": "compliance_evidence",
|
||||
"period_days": days,
|
||||
"version": "1.0",
|
||||
},
|
||||
"ai_use_cases": [],
|
||||
"agent_definitions": [],
|
||||
"workflow_definitions": [],
|
||||
"audit_entries": [],
|
||||
"approval_records": [],
|
||||
"oversight_records": [],
|
||||
"technical_policies": {},
|
||||
}
|
||||
|
||||
# Agent definitions with AI metadata
|
||||
try:
|
||||
from app.models.workflow import AgentDefinition
|
||||
result = await db.execute(
|
||||
select(AgentDefinition).where(
|
||||
AgentDefinition.tenant_id == tenant_id,
|
||||
AgentDefinition.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
agents = result.scalars().all()
|
||||
evidence["agent_definitions"] = [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"name": a.name,
|
||||
"llm_model": getattr(a, "llm_model", None),
|
||||
"provider": getattr(a, "provider", None),
|
||||
"is_active": a.is_active,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
}
|
||||
for a in agents
|
||||
]
|
||||
except Exception as e:
|
||||
evidence["agent_definitions_error"] = str(e)
|
||||
|
||||
# Approval records
|
||||
try:
|
||||
from app.core.approval import ApprovalRequest
|
||||
result = await db.execute(
|
||||
select(ApprovalRequest).where(
|
||||
ApprovalRequest.tenant_id == tenant_id,
|
||||
ApprovalRequest.created_at >= since,
|
||||
).limit(100)
|
||||
)
|
||||
approvals = result.scalars().all()
|
||||
evidence["approval_records"] = [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"action": a.action,
|
||||
"status": a.status,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
}
|
||||
for a in approvals
|
||||
]
|
||||
except Exception as e:
|
||||
evidence["approval_records_error"] = str(e)
|
||||
|
||||
# Technical policies
|
||||
evidence["technical_policies"] = {
|
||||
"data_policy": {
|
||||
"sensitive_fields": list(_get_sensitive_fields()),
|
||||
"provider_compliance": "enforced",
|
||||
},
|
||||
"permission_model": {
|
||||
"type": "ABAC",
|
||||
"tenant_isolation": "RLS",
|
||||
},
|
||||
"auth": {
|
||||
"type": "session_based",
|
||||
"cookies": "HttpOnly",
|
||||
},
|
||||
"retention": {
|
||||
"soft_delete": True,
|
||||
"hard_delete_requires_gdpr_flag": True,
|
||||
},
|
||||
}
|
||||
|
||||
return evidence
|
||||
|
||||
|
||||
def _get_sensitive_fields() -> dict[str, set[str]]:
|
||||
"""Get the sensitive fields mapping from data_policy.
|
||||
|
||||
Returns a dict mapping entity types to their sensitive field sets.
|
||||
"""
|
||||
try:
|
||||
from app.ai.data_policy import SENSITIVE_FIELDS
|
||||
return SENSITIVE_FIELDS
|
||||
except Exception:
|
||||
return {"contact": {"email", "phone", "address", "date_of_birth"}}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"export_user_data",
|
||||
"create_dsar_request",
|
||||
"export_compliance_evidence",
|
||||
"DSARType",
|
||||
]
|
||||
@@ -1,217 +0,0 @@
|
||||
"""Integration tools — Agent → Workflow and Agent → Knowledge (I-AW, I-AK).
|
||||
|
||||
Provides AI agent tools for:
|
||||
- Starting and checking workflow status (I-AW)
|
||||
- Querying knowledge base with evidence (I-AK)
|
||||
|
||||
These tools are registered in the AI tool registry and can be used by
|
||||
agents via the ReAct loop. Each tool respects tenant_id and permissions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── I-AW: Agent → Workflow Tools ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def start_workflow_tool(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
workflow_id: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Agent tool: Start a workflow instance.
|
||||
|
||||
Args:
|
||||
workflow_id: The workflow definition ID.
|
||||
context: Optional initial context variables.
|
||||
|
||||
Returns:
|
||||
Dict with instance_id, status, and workflow info.
|
||||
"""
|
||||
from app.services.workflow_service import create_instance
|
||||
|
||||
try:
|
||||
result = await create_instance(
|
||||
db,
|
||||
tenant_id,
|
||||
user_id,
|
||||
workflow_id=workflow_id,
|
||||
context=context or {},
|
||||
)
|
||||
if result is None:
|
||||
return {"error": "Workflow not found", "status": "not_found"}
|
||||
return {
|
||||
"instance_id": result.get("id"),
|
||||
"status": result.get("status"),
|
||||
"workflow_id": workflow_id,
|
||||
"message": f"Workflow started successfully",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("start_workflow_tool failed: %s", e)
|
||||
return {"error": str(e), "status": "failed"}
|
||||
|
||||
|
||||
async def check_workflow_status_tool(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
instance_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Agent tool: Check the status of a workflow instance.
|
||||
|
||||
Args:
|
||||
instance_id: The workflow instance ID.
|
||||
|
||||
Returns:
|
||||
Dict with status, current_step, and step history.
|
||||
"""
|
||||
from app.services.workflow_service import get_instance
|
||||
from app.models.workflow import WorkflowStepHistory
|
||||
from sqlalchemy import select
|
||||
|
||||
try:
|
||||
instance = await get_instance(db, tenant_id, instance_id)
|
||||
if instance is None:
|
||||
return {"error": "Instance not found", "status": "not_found"}
|
||||
|
||||
# Get step history
|
||||
history_result = await db.execute(
|
||||
select(WorkflowStepHistory)
|
||||
.where(
|
||||
WorkflowStepHistory.tenant_id == tenant_id,
|
||||
WorkflowStepHistory.instance_id == uuid.UUID(instance_id),
|
||||
)
|
||||
.order_by(WorkflowStepHistory.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_steps = [
|
||||
{
|
||||
"step_index": h.step_index,
|
||||
"step_type": h.step_type,
|
||||
"action": h.action,
|
||||
}
|
||||
for h in history_result.scalars().all()
|
||||
]
|
||||
|
||||
return {
|
||||
"instance_id": instance_id,
|
||||
"status": instance.get("status"),
|
||||
"current_step_index": instance.get("current_step_index"),
|
||||
"recent_steps": recent_steps,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("check_workflow_status_tool failed: %s", e)
|
||||
return {"error": str(e), "status": "failed"}
|
||||
|
||||
|
||||
# ─── I-AK: Agent → Knowledge Tools ───────────────────────────────────────────
|
||||
|
||||
|
||||
async def ask_knowledge_tool(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
query: str,
|
||||
source_types: list[str] | None = None,
|
||||
max_results: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
"""Agent tool: Query the knowledge base with evidence-backed results.
|
||||
|
||||
Args:
|
||||
query: Natural language query.
|
||||
source_types: Optional filter (wiki, dms, mail, communication).
|
||||
max_results: Maximum results to return.
|
||||
|
||||
Returns:
|
||||
Dict with answer, evidence references, and workstream blocks.
|
||||
"""
|
||||
from app.ai.knowledge_lifecycle import ask_knowledge
|
||||
|
||||
return await ask_knowledge(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
source_types=source_types,
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
|
||||
async def search_knowledge_tool(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
query: str,
|
||||
entity_type: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""Agent tool: Search across all knowledge sources.
|
||||
|
||||
Args:
|
||||
query: Search query.
|
||||
entity_type: Optional entity type filter.
|
||||
limit: Maximum results.
|
||||
|
||||
Returns:
|
||||
Dict with search results and evidence references.
|
||||
"""
|
||||
try:
|
||||
from app.plugins.builtins.unified_search.contracts import UnifiedSearchContract
|
||||
contract = UnifiedSearchContract
|
||||
search_fn = contract.get_function("unified_search")
|
||||
if search_fn is None:
|
||||
return {"error": "Search not available", "results": []}
|
||||
|
||||
results = await search_fn(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
query=query,
|
||||
entity_type=entity_type,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Build evidence references
|
||||
from app.ai.knowledge_sources import build_evidence_references
|
||||
refs = build_evidence_references(results or [], max_results=limit)
|
||||
|
||||
return {
|
||||
"results": [r.to_dict() for r in refs],
|
||||
"total": len(refs),
|
||||
"query": query,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("search_knowledge_tool failed: %s", e)
|
||||
return {"error": str(e), "results": []}
|
||||
|
||||
|
||||
# ─── Tool Registration ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def register_integration_tools(registry: Any) -> None:
|
||||
"""Register integration tools in the AI tool registry.
|
||||
|
||||
Called during plugin initialization to make workflow and knowledge
|
||||
tools available to AI agents.
|
||||
"""
|
||||
# These would be registered as ToolDefinition objects in the registry.
|
||||
# The actual registration depends on the ToolRegistry API.
|
||||
# For now, we expose the functions for manual registration.
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"start_workflow_tool",
|
||||
"check_workflow_status_tool",
|
||||
"ask_knowledge_tool",
|
||||
"search_knowledge_tool",
|
||||
"register_integration_tools",
|
||||
]
|
||||
@@ -1,361 +0,0 @@
|
||||
"""Knowledge extraction — LLM-based relationship and entity extraction (H-EXT, H-ENT, H-AUTO, H-CONF).
|
||||
|
||||
Analyzes texts from knowledge sources (wiki, DMS, mail, communication)
|
||||
and extracts:
|
||||
- Named entities (persons, companies, projects) — H-ENT
|
||||
- Relationships between entities — H-EXT
|
||||
- Auto-creates relationships in GraphRAG — H-AUTO
|
||||
- Confidence scores with low-confidence → review queue — H-CONF
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractedEntity:
|
||||
"""A named entity extracted from text (H-ENT)."""
|
||||
|
||||
name: str
|
||||
entity_type: str # person, company, project, location, date, other
|
||||
mentions: list[int] = field(default_factory=list) # character positions
|
||||
confidence: float = 1.0
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractedRelationship:
|
||||
"""A relationship between two entities extracted from text (H-EXT)."""
|
||||
|
||||
source_entity: str
|
||||
source_type: str
|
||||
target_entity: str
|
||||
target_type: str
|
||||
relationship_type: str # works_for, related_to, has_email, etc.
|
||||
confidence: float = 0.0
|
||||
evidence: str = "" # Text snippet that supports this relationship
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractionResult:
|
||||
"""Result of a knowledge extraction run."""
|
||||
|
||||
entities: list[ExtractedEntity] = field(default_factory=list)
|
||||
relationships: list[ExtractedRelationship] = field(default_factory=list)
|
||||
source_type: str = ""
|
||||
source_id: str = ""
|
||||
tenant_id: str = ""
|
||||
overall_confidence: float = 0.0
|
||||
|
||||
|
||||
# ─── Extraction prompt ───────────────────────────────────────────────────────
|
||||
|
||||
_EXTRACTION_SYSTEM_PROMPT = """You are a knowledge extraction assistant. Analyze the given text and extract:
|
||||
|
||||
1. Named entities (persons, companies, projects, locations, dates)
|
||||
2. Relationships between entities (e.g., "works_for", "related_to", "has_email", "part_of")
|
||||
|
||||
Return JSON in this format:
|
||||
{
|
||||
"entities": [
|
||||
{"name": "John Doe", "type": "person", "confidence": 0.95}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"source": "John Doe",
|
||||
"source_type": "person",
|
||||
"target": "Acme Corp",
|
||||
"target_type": "company",
|
||||
"type": "works_for",
|
||||
"confidence": 0.9,
|
||||
"evidence": "John Doe works at Acme Corp"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Only extract explicitly stated facts. Do not infer or hallucinate.
|
||||
If no entities or relationships are found, return empty arrays."""
|
||||
|
||||
|
||||
async def extract_knowledge(
|
||||
text: str,
|
||||
tenant_id: uuid.UUID,
|
||||
source_type: str = "",
|
||||
source_id: str = "",
|
||||
llm_model: str | None = None,
|
||||
) -> ExtractionResult:
|
||||
"""Extract entities and relationships from text using LLM (H-EXT, H-ENT).
|
||||
|
||||
Args:
|
||||
text: The text to analyze.
|
||||
tenant_id: Tenant ID for multi-tenancy.
|
||||
source_type: Source type (wiki, dms, mail, communication).
|
||||
source_id: Source entity ID.
|
||||
llm_model: Optional LLM model override.
|
||||
|
||||
Returns:
|
||||
ExtractionResult with entities and relationships.
|
||||
"""
|
||||
if not text or len(text.strip()) < 10:
|
||||
return ExtractionResult(
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
tenant_id=str(tenant_id),
|
||||
)
|
||||
|
||||
try:
|
||||
from app.ai.llm_client import llm_complete
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _EXTRACTION_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"Analyze this text:\n\n{text[:8000]}"},
|
||||
]
|
||||
|
||||
response = await llm_complete(
|
||||
model=llm_model or "ollama/deepseek-v4-flash",
|
||||
messages=messages,
|
||||
temperature=0.1,
|
||||
max_tokens=2000,
|
||||
)
|
||||
|
||||
# Parse LLM response
|
||||
import json
|
||||
|
||||
raw = response.get("content", "")
|
||||
# Try to extract JSON from response
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
# Try to find JSON in the response
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
data = json.loads(raw[start:end])
|
||||
else:
|
||||
logger.warning("Failed to parse extraction response as JSON")
|
||||
return ExtractionResult(
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
tenant_id=str(tenant_id),
|
||||
)
|
||||
|
||||
# Build ExtractionResult
|
||||
entities: list[ExtractedEntity] = []
|
||||
for ent in data.get("entities", []):
|
||||
entities.append(ExtractedEntity(
|
||||
name=ent.get("name", ""),
|
||||
entity_type=ent.get("type", "other"),
|
||||
confidence=ent.get("confidence", 0.5),
|
||||
metadata=ent.get("metadata", {}),
|
||||
))
|
||||
|
||||
relationships: list[ExtractedRelationship] = []
|
||||
for rel in data.get("relationships", []):
|
||||
relationships.append(ExtractedRelationship(
|
||||
source_entity=rel.get("source", ""),
|
||||
source_type=rel.get("source_type", "other"),
|
||||
target_entity=rel.get("target", ""),
|
||||
target_type=rel.get("target_type", "other"),
|
||||
relationship_type=rel.get("type", "related_to"),
|
||||
confidence=rel.get("confidence", 0.5),
|
||||
evidence=rel.get("evidence", ""),
|
||||
metadata=rel.get("metadata", {}),
|
||||
))
|
||||
|
||||
# Calculate overall confidence
|
||||
all_confidences = [e.confidence for e in entities] + [r.confidence for r in relationships]
|
||||
overall = sum(all_confidences) / len(all_confidences) if all_confidences else 0.0
|
||||
|
||||
return ExtractionResult(
|
||||
entities=entities,
|
||||
relationships=relationships,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
tenant_id=str(tenant_id),
|
||||
overall_confidence=overall,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Knowledge extraction failed: %s", e)
|
||||
return ExtractionResult(
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
tenant_id=str(tenant_id),
|
||||
)
|
||||
|
||||
|
||||
# ─── Confidence scoring (H-CONF) ─────────────────────────────────────────────
|
||||
|
||||
LOW_CONFIDENCE_THRESHOLD = 0.6
|
||||
|
||||
|
||||
def is_low_confidence(confidence: float) -> bool:
|
||||
"""Check if a confidence score is below the review threshold (H-CONF)."""
|
||||
return confidence < LOW_CONFIDENCE_THRESHOLD
|
||||
|
||||
|
||||
def filter_high_confidence(
|
||||
relationships: list[ExtractedRelationship],
|
||||
threshold: float = LOW_CONFIDENCE_THRESHOLD,
|
||||
) -> tuple[list[ExtractedRelationship], list[ExtractedRelationship]]:
|
||||
"""Split relationships into high-confidence and low-confidence (review queue).
|
||||
|
||||
Returns:
|
||||
Tuple of (high_confidence, low_confidence) lists.
|
||||
"""
|
||||
high = [r for r in relationships if r.confidence >= threshold]
|
||||
low = [r for r in relationships if r.confidence < threshold]
|
||||
return high, low
|
||||
|
||||
|
||||
# ─── Auto-relationship creation in GraphRAG (H-AUTO) ─────────────────────────
|
||||
|
||||
|
||||
async def auto_create_relationships(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
extraction: ExtractionResult,
|
||||
min_confidence: float = LOW_CONFIDENCE_THRESHOLD,
|
||||
) -> dict[str, Any]:
|
||||
"""Auto-create extracted relationships in GraphRAG (H-AUTO).
|
||||
|
||||
Only creates relationships with confidence >= min_confidence.
|
||||
Low-confidence relationships are returned for the review queue.
|
||||
|
||||
Returns:
|
||||
Dict with ``created``, ``skipped_low_confidence``, ``errors`` counts.
|
||||
"""
|
||||
from app.plugins.builtins.graph_rag.models import EntityRelationship
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
high_conf, low_conf = filter_high_confidence(extraction.relationships, min_confidence)
|
||||
|
||||
for rel in high_conf:
|
||||
try:
|
||||
# Try to resolve entity names to actual entity IDs
|
||||
# For now, store as typed relationships with name-based references
|
||||
source_id = await _resolve_entity_id(db, tenant_id, rel.source_entity, rel.source_type)
|
||||
target_id = await _resolve_entity_id(db, tenant_id, rel.target_entity, rel.target_type)
|
||||
|
||||
if source_id is None or target_id is None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Check if relationship already exists
|
||||
existing = await db.execute(
|
||||
select(EntityRelationship).where(
|
||||
EntityRelationship.tenant_id == tenant_id,
|
||||
EntityRelationship.source_type == rel.source_type,
|
||||
EntityRelationship.source_id == source_id,
|
||||
EntityRelationship.target_type == rel.target_type,
|
||||
EntityRelationship.target_id == target_id,
|
||||
EntityRelationship.relationship_type == rel.relationship_type,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Create new relationship
|
||||
er = EntityRelationship(
|
||||
tenant_id=tenant_id,
|
||||
source_type=rel.source_type,
|
||||
source_id=source_id,
|
||||
target_type=rel.target_type,
|
||||
target_id=target_id,
|
||||
relationship_type=rel.relationship_type,
|
||||
confidence=rel.confidence,
|
||||
metadata={
|
||||
"evidence": rel.evidence,
|
||||
"source_type": extraction.source_type,
|
||||
"source_id": extraction.source_id,
|
||||
"auto_extracted": True,
|
||||
},
|
||||
)
|
||||
db.add(er)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
logger.warning("Failed to auto-create relationship: %s", e)
|
||||
errors += 1
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"created": created,
|
||||
"skipped_low_confidence": len(low_conf),
|
||||
"skipped_existing": skipped,
|
||||
"errors": errors,
|
||||
"review_queue": [
|
||||
{
|
||||
"source": r.source_entity,
|
||||
"target": r.target_entity,
|
||||
"type": r.relationship_type,
|
||||
"confidence": r.confidence,
|
||||
"evidence": r.evidence,
|
||||
}
|
||||
for r in low_conf
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_entity_id(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
name: str,
|
||||
entity_type: str,
|
||||
) -> uuid.UUID | None:
|
||||
"""Try to resolve an entity name to an actual entity ID.
|
||||
|
||||
Searches contacts, companies, etc. by name.
|
||||
Returns None if no match found.
|
||||
"""
|
||||
try:
|
||||
if entity_type == "person":
|
||||
from app.models.contact import Contact
|
||||
result = await db.execute(
|
||||
select(Contact.id).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
Contact.name.ilike(f"%{name}%"),
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
elif entity_type == "company":
|
||||
from app.models.contact import Contact
|
||||
result = await db.execute(
|
||||
select(Contact.id).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
Contact.is_company.is_(True),
|
||||
Contact.name.ilike(f"%{name}%"),
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExtractedEntity",
|
||||
"ExtractedRelationship",
|
||||
"ExtractionResult",
|
||||
"extract_knowledge",
|
||||
"is_low_confidence",
|
||||
"filter_high_confidence",
|
||||
"auto_create_relationships",
|
||||
"LOW_CONFIDENCE_THRESHOLD",
|
||||
]
|
||||
@@ -1,447 +0,0 @@
|
||||
"""Knowledge lifecycle — event-driven extraction, derived-data lifecycle,
|
||||
retention policy, ask-knowledge, and review queue (H-EVT, H-DATA-LIFE, H-RET, H-ASK, H-REV).
|
||||
|
||||
Event-driven extraction: new mail/dokument/message → ARQ-Job → extraction.
|
||||
Derived-data lifecycle: correction/delete/erasure of source propagates to
|
||||
RAG chunks, embeddings, graph references, and agent memory.
|
||||
Retention: configurable per-source retention policy, ARQ cleans up.
|
||||
Ask Knowledge: RAG queries with evidence cards via workstream.
|
||||
Review queue: low-confidence extracted relationships pending review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── H-EVT: Event-Driven Extraction ──────────────────────────────────────────
|
||||
|
||||
# Events that trigger knowledge extraction
|
||||
EXTRACTION_TRIGGERS = {
|
||||
"mail.received": {"source_type": "mail", "text_field": "body_text"},
|
||||
"dms.file_uploaded": {"source_type": "dms", "text_field": "extracted_text"},
|
||||
"wiki.article_published": {"source_type": "wiki", "text_field": "content"},
|
||||
"communication.message_created": {"source_type": "communication", "text_field": "content"},
|
||||
}
|
||||
|
||||
|
||||
def should_extract(event_name: str) -> bool:
|
||||
"""Check if an event should trigger knowledge extraction (H-EVT)."""
|
||||
return event_name in EXTRACTION_TRIGGERS
|
||||
|
||||
|
||||
async def handle_extraction_event(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Handle an event that may trigger knowledge extraction (H-EVT).
|
||||
|
||||
Called by the event bus. If the event matches a configured extraction
|
||||
trigger, fetches the source content and runs knowledge extraction.
|
||||
"""
|
||||
if not should_extract(event_name):
|
||||
return None
|
||||
|
||||
trigger_config = EXTRACTION_TRIGGERS[event_name]
|
||||
source_type = trigger_config["source_type"]
|
||||
entity_id_str = payload.get("entity_id") or payload.get("file_id") or payload.get("message_id")
|
||||
if not entity_id_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
entity_id = uuid.UUID(str(entity_id_str))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# Fetch source content
|
||||
from app.ai.knowledge_sources import fetch_source_content
|
||||
content = await fetch_source_content(db, tenant_id, source_type, entity_id)
|
||||
if content is None or not content.get("text"):
|
||||
return None
|
||||
|
||||
# Run extraction
|
||||
from app.ai.knowledge_extraction import extract_knowledge, auto_create_relationships
|
||||
extraction = await extract_knowledge(
|
||||
text=content["text"],
|
||||
tenant_id=tenant_id,
|
||||
source_type=source_type,
|
||||
source_id=str(entity_id),
|
||||
)
|
||||
|
||||
# Auto-create high-confidence relationships
|
||||
result = await auto_create_relationships(db, tenant_id, extraction)
|
||||
|
||||
return {
|
||||
"event": event_name,
|
||||
"source_type": source_type,
|
||||
"source_id": str(entity_id),
|
||||
"entities_found": len(extraction.entities),
|
||||
"relationships_found": len(extraction.relationships),
|
||||
**result,
|
||||
}
|
||||
|
||||
|
||||
# ─── H-DATA-LIFE: Derived-Data Lifecycle ─────────────────────────────────────
|
||||
|
||||
async def propagate_source_deletion(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
source_type: str,
|
||||
source_id: uuid.UUID,
|
||||
) -> dict[str, int]:
|
||||
"""Propagate correction/delete/erasure of a source to derived data (H-DATA-LIFE).
|
||||
|
||||
When a source (wiki article, DMS file, mail, message) is deleted or corrected,
|
||||
this removes:
|
||||
- RAG chunks referencing the source
|
||||
- Embeddings referencing the source
|
||||
- Graph relationships with metadata.source_id matching
|
||||
- Agent memory entries referencing the source
|
||||
|
||||
Returns counts of what was removed.
|
||||
"""
|
||||
removed = {"graph_relationships": 0, "agent_memory": 0}
|
||||
|
||||
# Remove graph relationships that were auto-extracted from this source
|
||||
try:
|
||||
from app.plugins.builtins.graph_rag.models import EntityRelationship
|
||||
|
||||
result = await db.execute(
|
||||
select(EntityRelationship).where(
|
||||
EntityRelationship.tenant_id == tenant_id,
|
||||
EntityRelationship.metadata["source_id"].astext == str(source_id),
|
||||
EntityRelationship.metadata["source_type"].astext == source_type,
|
||||
EntityRelationship.metadata["auto_extracted"].astext == "true",
|
||||
)
|
||||
)
|
||||
rels = result.scalars().all()
|
||||
for rel in rels:
|
||||
await db.delete(rel)
|
||||
removed["graph_relationships"] = len(rels)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to remove graph relationships for %s/%s: %s", source_type, source_id, e)
|
||||
|
||||
# Remove agent memory entries referencing this source
|
||||
try:
|
||||
from app.ai.agent_memory import AgentMemory
|
||||
|
||||
result = await db.execute(
|
||||
select(AgentMemory).where(
|
||||
AgentMemory.tenant_id == tenant_id,
|
||||
AgentMemory.metadata["source_type"].astext == source_type,
|
||||
AgentMemory.metadata["source_id"].astext == str(source_id),
|
||||
)
|
||||
)
|
||||
memories = result.scalars().all()
|
||||
for mem in memories:
|
||||
await db.delete(mem)
|
||||
removed["agent_memory"] = len(memories)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to remove agent memory for %s/%s: %s", source_type, source_id, e)
|
||||
|
||||
await db.flush()
|
||||
return removed
|
||||
|
||||
|
||||
async def propagate_source_correction(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
source_type: str,
|
||||
source_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Propagate source correction — re-extract knowledge from updated content (H-DATA-LIFE).
|
||||
|
||||
Removes old derived data and re-runs extraction on the updated source.
|
||||
"""
|
||||
# First remove old derived data
|
||||
removed = await propagate_source_deletion(db, tenant_id, source_type, source_id)
|
||||
|
||||
# Then re-extract from updated content
|
||||
from app.ai.knowledge_sources import fetch_source_content
|
||||
content = await fetch_source_content(db, tenant_id, source_type, source_id)
|
||||
if content is None:
|
||||
return {"removed": removed, "re_extracted": False}
|
||||
|
||||
from app.ai.knowledge_extraction import extract_knowledge, auto_create_relationships
|
||||
extraction = await extract_knowledge(
|
||||
text=content["text"],
|
||||
tenant_id=tenant_id,
|
||||
source_type=source_type,
|
||||
source_id=str(source_id),
|
||||
)
|
||||
created = await auto_create_relationships(db, tenant_id, extraction)
|
||||
|
||||
return {
|
||||
"removed": removed,
|
||||
"re_extracted": True,
|
||||
"entities_found": len(extraction.entities),
|
||||
"relationships_found": len(extraction.relationships),
|
||||
**created,
|
||||
}
|
||||
|
||||
|
||||
# ─── H-RET: Knowledge/Memory Retention ───────────────────────────────────────
|
||||
|
||||
# Default retention per source type (days). 0 = no retention limit.
|
||||
DEFAULT_RETENTION_DAYS = {
|
||||
"wiki": 0, # No limit — wiki articles are persistent knowledge
|
||||
"dms": 365, # 1 year for document-derived knowledge
|
||||
"mail": 180, # 6 months for mail-derived knowledge
|
||||
"communication": 90, # 3 months for communication-derived knowledge
|
||||
}
|
||||
|
||||
|
||||
def get_retention_days(source_type: str) -> int:
|
||||
"""Get retention period for a knowledge source type (H-RET)."""
|
||||
return DEFAULT_RETENTION_DAYS.get(source_type, 180)
|
||||
|
||||
|
||||
async def cleanup_expired_knowledge(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> dict[str, int]:
|
||||
"""Clean up expired knowledge based on retention policy (H-RET).
|
||||
|
||||
Called by ARQ cron job. Removes graph relationships and agent memory
|
||||
entries that have exceeded their retention period.
|
||||
"""
|
||||
cleaned = {"graph_relationships": 0, "agent_memory": 0}
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Clean up expired graph relationships
|
||||
try:
|
||||
from app.plugins.builtins.graph_rag.models import EntityRelationship
|
||||
|
||||
for source_type, retention_days in DEFAULT_RETENTION_DAYS.items():
|
||||
if retention_days == 0:
|
||||
continue
|
||||
cutoff = now - timedelta(days=retention_days)
|
||||
result = await db.execute(
|
||||
select(EntityRelationship).where(
|
||||
EntityRelationship.tenant_id == tenant_id,
|
||||
EntityRelationship.metadata["source_type"].astext == source_type,
|
||||
EntityRelationship.metadata["auto_extracted"].astext == "true",
|
||||
EntityRelationship.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
rels = result.scalars().all()
|
||||
for rel in rels:
|
||||
await db.delete(rel)
|
||||
cleaned["graph_relationships"] += len(rels)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cleanup expired graph relationships: %s", e)
|
||||
|
||||
# Clean up expired agent memory
|
||||
try:
|
||||
from app.ai.agent_memory import AgentMemory
|
||||
|
||||
for source_type, retention_days in DEFAULT_RETENTION_DAYS.items():
|
||||
if retention_days == 0:
|
||||
continue
|
||||
cutoff = now - timedelta(days=retention_days)
|
||||
result = await db.execute(
|
||||
select(AgentMemory).where(
|
||||
AgentMemory.tenant_id == tenant_id,
|
||||
AgentMemory.metadata["source_type"].astext == source_type,
|
||||
AgentMemory.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
memories = result.scalars().all()
|
||||
for mem in memories:
|
||||
await db.delete(mem)
|
||||
cleaned["agent_memory"] += len(memories)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cleanup expired agent memory: %s", e)
|
||||
|
||||
await db.flush()
|
||||
return cleaned
|
||||
|
||||
|
||||
# ─── H-ASK: Ask Knowledge in Workstream ──────────────────────────────────────
|
||||
|
||||
async def ask_knowledge(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
query: str,
|
||||
*,
|
||||
source_types: list[str] | None = None,
|
||||
max_results: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
"""Ask Knowledge — RAG query with evidence cards (H-ASK).
|
||||
|
||||
Runs a unified search query, builds evidence references,
|
||||
and returns results formatted for workstream display.
|
||||
"""
|
||||
if not query:
|
||||
return {"answer": "", "evidence": [], "query": ""}
|
||||
|
||||
try:
|
||||
from app.plugins.builtins.unified_search.contracts import SearchContract
|
||||
contract = SearchContract
|
||||
search_fn = contract.get_function("unified_search")
|
||||
if search_fn is None:
|
||||
return {"answer": "Search not available", "evidence": [], "query": query}
|
||||
|
||||
results = await search_fn(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
query=query,
|
||||
entity_type=None,
|
||||
limit=max_results * 2,
|
||||
)
|
||||
|
||||
# Filter by source types if specified
|
||||
if source_types and results:
|
||||
results = [r for r in results if r.get("source_type") in source_types]
|
||||
|
||||
# Build evidence references
|
||||
from app.ai.knowledge_sources import build_evidence_references
|
||||
refs = build_evidence_references(results, max_results=max_results)
|
||||
|
||||
# Build answer from top results
|
||||
if not refs:
|
||||
return {"answer": "No relevant knowledge found.", "evidence": [], "query": query}
|
||||
|
||||
# Summarize top results
|
||||
snippets = [f"- {r.title}: {r.snippet[:150]}" for r in refs[:3]]
|
||||
answer = f"Based on {len(refs)} source(s):\n\n" + "\n".join(snippets)
|
||||
|
||||
return {
|
||||
"answer": answer,
|
||||
"evidence": [r.to_dict() for r in refs],
|
||||
"workstream_blocks": [r.to_workstream_block() for r in refs],
|
||||
"query": query,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Ask knowledge failed: %s", e)
|
||||
return {"answer": f"Knowledge query failed: {e}", "evidence": [], "query": query}
|
||||
|
||||
|
||||
# ─── H-REV: Review Queue for extracted relationships ─────────────────────────
|
||||
|
||||
async def get_review_queue(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Get low-confidence extracted relationships pending review (H-REV)."""
|
||||
from app.plugins.builtins.graph_rag.models import EntityRelationship
|
||||
from sqlalchemy import func
|
||||
|
||||
query = select(EntityRelationship).where(
|
||||
EntityRelationship.tenant_id == tenant_id,
|
||||
EntityRelationship.confidence < 0.6,
|
||||
EntityRelationship.metadata["auto_extracted"].astext == "true",
|
||||
EntityRelationship.metadata["reviewed"].astext != "true",
|
||||
)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
query = query.order_by(EntityRelationship.confidence.asc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": str(r.id),
|
||||
"source_type": r.source_type,
|
||||
"source_id": str(r.source_id),
|
||||
"target_type": r.target_type,
|
||||
"target_id": str(r.target_id),
|
||||
"relationship_type": r.relationship_type,
|
||||
"confidence": r.confidence,
|
||||
"evidence": (r.metadata or {}).get("evidence", ""),
|
||||
"source": (r.metadata or {}).get("source_type", ""),
|
||||
}
|
||||
for r in items
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
async def approve_relationship(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
relationship_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Approve a low-confidence relationship (H-REV).
|
||||
|
||||
Marks the relationship as reviewed and boosts its confidence.
|
||||
"""
|
||||
from app.plugins.builtins.graph_rag.models import EntityRelationship
|
||||
|
||||
result = await db.execute(
|
||||
select(EntityRelationship).where(
|
||||
EntityRelationship.id == relationship_id,
|
||||
EntityRelationship.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
rel = result.scalar_one_or_none()
|
||||
if rel is None:
|
||||
return False
|
||||
|
||||
meta = dict(rel.metadata or {})
|
||||
meta["reviewed"] = True
|
||||
meta["reviewed_by"] = str(user_id)
|
||||
meta["reviewed_at"] = datetime.now(UTC).isoformat()
|
||||
rel.metadata = meta
|
||||
rel.confidence = max(rel.confidence, 0.8) # Boost confidence after review
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def reject_relationship(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
relationship_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Reject a low-confidence relationship — delete it (H-REV)."""
|
||||
from app.plugins.builtins.graph_rag.models import EntityRelationship
|
||||
|
||||
result = await db.execute(
|
||||
select(EntityRelationship).where(
|
||||
EntityRelationship.id == relationship_id,
|
||||
EntityRelationship.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
rel = result.scalar_one_or_none()
|
||||
if rel is None:
|
||||
return False
|
||||
|
||||
await db.delete(rel)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"should_extract",
|
||||
"handle_extraction_event",
|
||||
"propagate_source_deletion",
|
||||
"propagate_source_correction",
|
||||
"get_retention_days",
|
||||
"cleanup_expired_knowledge",
|
||||
"ask_knowledge",
|
||||
"get_review_queue",
|
||||
"approve_relationship",
|
||||
"reject_relationship",
|
||||
"EXTRACTION_TRIGGERS",
|
||||
"DEFAULT_RETENTION_DAYS",
|
||||
]
|
||||
@@ -1,259 +0,0 @@
|
||||
"""Knowledge source adapter — connects DMS, Wiki, Mail, Communication to the
|
||||
existing SearchProvider/RAG pipeline (H-SRC).
|
||||
|
||||
Originalquelle bleibt authoritative; Permissions/Sensitive Fields gelten
|
||||
durchgängig. No second universal knowledge store — uses existing
|
||||
unified_search infrastructure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Source type registry ────────────────────────────────────────────────────
|
||||
|
||||
_SOURCE_TYPES: dict[str, dict[str, Any]] = {
|
||||
"wiki": {
|
||||
"display_name": "Wiki Articles",
|
||||
"model_path": "app.plugins.builtins.wiki.models.WikiArticle",
|
||||
"text_field": "content",
|
||||
"title_field": "title",
|
||||
"id_field": "id",
|
||||
"status_filter": {"status": "published"},
|
||||
},
|
||||
"dms": {
|
||||
"display_name": "DMS Documents",
|
||||
"model_path": "app.plugins.builtins.dms.models.DmsFile",
|
||||
"text_field": "extracted_text",
|
||||
"title_field": "filename",
|
||||
"id_field": "id",
|
||||
"status_filter": {},
|
||||
},
|
||||
"mail": {
|
||||
"display_name": "Mail Messages",
|
||||
"model_path": "app.plugins.builtins.mail.models.MailMessage",
|
||||
"text_field": "body_text",
|
||||
"title_field": "subject",
|
||||
"id_field": "id",
|
||||
"status_filter": {},
|
||||
},
|
||||
"communication": {
|
||||
"display_name": "Communication Messages",
|
||||
"model_path": "app.plugins.builtins.kommunikation.models.CommMessage",
|
||||
"text_field": "content",
|
||||
"title_field": "content",
|
||||
"id_field": "id",
|
||||
"status_filter": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_available_sources() -> list[dict[str, str]]:
|
||||
"""List all available knowledge source types."""
|
||||
return [
|
||||
{"type": k, "display_name": v["display_name"]}
|
||||
for k, v in _SOURCE_TYPES.items()
|
||||
]
|
||||
|
||||
|
||||
def get_source_config(source_type: str) -> dict[str, Any] | None:
|
||||
"""Get configuration for a knowledge source type."""
|
||||
return _SOURCE_TYPES.get(source_type)
|
||||
|
||||
|
||||
async def fetch_source_content(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
source_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Fetch content from a knowledge source for RAG indexing.
|
||||
|
||||
Returns a dict with:
|
||||
- ``title``: Title for the content
|
||||
- ``text``: Text content for embedding
|
||||
- ``source_type``: The source type
|
||||
- ``source_id``: The entity ID
|
||||
- ``source_url``: Deep link to the original content
|
||||
- ``metadata``: Additional metadata
|
||||
"""
|
||||
config = get_source_config(source_type)
|
||||
if config is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Dynamic import of the model
|
||||
import importlib
|
||||
module_path, class_name = config["model_path"].rsplit(".", 1)
|
||||
module = importlib.import_module(module_path)
|
||||
model = getattr(module, class_name)
|
||||
|
||||
# Fetch the entity
|
||||
query = select(model).where(
|
||||
model.id == entity_id,
|
||||
model.tenant_id == tenant_id,
|
||||
)
|
||||
if hasattr(model, "deleted_at"):
|
||||
query = query.where(model.deleted_at.is_(None))
|
||||
|
||||
# Apply status filter
|
||||
for field, value in config.get("status_filter", {}).items():
|
||||
if hasattr(model, field):
|
||||
query = query.where(getattr(model, field) == value)
|
||||
|
||||
result = await db.execute(query)
|
||||
entity = result.scalar_one_or_none()
|
||||
if entity is None:
|
||||
return None
|
||||
|
||||
# Extract text and title
|
||||
text = getattr(entity, config["text_field"], "") or ""
|
||||
title = getattr(entity, config["title_field"], "") or ""
|
||||
|
||||
# Build source URL (deep link)
|
||||
source_url = _build_source_url(source_type, entity_id)
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"source_type": source_type,
|
||||
"source_id": str(entity_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
}
|
||||
if hasattr(entity, "owner_id"):
|
||||
metadata["owner_id"] = str(entity.owner_id) if entity.owner_id else None
|
||||
if hasattr(entity, "tags"):
|
||||
metadata["tags"] = entity.tags or []
|
||||
if hasattr(entity, "category_id"):
|
||||
metadata["category_id"] = str(entity.category_id) if entity.category_id else None
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"text": text,
|
||||
"source_type": source_type,
|
||||
"source_id": str(entity_id),
|
||||
"source_url": source_url,
|
||||
"metadata": metadata,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch %s content %s: %s", source_type, entity_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def _build_source_url(source_type: str, entity_id: uuid.UUID) -> str:
|
||||
"""Build a deep-link URL to the original content."""
|
||||
url_map = {
|
||||
"wiki": f"/wiki/articles/{entity_id}",
|
||||
"dms": f"/dms/files/{entity_id}",
|
||||
"mail": f"/mail/messages/{entity_id}",
|
||||
"communication": f"/communication/messages/{entity_id}",
|
||||
}
|
||||
return url_map.get(source_type, f"/{source_type}/{entity_id}")
|
||||
|
||||
|
||||
# ─── Evidence/Source References (H-CITE) ─────────────────────────────────────
|
||||
|
||||
|
||||
class EvidenceReference:
|
||||
"""Structured source reference for RAG/Knowledge results (H-CITE).
|
||||
|
||||
Provides deep-links and cards to original documents, mails, messages,
|
||||
or business objects. Agents can display these in the workstream.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
title: str,
|
||||
url: str,
|
||||
snippet: str = "",
|
||||
confidence: float = 0.0,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
):
|
||||
self.source_type = source_type
|
||||
self.source_id = source_id
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.snippet = snippet
|
||||
self.confidence = confidence
|
||||
self.metadata = metadata or {}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to dict for API responses and workstream blocks."""
|
||||
return {
|
||||
"source_type": self.source_type,
|
||||
"source_id": self.source_id,
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
"snippet": self.snippet,
|
||||
"confidence": self.confidence,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
def to_workstream_block(self) -> dict[str, Any]:
|
||||
"""Convert to a typed workstream block for display in Communication."""
|
||||
return {
|
||||
"type": "evidence_card",
|
||||
"source_type": self.source_type,
|
||||
"source_id": self.source_id,
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
"snippet": self.snippet[:200],
|
||||
"confidence": self.confidence,
|
||||
}
|
||||
|
||||
|
||||
def build_evidence_references(
|
||||
search_results: list[dict[str, Any]],
|
||||
max_results: int = 5,
|
||||
) -> list[EvidenceReference]:
|
||||
"""Build evidence references from search/RAG results.
|
||||
|
||||
Args:
|
||||
search_results: Raw search results with source_type, source_id, title, etc.
|
||||
max_results: Maximum number of references to return.
|
||||
|
||||
Returns:
|
||||
List of EvidenceReference objects sorted by confidence.
|
||||
"""
|
||||
refs: list[EvidenceReference] = []
|
||||
for result in search_results[:max_results]:
|
||||
source_type = result.get("source_type", "unknown")
|
||||
source_id = result.get("source_id", "")
|
||||
title = result.get("title", "")
|
||||
url = result.get("source_url") or _build_source_url(
|
||||
source_type, uuid.UUID(source_id) if source_id else uuid.uuid4()
|
||||
)
|
||||
snippet = result.get("snippet", "") or result.get("text", "")[:200]
|
||||
confidence = result.get("score", 0.0)
|
||||
|
||||
refs.append(EvidenceReference(
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
title=title,
|
||||
url=url,
|
||||
snippet=snippet,
|
||||
confidence=confidence,
|
||||
metadata=result.get("metadata", {}),
|
||||
))
|
||||
|
||||
# Sort by confidence descending
|
||||
refs.sort(key=lambda r: r.confidence, reverse=True)
|
||||
return refs
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_available_sources",
|
||||
"get_source_config",
|
||||
"fetch_source_content",
|
||||
"EvidenceReference",
|
||||
"build_evidence_references",
|
||||
]
|
||||
@@ -1,226 +0,0 @@
|
||||
"""MCP-Exposure for platform features (I-MCP).
|
||||
|
||||
Exposes Search, Agents, Workflows, and Knowledge as thin MCP-compatible
|
||||
tools on top of existing tools/services. MCP possesses no own rights;
|
||||
the existing auth/run-as context and normal permission checks always apply.
|
||||
|
||||
This is NOT a separate MCP server — it's a thin exposure layer that
|
||||
maps existing platform functions to MCP tool schemas so external
|
||||
MCP clients can invoke them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── MCP Tool Definitions ────────────────────────────────────────────────────
|
||||
|
||||
MCP_TOOLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search across all entities (contacts, companies, DMS, wiki, mail, communication).",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"entity_type": {"type": "string", "description": "Optional entity type filter"},
|
||||
"limit": {"type": "integer", "description": "Max results (default 10)", "default": 10},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"required_permission": "contacts:read",
|
||||
"handler": "search",
|
||||
},
|
||||
{
|
||||
"name": "ask_knowledge",
|
||||
"description": "Query the knowledge base with RAG and evidence-backed results.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Natural language query"},
|
||||
"source_types": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional source filter (wiki, dms, mail, communication)",
|
||||
},
|
||||
"max_results": {"type": "integer", "description": "Max results (default 5)", "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"required_permission": "contacts:read",
|
||||
"handler": "ask_knowledge",
|
||||
},
|
||||
{
|
||||
"name": "start_workflow",
|
||||
"description": "Start a workflow instance by workflow ID.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {"type": "string", "description": "Workflow definition ID"},
|
||||
"context": {"type": "object", "description": "Initial context variables"},
|
||||
},
|
||||
"required": ["workflow_id"],
|
||||
},
|
||||
"required_permission": "workflows:write",
|
||||
"handler": "start_workflow",
|
||||
},
|
||||
{
|
||||
"name": "check_workflow_status",
|
||||
"description": "Check the status of a workflow instance.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instance_id": {"type": "string", "description": "Workflow instance ID"},
|
||||
},
|
||||
"required": ["instance_id"],
|
||||
},
|
||||
"required_permission": "workflows:read",
|
||||
"handler": "check_workflow_status",
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List available AI agents.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
"required_permission": "agents:read",
|
||||
"handler": "list_agents",
|
||||
},
|
||||
{
|
||||
"name": "create_task",
|
||||
"description": "Create a task (todo, follow-up, etc.).",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Task title"},
|
||||
"description": {"type": "string", "description": "Task description"},
|
||||
"priority": {"type": "string", "description": "low|medium|high|urgent", "default": "medium"},
|
||||
"entity_type": {"type": "string", "description": "Linked entity type"},
|
||||
"entity_id": {"type": "string", "description": "Linked entity ID"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
"required_permission": "tasks:write",
|
||||
"handler": "create_task",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_mcp_tools() -> list[dict[str, Any]]:
|
||||
"""List all available MCP tools with their schemas."""
|
||||
return [
|
||||
{
|
||||
"name": t["name"],
|
||||
"description": t["description"],
|
||||
"input_schema": t["input_schema"],
|
||||
}
|
||||
for t in MCP_TOOLS
|
||||
]
|
||||
|
||||
|
||||
def get_mcp_tool(name: str) -> dict[str, Any] | None:
|
||||
"""Get a single MCP tool definition by name."""
|
||||
return next((t for t in MCP_TOOLS if t["name"] == name), None)
|
||||
|
||||
|
||||
async def execute_mcp_tool(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
user_permissions: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute an MCP tool — thin wrapper over existing platform functions.
|
||||
|
||||
MCP possesses no own rights. The existing auth/run-as context and
|
||||
normal permission checks always apply. This function checks the
|
||||
user's permissions before executing the tool.
|
||||
"""
|
||||
tool = get_mcp_tool(tool_name)
|
||||
if tool is None:
|
||||
return {"error": f"Unknown MCP tool: {tool_name}", "status": "not_found"}
|
||||
|
||||
# Permission check — MCP has no own rights
|
||||
required_perm = tool.get("required_permission")
|
||||
if required_perm and user_permissions:
|
||||
from app.core.permissions import check_permission
|
||||
if not check_permission(user_permissions, required_perm):
|
||||
return {"error": f"Permission denied: {required_perm}", "status": "forbidden"}
|
||||
|
||||
handler = tool["handler"]
|
||||
|
||||
try:
|
||||
if handler == "search":
|
||||
from app.ai.integration_tools import search_knowledge_tool
|
||||
return await search_knowledge_tool(
|
||||
db=db, tenant_id=tenant_id, user_id=user_id,
|
||||
query=arguments.get("query", ""),
|
||||
entity_type=arguments.get("entity_type"),
|
||||
limit=arguments.get("limit", 10),
|
||||
)
|
||||
|
||||
elif handler == "ask_knowledge":
|
||||
from app.ai.integration_tools import ask_knowledge_tool
|
||||
return await ask_knowledge_tool(
|
||||
db=db, tenant_id=tenant_id, user_id=user_id,
|
||||
query=arguments.get("query", ""),
|
||||
source_types=arguments.get("source_types"),
|
||||
max_results=arguments.get("max_results", 5),
|
||||
)
|
||||
|
||||
elif handler == "start_workflow":
|
||||
from app.ai.integration_tools import start_workflow_tool
|
||||
return await start_workflow_tool(
|
||||
db=db, tenant_id=tenant_id, user_id=user_id,
|
||||
workflow_id=arguments.get("workflow_id", ""),
|
||||
context=arguments.get("context"),
|
||||
)
|
||||
|
||||
elif handler == "check_workflow_status":
|
||||
from app.ai.integration_tools import check_workflow_status_tool
|
||||
return await check_workflow_status_tool(
|
||||
db=db, tenant_id=tenant_id,
|
||||
instance_id=arguments.get("instance_id", ""),
|
||||
)
|
||||
|
||||
elif handler == "list_agents":
|
||||
# List available agents — thin wrapper
|
||||
from app.plugins.builtins.automation.contracts import AutomationContract
|
||||
contract = AutomationContract
|
||||
list_fn = contract.get_function("list_agents")
|
||||
if list_fn is None:
|
||||
return {"error": "Agents not available", "status": "not_available"}
|
||||
agents = await list_fn(db=db, tenant_id=tenant_id, user_id=user_id)
|
||||
return {"agents": agents or [], "total": len(agents or [])}
|
||||
|
||||
elif handler == "create_task":
|
||||
from app.plugins.builtins.tasks.services import create_task
|
||||
result = await create_task(
|
||||
db=db, tenant_id=tenant_id, user_id=user_id,
|
||||
data=arguments,
|
||||
)
|
||||
return result or {"error": "Failed to create task"}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown handler: {handler}", "status": "not_implemented"}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MCP tool '%s' failed: %s", tool_name, e)
|
||||
return {"error": str(e), "status": "failed"}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MCP_TOOLS",
|
||||
"get_mcp_tools",
|
||||
"get_mcp_tool",
|
||||
"execute_mcp_tool",
|
||||
]
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Feature onboarding — setup wizard backend (I-ONB).
|
||||
|
||||
Provides API endpoints for the setup wizard that guides users through
|
||||
configuring agents, workflows, knowledge, workstreams/miniapps,
|
||||
and proactive collaboration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_onboarding_status(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Get the onboarding progress for a user.
|
||||
|
||||
Returns which steps are completed: agent_created, workflow_created,
|
||||
knowledge_enabled, workstream_enabled, proactive_enabled.
|
||||
"""
|
||||
status: dict[str, Any] = {
|
||||
"steps": {
|
||||
"welcome": {"completed": True, "required": True},
|
||||
"create_agent": {"completed": False, "required": True},
|
||||
"create_workflow": {"completed": False, "required": False},
|
||||
"enable_knowledge": {"completed": False, "required": False},
|
||||
"enable_workstream": {"completed": False, "required": False},
|
||||
},
|
||||
"progress_pct": 20, # Welcome is done
|
||||
}
|
||||
|
||||
# Check if user has created an agent
|
||||
try:
|
||||
from app.models.workflow import AgentDefinition
|
||||
from sqlalchemy import select, func
|
||||
agent_count = await db.scalar(
|
||||
select(func.count(AgentDefinition.id)).where(
|
||||
AgentDefinition.tenant_id == tenant_id,
|
||||
AgentDefinition.created_by == user_id,
|
||||
)
|
||||
)
|
||||
if agent_count and agent_count > 0:
|
||||
status["steps"]["create_agent"]["completed"] = True
|
||||
status["progress_pct"] += 20
|
||||
except Exception as e:
|
||||
logger.warning("Onboarding agent check failed: %s", e)
|
||||
|
||||
# Check if user has created a workflow
|
||||
try:
|
||||
from app.models.workflow import WorkflowDefinition
|
||||
from sqlalchemy import select, func
|
||||
wf_count = await db.scalar(
|
||||
select(func.count(WorkflowDefinition.id)).where(
|
||||
WorkflowDefinition.tenant_id == tenant_id,
|
||||
WorkflowDefinition.created_by == user_id,
|
||||
)
|
||||
)
|
||||
if wf_count and wf_count > 0:
|
||||
status["steps"]["create_workflow"]["completed"] = True
|
||||
status["progress_pct"] += 20
|
||||
except Exception as e:
|
||||
logger.warning("Onboarding workflow check failed: %s", e)
|
||||
|
||||
# Check knowledge (wiki articles)
|
||||
try:
|
||||
from app.plugins.builtins.wiki.models import WikiArticle
|
||||
from sqlalchemy import select, func
|
||||
wiki_count = await db.scalar(
|
||||
select(func.count(WikiArticle.id)).where(
|
||||
WikiArticle.tenant_id == tenant_id,
|
||||
WikiArticle.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if wiki_count and wiki_count > 0:
|
||||
status["steps"]["enable_knowledge"]["completed"] = True
|
||||
status["progress_pct"] += 20
|
||||
except Exception as e:
|
||||
logger.warning("Onboarding knowledge check failed: %s", e)
|
||||
|
||||
# Check workstream (communication messages)
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.models import CommMessage
|
||||
from sqlalchemy import select, func
|
||||
msg_count = await db.scalar(
|
||||
select(func.count(CommMessage.id)).where(
|
||||
CommMessage.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
if msg_count and msg_count > 0:
|
||||
status["steps"]["enable_workstream"]["completed"] = True
|
||||
status["progress_pct"] += 20
|
||||
except Exception as e:
|
||||
logger.warning("Onboarding workstream check failed: %s", e)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def get_onboarding_guide() -> dict[str, Any]:
|
||||
"""Get the onboarding guide content for the setup wizard.
|
||||
|
||||
Returns step-by-step instructions for each onboarding step.
|
||||
"""
|
||||
return {
|
||||
"steps": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"title": "Welcome to LeoCRM",
|
||||
"description": "Get started with your AI-powered CRM platform.",
|
||||
"icon": "sparkles",
|
||||
},
|
||||
{
|
||||
"id": "create_agent",
|
||||
"title": "Create Your First Agent",
|
||||
"description": "Set up an AI agent to help with email triage, contact enrichment, or follow-ups.",
|
||||
"icon": "bot",
|
||||
"action_url": "/agents/new",
|
||||
},
|
||||
{
|
||||
"id": "create_workflow",
|
||||
"title": "Create Your First Workflow",
|
||||
"description": "Automate repetitive tasks with workflows. Start with a template or build your own.",
|
||||
"icon": "workflow",
|
||||
"action_url": "/workflows/new",
|
||||
},
|
||||
{
|
||||
"id": "enable_knowledge",
|
||||
"title": "Enable Knowledge Base",
|
||||
"description": "Create wiki articles and let AI find answers from your company knowledge.",
|
||||
"icon": "book-open",
|
||||
"action_url": "/wiki",
|
||||
},
|
||||
{
|
||||
"id": "enable_workstream",
|
||||
"title": "Enable Human-AI Workstream",
|
||||
"description": "Connect humans, agents, and workflows in a unified communication stream.",
|
||||
"icon": "message-square",
|
||||
"action_url": "/workstream",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_onboarding_status",
|
||||
"get_onboarding_guide",
|
||||
]
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Proactive workstream feed — contextual suggestions and actions (I-WORK-PROACTIVE).
|
||||
|
||||
UI-/Domain-Trigger erzeugen kontextuelle Vorschläge/Actions im Workstream
|
||||
mit Priority, Dedupe, Cooldown und User-Einstellungen. Kein störendes
|
||||
Popup-/Clippy-Verhalten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Priority = Literal["low", "medium", "high", "urgent"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProactiveSuggestion:
|
||||
"""A proactive suggestion/action for the workstream feed."""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
trigger: str = "" # What triggered this (e.g. "mail.received", "contact.created")
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
priority: Priority = "medium"
|
||||
action_type: str = "" # suggestion, action_required, info
|
||||
action_url: str = "" # Deep link to action
|
||||
entity_type: str | None = None
|
||||
entity_id: str | None = None
|
||||
blocks: list[dict[str, Any]] = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
expires_at: datetime | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"trigger": self.trigger,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"priority": self.priority,
|
||||
"action_type": self.action_type,
|
||||
"action_url": self.action_url,
|
||||
"entity_type": self.entity_type,
|
||||
"entity_id": self.entity_id,
|
||||
"blocks": self.blocks,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
# ─── Dedupe + Cooldown ───────────────────────────────────────────────────────
|
||||
|
||||
# In-memory dedupe cache (per-tenant). In production, use Redis.
|
||||
_dedupe_cache: dict[str, dict[str, datetime]] = {}
|
||||
|
||||
# Default cooldown per trigger type (seconds)
|
||||
DEFAULT_COOLDOWNS: dict[str, int] = {
|
||||
"mail.received": 300, # 5 min between suggestions for same mail
|
||||
"contact.created": 600, # 10 min
|
||||
"workflow.completed": 60, # 1 min
|
||||
"agent.result": 120, # 2 min
|
||||
"default": 300, # 5 min default
|
||||
}
|
||||
|
||||
|
||||
def get_cooldown(trigger: str) -> int:
|
||||
"""Get cooldown period for a trigger type."""
|
||||
return DEFAULT_COOLDOWNS.get(trigger, DEFAULT_COOLDOWNS["default"])
|
||||
|
||||
|
||||
def _dedupe_key(tenant_id: uuid.UUID, trigger: str, entity_id: str | None) -> str:
|
||||
"""Build a dedupe key for a suggestion."""
|
||||
return f"{tenant_id}:{trigger}:{entity_id or 'none'}"
|
||||
|
||||
|
||||
def is_cooled_down(tenant_id: uuid.UUID, trigger: str, entity_id: str | None = None) -> bool:
|
||||
"""Check if a trigger is still in cooldown (should not produce new suggestions)."""
|
||||
key = _dedupe_key(tenant_id, trigger, entity_id)
|
||||
tenant_cache = _dedupe_cache.get(str(tenant_id), {})
|
||||
last_seen = tenant_cache.get(key)
|
||||
if last_seen is None:
|
||||
return False
|
||||
cooldown = get_cooldown(trigger)
|
||||
return datetime.now(UTC) - last_seen < timedelta(seconds=cooldown)
|
||||
|
||||
|
||||
def mark_suggested(tenant_id: uuid.UUID, trigger: str, entity_id: str | None = None) -> None:
|
||||
"""Mark a trigger as having produced a suggestion (for cooldown tracking)."""
|
||||
key = _dedupe_key(tenant_id, trigger, entity_id)
|
||||
tenant_id_str = str(tenant_id)
|
||||
if tenant_id_str not in _dedupe_cache:
|
||||
_dedupe_cache[tenant_id_str] = {}
|
||||
_dedupe_cache[tenant_id_str][key] = datetime.now(UTC)
|
||||
|
||||
|
||||
# ─── Suggestion Generators ───────────────────────────────────────────────────
|
||||
|
||||
async def generate_suggestions(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
trigger: str,
|
||||
payload: dict[str, Any],
|
||||
) -> list[ProactiveSuggestion]:
|
||||
"""Generate proactive suggestions for a trigger event (I-WORK-PROACTIVE).
|
||||
|
||||
Checks cooldown, generates suggestions, and marks them as suggested.
|
||||
Returns a list of ProactiveSuggestion objects.
|
||||
"""
|
||||
entity_id = payload.get("entity_id") or payload.get("contact_id") or payload.get("message_id")
|
||||
|
||||
# Check cooldown — don't spam
|
||||
if is_cooled_down(tenant_id, trigger, entity_id):
|
||||
return []
|
||||
|
||||
suggestions: list[ProactiveSuggestion] = []
|
||||
|
||||
# Generate based on trigger type
|
||||
if trigger == "mail.received":
|
||||
suggestions.append(ProactiveSuggestion(
|
||||
trigger=trigger,
|
||||
title="New email received",
|
||||
description=f"You received a new email from {payload.get('sender', 'unknown')}",
|
||||
priority="medium",
|
||||
action_type="info",
|
||||
action_url=f"/mail/messages/{entity_id}" if entity_id else "",
|
||||
entity_type="mail",
|
||||
entity_id=entity_id,
|
||||
blocks=[],
|
||||
))
|
||||
|
||||
elif trigger == "contact.created":
|
||||
suggestions.append(ProactiveSuggestion(
|
||||
trigger=trigger,
|
||||
title="New contact created",
|
||||
description=f"New contact: {payload.get('name', 'Unknown')}",
|
||||
priority="low",
|
||||
action_type="suggestion",
|
||||
action_url=f"/contacts/{entity_id}" if entity_id else "",
|
||||
entity_type="contact",
|
||||
entity_id=entity_id,
|
||||
))
|
||||
|
||||
elif trigger == "workflow.completed":
|
||||
suggestions.append(ProactiveSuggestion(
|
||||
trigger=trigger,
|
||||
title="Workflow completed",
|
||||
description=f"Workflow '{payload.get('workflow_name', 'Unknown')}' has been completed.",
|
||||
priority="medium",
|
||||
action_type="info",
|
||||
action_url=f"/workflows/instances/{entity_id}" if entity_id else "",
|
||||
entity_type="workflow_instance",
|
||||
entity_id=entity_id,
|
||||
))
|
||||
|
||||
elif trigger == "agent.result":
|
||||
suggestions.append(ProactiveSuggestion(
|
||||
trigger=trigger,
|
||||
title="Agent completed task",
|
||||
description=f"Agent finished: {payload.get('summary', 'Task completed')}",
|
||||
priority="medium",
|
||||
action_type="action_required",
|
||||
action_url=f"/agents/runs/{entity_id}" if entity_id else "",
|
||||
entity_type="agent_run",
|
||||
entity_id=entity_id,
|
||||
))
|
||||
|
||||
# Mark as suggested (cooldown)
|
||||
if suggestions:
|
||||
mark_suggested(tenant_id, trigger, entity_id)
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
# ─── User Settings ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_user_proactive_settings(user_id: uuid.UUID) -> dict[str, Any]:
|
||||
"""Get proactive feed settings for a user.
|
||||
|
||||
In production, this would load from DB/user preferences.
|
||||
For now, returns defaults.
|
||||
"""
|
||||
return {
|
||||
"enabled": True,
|
||||
"min_priority": "low", # Don't show suggestions below this priority
|
||||
"max_per_hour": 20, # Rate limit suggestions per hour
|
||||
"triggers_enabled": {
|
||||
"mail.received": True,
|
||||
"contact.created": True,
|
||||
"workflow.completed": True,
|
||||
"agent.result": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def filter_by_user_settings(
|
||||
suggestions: list[ProactiveSuggestion],
|
||||
settings: dict[str, Any],
|
||||
) -> list[ProactiveSuggestion]:
|
||||
"""Filter suggestions by user settings."""
|
||||
if not settings.get("enabled", True):
|
||||
return []
|
||||
|
||||
min_priority = settings.get("min_priority", "low")
|
||||
priority_order = {"low": 0, "medium": 1, "high": 2, "urgent": 3}
|
||||
min_level = priority_order.get(min_priority, 0)
|
||||
|
||||
triggers_enabled = settings.get("triggers_enabled", {})
|
||||
|
||||
return [
|
||||
s for s in suggestions
|
||||
if priority_order.get(s.priority, 0) >= min_level
|
||||
and triggers_enabled.get(s.trigger, True)
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProactiveSuggestion",
|
||||
"generate_suggestions",
|
||||
"is_cooled_down",
|
||||
"mark_suggested",
|
||||
"get_cooldown",
|
||||
"get_user_proactive_settings",
|
||||
"filter_by_user_settings",
|
||||
"DEFAULT_COOLDOWNS",
|
||||
]
|
||||
@@ -1,659 +0,0 @@
|
||||
"""Controlled self-improvement system (Phase J).
|
||||
|
||||
Implements the improvement loop:
|
||||
Observe → Detect Patterns → Propose → Draft → Evaluate →
|
||||
Human Approval → Activate → Measure → Keep/Rollback
|
||||
|
||||
No autonomous production code changes. All improvements go through
|
||||
versioned drafts, evaluation, and human approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Enums ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ProposalType(str, Enum):
|
||||
AGENT = "agent"
|
||||
SKILL = "skill"
|
||||
TRIGGER = "trigger"
|
||||
WORKFLOW = "workflow"
|
||||
MINIAPP_TEMPLATE = "miniapp_template"
|
||||
PLUGIN_PATCH = "plugin_patch"
|
||||
|
||||
|
||||
class ProposalStatus(str, Enum):
|
||||
DRAFT = "draft"
|
||||
EVALUATING = "evaluating"
|
||||
PENDING_APPROVAL = "pending_approval"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
ACTIVE = "active"
|
||||
ROLLED_BACK = "rolled_back"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class SignalType(str, Enum):
|
||||
AGENT_RUN = "agent_run"
|
||||
WORKFLOW_RUN = "workflow_run"
|
||||
PROACTIVE_SUGGESTION = "proactive_suggestion"
|
||||
AUDIT_LOG = "audit_log"
|
||||
ENTITY_HISTORY = "entity_history"
|
||||
USER_CORRECTION = "user_correction"
|
||||
HANDOFF = "handoff"
|
||||
ERROR_RETRY = "error_retry"
|
||||
|
||||
|
||||
# ─── J-SIGNAL: Improvement Signals ───────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImprovementSignal:
|
||||
"""A referenced signal from platform usage data (J-SIGNAL).
|
||||
|
||||
Uses references/aggregates instead of full PII copies.
|
||||
Data minimization/exposure-policy/retention apply.
|
||||
"""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
signal_type: SignalType = SignalType.AGENT_RUN
|
||||
source_ref: str = "" # Reference to source (e.g. "agent_run:uuid")
|
||||
tenant_id: str = ""
|
||||
user_id: str | None = None
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
outcome: str = "" # success, failure, corrected, dismissed, accepted
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"signal_type": self.signal_type.value,
|
||||
"source_ref": self.source_ref,
|
||||
"tenant_id": self.tenant_id,
|
||||
"user_id": self.user_id,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"outcome": self.outcome,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
async def collect_signals(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
days: int = 30,
|
||||
) -> list[ImprovementSignal]:
|
||||
"""Collect improvement signals from platform usage data (J-SIGNAL).
|
||||
|
||||
Aggregates signals from AgentRuns, WorkflowRuns, AuditLog, and
|
||||
Proactive Suggestions. Uses references, not full PII copies.
|
||||
"""
|
||||
since = datetime.now(UTC) - timedelta(days=days)
|
||||
signals: list[ImprovementSignal] = []
|
||||
|
||||
# Agent run signals
|
||||
try:
|
||||
from app.models.workflow import AgentRun
|
||||
result = await db.execute(
|
||||
select(AgentRun).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since,
|
||||
).limit(200)
|
||||
)
|
||||
for run in result.scalars().all():
|
||||
signals.append(ImprovementSignal(
|
||||
signal_type=SignalType.AGENT_RUN,
|
||||
source_ref=f"agent_run:{run.id}",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=str(run.user_id) if run.user_id else None,
|
||||
timestamp=run.created_at or datetime.now(UTC),
|
||||
outcome=run.status or "unknown",
|
||||
metadata={"agent_id": str(run.agent_id) if run.agent_id else None, "cost_usd": float(run.total_cost_usd or 0)},
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("Signal collection (agent_runs) failed: %s", e)
|
||||
|
||||
# Workflow instance signals
|
||||
try:
|
||||
from app.models.workflow import WorkflowInstance
|
||||
result = await db.execute(
|
||||
select(WorkflowInstance).where(
|
||||
WorkflowInstance.tenant_id == tenant_id,
|
||||
WorkflowInstance.created_at >= since,
|
||||
).limit(200)
|
||||
)
|
||||
for inst in result.scalars().all():
|
||||
signals.append(ImprovementSignal(
|
||||
signal_type=SignalType.WORKFLOW_RUN,
|
||||
source_ref=f"workflow_instance:{inst.id}",
|
||||
tenant_id=str(tenant_id),
|
||||
timestamp=inst.created_at or datetime.now(UTC),
|
||||
outcome=inst.status or "unknown",
|
||||
metadata={"workflow_id": str(inst.workflow_id) if inst.workflow_id else None},
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("Signal collection (workflow_instances) failed: %s", e)
|
||||
|
||||
# Audit log signals (user corrections)
|
||||
try:
|
||||
from app.models.audit import AuditLog
|
||||
result = await db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.tenant_id == tenant_id,
|
||||
AuditLog.created_at >= since,
|
||||
AuditLog.action.like("%.correct%"),
|
||||
).limit(100)
|
||||
)
|
||||
for entry in result.scalars().all():
|
||||
signals.append(ImprovementSignal(
|
||||
signal_type=SignalType.USER_CORRECTION,
|
||||
source_ref=f"audit:{entry.id}",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=str(entry.user_id) if entry.user_id else None,
|
||||
timestamp=entry.created_at or datetime.now(UTC),
|
||||
outcome="corrected",
|
||||
metadata={"action": entry.action},
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("Signal collection (audit) failed: %s", e)
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
# ─── J-PATTERN: Pattern/Bottleneck Detection ────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectedPattern:
|
||||
"""A detected pattern or bottleneck from signals (J-PATTERN)."""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
pattern_type: str = "" # repetitive_sequence, frequent_corrections, rejected_suggestions, error_retries, repetitive_handoffs
|
||||
description: str = ""
|
||||
confidence: float = 0.0
|
||||
occurrence_count: int = 0
|
||||
evidence_refs: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"pattern_type": self.pattern_type,
|
||||
"description": self.description,
|
||||
"confidence": self.confidence,
|
||||
"occurrence_count": self.occurrence_count,
|
||||
"evidence_refs": self.evidence_refs,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
def detect_patterns(signals: list[ImprovementSignal]) -> list[DetectedPattern]:
|
||||
"""Detect patterns and bottlenecks from signals (J-PATTERN).
|
||||
|
||||
Identifies: repetitive sequences, frequent corrections,
|
||||
rejected suggestions, retries/errors, repetitive handoffs.
|
||||
"""
|
||||
patterns: list[DetectedPattern] = []
|
||||
|
||||
# Group signals by type and outcome
|
||||
by_type: dict[str, list[ImprovementSignal]] = {}
|
||||
for s in signals:
|
||||
key = f"{s.signal_type.value}:{s.outcome}"
|
||||
by_type.setdefault(key, []).append(s)
|
||||
|
||||
# Detect frequent errors/retries
|
||||
error_signals = [s for s in signals if s.outcome in ("stopped_error", "stopped_timeout", "failed")]
|
||||
if len(error_signals) >= 3:
|
||||
patterns.append(DetectedPattern(
|
||||
pattern_type="error_retries",
|
||||
description=f"{len(error_signals)} failed agent/workflow runs detected",
|
||||
confidence=min(0.9, len(error_signals) / 20),
|
||||
occurrence_count=len(error_signals),
|
||||
evidence_refs=[s.source_ref for s in error_signals[:10]],
|
||||
metadata={"avg_per_day": len(error_signals) / 30 if len(error_signals) > 0 else 0},
|
||||
))
|
||||
|
||||
# Detect frequent user corrections
|
||||
correction_signals = [s for s in signals if s.signal_type == SignalType.USER_CORRECTION]
|
||||
if len(correction_signals) >= 3:
|
||||
patterns.append(DetectedPattern(
|
||||
pattern_type="frequent_corrections",
|
||||
description=f"{len(correction_signals)} user corrections detected — agents may need tuning",
|
||||
confidence=min(0.85, len(correction_signals) / 15),
|
||||
occurrence_count=len(correction_signals),
|
||||
evidence_refs=[s.source_ref for s in correction_signals[:10]],
|
||||
))
|
||||
|
||||
# Detect repetitive handoffs
|
||||
handoff_signals = [s for s in signals if s.signal_type == SignalType.HANDOFF]
|
||||
if len(handoff_signals) >= 3:
|
||||
patterns.append(DetectedPattern(
|
||||
pattern_type="repetitive_handoffs",
|
||||
description=f"{len(handoff_signals)} handoffs detected — workflow may need automation",
|
||||
confidence=min(0.8, len(handoff_signals) / 10),
|
||||
occurrence_count=len(handoff_signals),
|
||||
evidence_refs=[s.source_ref for s in handoff_signals[:10]],
|
||||
))
|
||||
|
||||
# Detect dismissed proactive suggestions
|
||||
dismissed = [s for s in signals if s.signal_type == SignalType.PROACTIVE_SUGGESTION and s.outcome == "dismissed"]
|
||||
if len(dismissed) >= 5:
|
||||
patterns.append(DetectedPattern(
|
||||
pattern_type="rejected_suggestions",
|
||||
description=f"{len(dismissed)} proactive suggestions dismissed — suggestions may be too frequent or irrelevant",
|
||||
confidence=min(0.75, len(dismissed) / 20),
|
||||
occurrence_count=len(dismissed),
|
||||
evidence_refs=[s.source_ref for s in dismissed[:10]],
|
||||
))
|
||||
|
||||
return patterns
|
||||
|
||||
|
||||
# ─── J-PROP: ImprovementProposal ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImprovementProposal:
|
||||
"""An improvement proposal with evidence and status (J-PROP)."""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
proposal_type: ProposalType = ProposalType.AGENT
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
rationale: str = ""
|
||||
expected_benefit: str = ""
|
||||
risk_assessment: str = ""
|
||||
status: ProposalStatus = ProposalStatus.DRAFT
|
||||
evidence_refs: list[str] = field(default_factory=list)
|
||||
pattern_refs: list[str] = field(default_factory=list)
|
||||
draft_config: dict[str, Any] = field(default_factory=dict)
|
||||
evaluation_result: dict[str, Any] = field(default_factory=dict)
|
||||
measurement_before: dict[str, Any] = field(default_factory=dict)
|
||||
measurement_after: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
approved_by: str | None = None
|
||||
activated_at: datetime | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"proposal_type": self.proposal_type.value,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"rationale": self.rationale,
|
||||
"expected_benefit": self.expected_benefit,
|
||||
"risk_assessment": self.risk_assessment,
|
||||
"status": self.status.value,
|
||||
"evidence_refs": self.evidence_refs,
|
||||
"pattern_refs": self.pattern_refs,
|
||||
"draft_config": self.draft_config,
|
||||
"evaluation_result": self.evaluation_result,
|
||||
"measurement_before": self.measurement_before,
|
||||
"measurement_after": self.measurement_after,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"approved_by": self.approved_by,
|
||||
"activated_at": self.activated_at.isoformat() if self.activated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def create_proposal(
|
||||
pattern: DetectedPattern,
|
||||
proposal_type: ProposalType = ProposalType.AGENT,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
draft_config: dict[str, Any] | None = None,
|
||||
) -> ImprovementProposal:
|
||||
"""Create an improvement proposal from a detected pattern (J-PROP)."""
|
||||
return ImprovementProposal(
|
||||
proposal_type=proposal_type,
|
||||
title=title or f"Improve: {pattern.pattern_type}",
|
||||
description=description or pattern.description,
|
||||
rationale=f"Based on {pattern.occurrence_count} occurrences with {pattern.confidence:.0%} confidence",
|
||||
expected_benefit="Reduce manual effort and improve accuracy",
|
||||
risk_assessment="Low — versioned draft with rollback capability",
|
||||
evidence_refs=pattern.evidence_refs,
|
||||
pattern_refs=[pattern.id],
|
||||
draft_config=draft_config or {},
|
||||
)
|
||||
|
||||
|
||||
# ─── J-DRAFT: Versioned Draft ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class VersionedDraft:
|
||||
"""A versioned draft of an agent/workflow/skill config (J-DRAFT)."""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
proposal_id: str = ""
|
||||
version: int = 1
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
previous_version_id: str | None = None
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"proposal_id": self.proposal_id,
|
||||
"version": self.version,
|
||||
"config": self.config,
|
||||
"previous_version_id": self.previous_version_id,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def create_draft(proposal: ImprovementProposal, previous_draft: VersionedDraft | None = None) -> VersionedDraft:
|
||||
"""Create a versioned draft from a proposal (J-DRAFT)."""
|
||||
version = (previous_draft.version + 1) if previous_draft else 1
|
||||
return VersionedDraft(
|
||||
proposal_id=proposal.id,
|
||||
version=version,
|
||||
config=proposal.draft_config,
|
||||
previous_version_id=previous_draft.id if previous_draft else None,
|
||||
)
|
||||
|
||||
|
||||
# ─── J-EVAL: Evaluation/Sandbox ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def evaluate_proposal(
|
||||
proposal: ImprovementProposal,
|
||||
draft: VersionedDraft,
|
||||
historical_signals: list[ImprovementSignal] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Evaluate a proposal via dry-run/simulation (J-EVAL).
|
||||
|
||||
No external side effects. Tests against historical/synthetic cases.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"proposal_id": proposal.id,
|
||||
"draft_id": draft.id,
|
||||
"evaluated_at": datetime.now(UTC).isoformat(),
|
||||
"test_cases": 0,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"score": 0.0,
|
||||
"recommendation": "",
|
||||
"details": [],
|
||||
}
|
||||
|
||||
# Simulate against historical signals
|
||||
test_signals = historical_signals or []
|
||||
result["test_cases"] = len(test_signals)
|
||||
|
||||
for signal in test_signals:
|
||||
# Simulate: would the new config have handled this better?
|
||||
# This is a simplified evaluation — real implementation would
|
||||
# replay the signal through the new config
|
||||
if signal.outcome in ("stopped_error", "stopped_timeout", "failed"):
|
||||
# Assume new config would fix 60% of errors
|
||||
result["passed"] += 1
|
||||
else:
|
||||
result["passed"] += 1
|
||||
|
||||
result["failed"] = result["test_cases"] - result["passed"]
|
||||
result["score"] = (result["passed"] / result["test_cases"] * 100) if result["test_cases"] > 0 else 0.0
|
||||
|
||||
if result["score"] >= 80:
|
||||
result["recommendation"] = "approve"
|
||||
elif result["score"] >= 60:
|
||||
result["recommendation"] = "approve_with_caution"
|
||||
else:
|
||||
result["recommendation"] = "reject"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─── J-APPROVAL: Human Approval ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def request_approval(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
proposal: ImprovementProposal,
|
||||
evaluation: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Request human approval for a proposal (J-APPROVAL).
|
||||
|
||||
Uses the central ApprovalRequest system. The approver sees
|
||||
evidence, diff, tests, and expected impact.
|
||||
"""
|
||||
try:
|
||||
from app.core.approval import create_approval_request
|
||||
approval = await create_approval_request(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="improvement_proposal",
|
||||
entity_id=uuid.UUID(proposal.id) if _is_valid_uuid(proposal.id) else uuid.uuid4(),
|
||||
action=f"activate:{proposal.proposal_type.value}",
|
||||
requested_by=user_id,
|
||||
requested_by_type="system",
|
||||
)
|
||||
proposal.status = ProposalStatus.PENDING_APPROVAL
|
||||
proposal.updated_at = datetime.now(UTC)
|
||||
return {
|
||||
"approval_id": str(approval.id),
|
||||
"proposal_id": proposal.id,
|
||||
"status": "pending_approval",
|
||||
"evaluation": evaluation,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Approval request failed: %s", e)
|
||||
return {"error": str(e), "status": "failed"}
|
||||
|
||||
|
||||
def _is_valid_uuid(s: str) -> bool:
|
||||
try:
|
||||
uuid.UUID(s)
|
||||
return True
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
# ─── J-ACTIVATE: Controlled Activate + Rollback ──────────────────────────────
|
||||
|
||||
|
||||
async def activate_proposal(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
proposal: ImprovementProposal,
|
||||
draft: VersionedDraft,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically activate an approved proposal (J-ACTIVATE).
|
||||
|
||||
Previous version remains rollback-capable.
|
||||
"""
|
||||
if proposal.status != ProposalStatus.APPROVED:
|
||||
return {"error": "Proposal must be approved before activation", "status": "rejected"}
|
||||
|
||||
try:
|
||||
# Apply the draft config to the target system
|
||||
# This would update the agent/workflow/skill definition
|
||||
proposal.status = ProposalStatus.ACTIVE
|
||||
proposal.activated_at = datetime.now(UTC)
|
||||
proposal.updated_at = datetime.now(UTC)
|
||||
|
||||
return {
|
||||
"proposal_id": proposal.id,
|
||||
"draft_id": draft.id,
|
||||
"status": "active",
|
||||
"activated_at": proposal.activated_at.isoformat(),
|
||||
"rollback_available": True,
|
||||
"previous_version_id": draft.previous_version_id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Activation failed: %s", e)
|
||||
return {"error": str(e), "status": "failed"}
|
||||
|
||||
|
||||
async def rollback_proposal(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
proposal: ImprovementProposal,
|
||||
previous_draft: VersionedDraft | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rollback an active proposal to its previous version (J-ACTIVATE)."""
|
||||
if proposal.status != ProposalStatus.ACTIVE:
|
||||
return {"error": "Only active proposals can be rolled back", "status": "rejected"}
|
||||
|
||||
try:
|
||||
proposal.status = ProposalStatus.ROLLED_BACK
|
||||
proposal.updated_at = datetime.now(UTC)
|
||||
|
||||
return {
|
||||
"proposal_id": proposal.id,
|
||||
"status": "rolled_back",
|
||||
"previous_version_id": previous_draft.id if previous_draft else None,
|
||||
"rolled_back_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Rollback failed: %s", e)
|
||||
return {"error": str(e), "status": "failed"}
|
||||
|
||||
|
||||
# ─── J-MEASURE: Pre/Post Impact Measurement ─────────────────────────────────
|
||||
|
||||
|
||||
async def measure_impact(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
proposal: ImprovementProposal,
|
||||
days: int = 7,
|
||||
) -> dict[str, Any]:
|
||||
"""Measure pre/post impact of an activated proposal (J-MEASURE).
|
||||
|
||||
Compares time, errors, acceptance rate, cost, throughput,
|
||||
and business outcome metrics.
|
||||
"""
|
||||
if not proposal.activated_at:
|
||||
return {"error": "Proposal has not been activated", "status": "not_active"}
|
||||
|
||||
since_activation = proposal.activated_at
|
||||
before_start = since_activation - timedelta(days=days)
|
||||
|
||||
measurement: dict[str, Any] = {
|
||||
"proposal_id": proposal.id,
|
||||
"measured_at": datetime.now(UTC).isoformat(),
|
||||
"period_days": days,
|
||||
"before": proposal.measurement_before,
|
||||
"after": {},
|
||||
"delta": {},
|
||||
}
|
||||
|
||||
# Collect post-activation metrics
|
||||
try:
|
||||
from app.models.workflow import AgentRun
|
||||
|
||||
# Post-activation metrics
|
||||
post_runs = await db.scalar(
|
||||
select(func.count(AgentRun.id)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since_activation,
|
||||
)
|
||||
)
|
||||
post_errors = await db.scalar(
|
||||
select(func.count(AgentRun.id)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since_activation,
|
||||
AgentRun.status.in_(["stopped_error", "stopped_timeout"]),
|
||||
)
|
||||
)
|
||||
post_cost = await db.scalar(
|
||||
select(func.sum(AgentRun.total_cost_usd)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since_activation,
|
||||
)
|
||||
)
|
||||
|
||||
measurement["after"] = {
|
||||
"total_runs": post_runs or 0,
|
||||
"errors": post_errors or 0,
|
||||
"cost_usd": float(post_cost or 0),
|
||||
"error_rate": (post_errors / post_runs * 100) if post_runs else 0.0,
|
||||
}
|
||||
|
||||
# Calculate delta
|
||||
before = proposal.measurement_before
|
||||
if before:
|
||||
measurement["delta"] = {
|
||||
"runs_change": (post_runs or 0) - before.get("total_runs", 0),
|
||||
"errors_change": (post_errors or 0) - before.get("errors", 0),
|
||||
"cost_change": float(post_cost or 0) - before.get("cost_usd", 0),
|
||||
"error_rate_change": ((post_errors / post_runs * 100) if post_runs else 0) - before.get("error_rate", 0),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
measurement["error"] = str(e)
|
||||
|
||||
return measurement
|
||||
|
||||
|
||||
async def capture_baseline(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
days: int = 7,
|
||||
) -> dict[str, Any]:
|
||||
"""Capture baseline metrics before activating a proposal (J-MEASURE)."""
|
||||
since = datetime.now(UTC) - timedelta(days=days)
|
||||
try:
|
||||
from app.models.workflow import AgentRun
|
||||
total_runs = await db.scalar(
|
||||
select(func.count(AgentRun.id)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since,
|
||||
)
|
||||
)
|
||||
errors = await db.scalar(
|
||||
select(func.count(AgentRun.id)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since,
|
||||
AgentRun.status.in_(["stopped_error", "stopped_timeout"]),
|
||||
)
|
||||
)
|
||||
cost = await db.scalar(
|
||||
select(func.sum(AgentRun.total_cost_usd)).where(
|
||||
AgentRun.tenant_id == tenant_id,
|
||||
AgentRun.created_at >= since,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"total_runs": total_runs or 0,
|
||||
"errors": errors or 0,
|
||||
"cost_usd": float(cost or 0),
|
||||
"error_rate": (errors / total_runs * 100) if total_runs else 0.0,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProposalType",
|
||||
"ProposalStatus",
|
||||
"SignalType",
|
||||
"ImprovementSignal",
|
||||
"DetectedPattern",
|
||||
"ImprovementProposal",
|
||||
"VersionedDraft",
|
||||
"collect_signals",
|
||||
"detect_patterns",
|
||||
"create_proposal",
|
||||
"create_draft",
|
||||
"evaluate_proposal",
|
||||
"request_approval",
|
||||
"activate_proposal",
|
||||
"rollback_proposal",
|
||||
"measure_impact",
|
||||
"capture_baseline",
|
||||
]
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Workstream contract — unified posting path for Human/System/Agent/Workflow (I-WORK-BASE, I-WORK-ACTOR, I-WORK-HANDOFF).
|
||||
|
||||
Defines the contract for posting typed blocks to the central Communication
|
||||
system. All actors use the same posting path with typed blocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ActorType = Literal["human", "system", "agent", "workflow"]
|
||||
BlockType = Literal["text", "entity_card", "action_card", "evidence_card", "approval_card", "miniapp", "workflow_status", "workflow_handoff", "error"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkstreamBlock:
|
||||
type: BlockType
|
||||
content: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"type": self.type, "content": self.content, "metadata": self.metadata}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkstreamMessage:
|
||||
actor_type: ActorType
|
||||
actor_id: str | None = None
|
||||
content: str = ""
|
||||
blocks: list[WorkstreamBlock] = field(default_factory=list)
|
||||
conversation_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"actor_type": self.actor_type,
|
||||
"actor_id": self.actor_id,
|
||||
"content": self.content,
|
||||
"blocks": [b.to_dict() for b in self.blocks],
|
||||
"conversation_id": self.conversation_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
||||
|
||||
|
||||
async def post_to_workstream(db: AsyncSession, tenant_id: uuid.UUID, message: WorkstreamMessage) -> dict[str, Any] | None:
|
||||
"""Post a message to the central workstream (I-WORK-ACTOR)."""
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.contracts import KommunikationContract
|
||||
contract = KommunikationContract
|
||||
post_fn = contract.get_function("post_message")
|
||||
if post_fn is None:
|
||||
if message.actor_type == "human" and message.actor_id:
|
||||
from app.core.notifications import post_system_message
|
||||
await post_system_message(db, tenant_id, uuid.UUID(message.actor_id), "workstream", message.content[:200], message.content)
|
||||
return None
|
||||
return await post_fn(db=db, tenant_id=tenant_id, sender_id=uuid.UUID(message.actor_id) if message.actor_id else None, sender_type=message.actor_type, message_type=f"workstream_{message.blocks[0].type}" if message.blocks else "workstream_text", content=message.content, blocks=[b.to_dict() for b in message.blocks], conversation_id=message.conversation_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to post to workstream: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def create_handoff(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, *, handoff_type: str, assignee_type: str = "user", assignee_id: str | None = None, entity_type: str | None = None, entity_id: str | None = None, description: str = "", agent_run_id: str | None = None, workflow_instance_id: str | None = None, conversation_id: str | None = None) -> dict[str, Any]:
|
||||
"""Create a Human<->Agent handoff (I-WORK-HANDOFF). Creates a Task with task_type='handoff'."""
|
||||
from app.plugins.builtins.tasks.services import create_task
|
||||
task_data: dict[str, Any] = {"title": f"Handoff: {handoff_type}", "description": description, "task_type": "handoff", "assignee_type": assignee_type, "assignee_id": assignee_id, "entity_type": entity_type, "entity_id": entity_id, "status": "open", "priority": "medium"}
|
||||
task = await create_task(db, tenant_id, user_id, task_data)
|
||||
handoff_block = WorkstreamBlock(type="workflow_handoff", content=description, metadata={"handoff_type": handoff_type, "task_id": task.get("id") if task else None, "assignee_type": assignee_type, "assignee_id": assignee_id, "entity_type": entity_type, "entity_id": entity_id, "agent_run_id": agent_run_id, "workflow_instance_id": workflow_instance_id})
|
||||
message = WorkstreamMessage(actor_type="system", content=f"Handoff: {handoff_type} - {description}", blocks=[handoff_block], conversation_id=conversation_id, tenant_id=str(tenant_id))
|
||||
post_result = await post_to_workstream(db, tenant_id, message)
|
||||
return {"task": task, "workstream_post": post_result, "handoff_type": handoff_type}
|
||||
|
||||
|
||||
def build_entity_card(entity_type: str, entity_id: str, title: str = "", subtitle: str = "", url: str = "") -> WorkstreamBlock:
|
||||
return WorkstreamBlock(type="entity_card", content=title, metadata={"entity_type": entity_type, "entity_id": entity_id, "title": title, "subtitle": subtitle, "url": url})
|
||||
|
||||
|
||||
def build_action_card(title: str, actions: list[dict[str, str]], description: str = "") -> WorkstreamBlock:
|
||||
return WorkstreamBlock(type="action_card", content=title, metadata={"title": title, "description": description, "actions": actions})
|
||||
|
||||
|
||||
def build_evidence_card(source_type: str, source_id: str, title: str, snippet: str = "", url: str = "", confidence: float = 0.0) -> WorkstreamBlock:
|
||||
return WorkstreamBlock(type="evidence_card", content=title, metadata={"source_type": source_type, "source_id": source_id, "title": title, "snippet": snippet[:200], "url": url, "confidence": confidence})
|
||||
|
||||
|
||||
def build_approval_card(approval_id: str, action: str, description: str = "") -> WorkstreamBlock:
|
||||
return WorkstreamBlock(type="approval_card", content=f"Approval needed: {action}", metadata={"approval_id": approval_id, "action": action, "description": description})
|
||||
|
||||
|
||||
def build_miniapp_block(app_id: str, title: str = "", render_schema: dict[str, Any] | None = None) -> WorkstreamBlock:
|
||||
return WorkstreamBlock(type="miniapp", content=title, metadata={"app_id": app_id, "title": title, "render_schema": render_schema or {}})
|
||||
|
||||
|
||||
__all__ = ["ActorType", "BlockType", "WorkstreamBlock", "WorkstreamMessage", "post_to_workstream", "create_handoff", "build_entity_card", "build_action_card", "build_evidence_card", "build_approval_card", "build_miniapp_block"]
|
||||
Reference in New Issue
Block a user