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:
Agent Zero
2026-08-19 09:47:20 +02:00
parent 9e37c41871
commit 7d86592e54
35 changed files with 4 additions and 7129 deletions
-201
View File
@@ -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,
)
-300
View File
@@ -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",
]
-346
View File
@@ -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",
]
-217
View File
@@ -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",
]
-361
View File
@@ -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",
]
-447
View File
@@ -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",
]
-259
View File
@@ -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",
]
-226
View File
@@ -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",
]
-154
View File
@@ -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",
]
-236
View File
@@ -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",
]
-659
View File
@@ -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",
]
-100
View File
@@ -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"]
-2
View File
@@ -73,7 +73,6 @@ from app.routes import ( # noqa: E402
webhooks,
workflows,
workspaces,
platform,
)
# ── Graceful shutdown signal ─────────────────────────────────────────────────
@@ -581,7 +580,6 @@ def create_app() -> FastAPI:
app.include_router(outbox.router)
app.include_router(api_tokens.router)
app.include_router(approvals.router)
app.include_router(platform.router)
# ── Register plugin routes for all discovered plugins ──
# Routes are registered at app creation time so OpenAPI docs are complete.
-161
View File
@@ -1,161 +0,0 @@
"""Platform routes — dashboard, onboarding, improvement, and DSGVO endpoints.
Phase G-J platform-level API routes for platform dashboard, cost tracking,
usage analytics, onboarding status, improvement proposals/patterns,
and DSGVO data export.
"""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user
router = APIRouter(prefix="/api/v1", tags=["platform"])
# ── Platform Dashboard ──────────────────────────────────────────────────────
@router.get("/dashboard/platform")
async def get_platform_dashboard(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Get platform-level dashboard data: agents, workflows, search, knowledge, cost."""
return {
"agents": {"active": 0, "total_runs": 0, "recent_runs_7d": 0},
"workflows": {"active": 0, "running_instances": 0, "completed_instances": 0},
"search": {"total_queries": 0, "avg_latency_ms": 0},
"knowledge": {"wiki_articles": 0, "coverage": 0},
"cost": {"total_cost_30d": 0, "budget_utilization": 0},
"system_health": "healthy",
}
@router.get("/dashboard/cost")
async def get_cost_dashboard(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Get cost tracking dashboard data."""
return {
"total_cost_30d": 0,
"budget_utilization": 0,
"by_service": {},
"trend": [],
}
@router.get("/dashboard/usage")
async def get_usage_analytics(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Get usage analytics data."""
return {
"active_users": 0,
"total_requests": 0,
"by_endpoint": {},
"trend": [],
}
# ── Onboarding ──────────────────────────────────────────────────────────────
@router.get("/onboarding/status")
async def get_onboarding_status(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Get onboarding status for the current tenant."""
return {
"completed": False,
"steps": {
"welcome": True,
"first_agent": False,
"first_workflow": False,
"knowledge_workstream": False,
},
}
@router.get("/onboarding/guide")
async def get_onboarding_guide(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Get onboarding guide content."""
return {
"steps": [
{"id": "welcome", "title": "Welcome", "description": "Get started with LeoCRM"},
{"id": "first_agent", "title": "Create your first Agent", "description": "Set up an AI agent"},
{"id": "first_workflow", "title": "Create your first Workflow", "description": "Automate a process"},
{"id": "knowledge", "title": "Enable Knowledge & Workstream", "description": "Connect knowledge sources"},
],
}
# ── Improvement ─────────────────────────────────────────────────────────────
@router.get("/improvement/proposals")
async def list_improvement_proposals(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> list[dict[str, Any]]:
"""List improvement proposals (stub — returns empty list)."""
return []
@router.get("/improvement/patterns")
async def list_improvement_patterns(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> list[dict[str, Any]]:
"""List detected improvement patterns (stub — returns empty list)."""
return []
# ── DSGVO ───────────────────────────────────────────────────────────────────
@router.get("/dsgvo/export/{user_id}")
async def export_user_data(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Export all data associated with a user (DSGVO/GDPR right to data portability)."""
return {
"user_id": str(user_id),
"exported_by": current_user.get("user_id"),
"data": {
"contacts": [],
"companies": [],
"emails": [],
"documents": [],
"calendar_events": [],
"tasks": [],
"audit_logs": [],
},
}
@router.get("/dsgvo/compliance-export")
async def export_compliance_evidence(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Export compliance evidence for the current tenant."""
return {
"tenant_id": current_user.get("tenant_id"),
"exported_by": current_user.get("user_id"),
"evidence": {
"audit_logs": [],
"consent_records": [],
"data_retention_policies": [],
},
}
-221
View File
@@ -1,221 +0,0 @@
"""Workflow workstream integration — posts workflow events to the central
Communication system (G-WORK).
Replaces the old notification-based workflow messages with typed
CommMessage blocks: status, handoff, approval, action, and error.
Used by the WorkflowEngine to post step transitions, approvals,
errors, and completions to the workstream.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
async def post_workflow_status(
db: AsyncSession,
tenant_id: uuid.UUID,
instance_id: uuid.UUID,
workflow_name: str,
status: str,
step_index: int | None = None,
step_name: str | None = None,
user_id: uuid.UUID | None = None,
) -> dict[str, Any] | None:
"""Post a workflow status update to the workstream.
Creates a CommMessage with a typed ``workflow_status`` block.
"""
try:
from app.plugins.builtins.kommunikation.contracts import KommunikationContract
contract = KommunikationContract
post_fn = contract.get_function("post_message")
if post_fn is None:
# Fallback to system notification
from app.core.notifications import post_system_message
if user_id:
await post_system_message(
db, tenant_id, user_id, "workflow_status",
f"Workflow: {workflow_name}",
f"Status: {status}" + (f" (Step: {step_name})" if step_name else ""),
)
return None
block = {
"type": "workflow_status",
"workflow_name": workflow_name,
"instance_id": str(instance_id),
"status": status,
"step_index": step_index,
"step_name": step_name,
}
return await post_fn(
db=db,
tenant_id=tenant_id,
sender_id=None, # System sender
sender_type="system",
message_type="workflow_status",
content=f"Workflow '{workflow_name}'{status}",
blocks=[block],
)
except Exception as e:
logger.warning("Failed to post workflow status to workstream: %s", e)
return None
async def post_workflow_handoff(
db: AsyncSession,
tenant_id: uuid.UUID,
instance_id: uuid.UUID,
workflow_name: str,
handoff_type: str, # review_needed, action_required, waiting_for_user
assignee_id: uuid.UUID | None = None,
assignee_type: str = "user",
description: str = "",
user_id: uuid.UUID | None = None,
) -> dict[str, Any] | None:
"""Post a workflow handoff to the workstream.
Creates a CommMessage with a typed ``workflow_handoff`` block.
The handoff indicates that the workflow is waiting for human input.
"""
try:
from app.plugins.builtins.kommunikation.contracts import KommunikationContract
contract = KommunikationContract
post_fn = contract.get_function("post_message")
if post_fn is None:
from app.core.notifications import post_system_message
if assignee_id:
await post_system_message(
db, tenant_id, assignee_id, "workflow_handoff",
f"Workflow Handoff: {workflow_name}",
f"{handoff_type}: {description}",
)
return None
block = {
"type": "workflow_handoff",
"workflow_name": workflow_name,
"instance_id": str(instance_id),
"handoff_type": handoff_type,
"assignee_id": str(assignee_id) if assignee_id else None,
"assignee_type": assignee_type,
"description": description,
}
return await post_fn(
db=db,
tenant_id=tenant_id,
sender_id=None,
sender_type="system",
message_type="workflow_handoff",
content=f"Workflow '{workflow_name}'{handoff_type}",
blocks=[block],
)
except Exception as e:
logger.warning("Failed to post workflow handoff to workstream: %s", e)
return None
async def post_workflow_error(
db: AsyncSession,
tenant_id: uuid.UUID,
instance_id: uuid.UUID,
workflow_name: str,
error: str,
step_index: int | None = None,
step_name: str | None = None,
user_id: uuid.UUID | None = None,
) -> dict[str, Any] | None:
"""Post a workflow error to the workstream."""
try:
from app.plugins.builtins.kommunikation.contracts import KommunikationContract
contract = KommunikationContract
post_fn = contract.get_function("post_message")
if post_fn is None:
from app.core.notifications import post_system_message
if user_id:
await post_system_message(
db, tenant_id, user_id, "workflow_error",
f"Workflow Error: {workflow_name}",
f"Error at step {step_name or step_index}: {error}",
)
return None
block = {
"type": "workflow_error",
"workflow_name": workflow_name,
"instance_id": str(instance_id),
"error": error,
"step_index": step_index,
"step_name": step_name,
}
return await post_fn(
db=db,
tenant_id=tenant_id,
sender_id=None,
sender_type="system",
message_type="workflow_error",
content=f"Workflow '{workflow_name}' → Error: {error}",
blocks=[block],
)
except Exception as e:
logger.warning("Failed to post workflow error to workstream: %s", e)
return None
async def post_workflow_completed(
db: AsyncSession,
tenant_id: uuid.UUID,
instance_id: uuid.UUID,
workflow_name: str,
result: dict[str, Any] | None = None,
user_id: uuid.UUID | None = None,
) -> dict[str, Any] | None:
"""Post a workflow completion to the workstream."""
try:
from app.plugins.builtins.kommunikation.contracts import KommunikationContract
contract = KommunikationContract
post_fn = contract.get_function("post_message")
if post_fn is None:
from app.core.notifications import post_system_message
if user_id:
await post_system_message(
db, tenant_id, user_id, "workflow_completed",
f"Workflow Completed: {workflow_name}",
f"Workflow '{workflow_name}' has been completed successfully.",
)
return None
block = {
"type": "workflow_completed",
"workflow_name": workflow_name,
"instance_id": str(instance_id),
"result": result or {},
}
return await post_fn(
db=db,
tenant_id=tenant_id,
sender_id=None,
sender_type="system",
message_type="workflow_completed",
content=f"Workflow '{workflow_name}' → Completed",
blocks=[block],
)
except Exception as e:
logger.warning("Failed to post workflow completion to workstream: %s", e)
return None
__all__ = [
"post_workflow_status",
"post_workflow_handoff",
"post_workflow_error",
"post_workflow_completed",
]
-159
View File
@@ -1,159 +0,0 @@
/**
* Controlled Self-Improvement API client (Phase J).
*
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target
* the self-improvement routes under `/improvement/...`.
*/
import { apiDelete, apiGet, apiPost, apiPut } from './client';
// ─── Types ───
export type ProposalType =
| 'agent'
| 'skill'
| 'trigger'
| 'workflow'
| 'miniapp_template'
| 'plugin_patch';
export type ProposalStatus =
| 'draft'
| 'evaluating'
| 'pending_approval'
| 'approved'
| 'rejected'
| 'active'
| 'rolled_back'
| 'expired';
export type SignalType =
| 'agent_run'
| 'workflow_run'
| 'proactive_suggestion'
| 'audit_log'
| 'entity_history'
| 'user_correction'
| 'handoff'
| 'error_retry';
export interface ImprovementSignal {
id: string;
signal_type: SignalType;
source_ref: string;
tenant_id: string;
user_id?: string | null;
timestamp: string;
outcome: string;
metadata: Record<string, unknown>;
}
export interface DetectedPattern {
id: string;
pattern_type: string;
description: string;
confidence: number;
occurrence_count: number;
evidence_refs: string[];
metadata: Record<string, unknown>;
}
export interface EvaluationResult {
proposal_id?: string;
draft_id?: string;
evaluated_at?: string;
test_cases: number;
passed: number;
failed: number;
score: number;
recommendation: string;
details?: unknown[];
[key: string]: unknown;
}
export interface ImprovementProposal {
id: string;
proposal_type: ProposalType;
title: string;
description: string;
rationale: string;
expected_benefit: string;
risk_assessment: string;
status: ProposalStatus;
evidence_refs: string[];
pattern_refs: string[];
draft_config: Record<string, unknown>;
evaluation_result: EvaluationResult;
measurement_before: Record<string, unknown>;
measurement_after: Record<string, unknown>;
created_at: string;
updated_at: string;
approved_by?: string | null;
activated_at?: string | null;
}
export interface VersionedDraft {
id: string;
proposal_id: string;
version: number;
config: Record<string, unknown>;
previous_version_id?: string | null;
created_at: string;
}
export interface ProposalActionResponse {
proposal_id: string;
status: string;
error?: string;
approval_id?: string;
activated_at?: string;
rolled_back_at?: string;
rollback_available?: boolean;
previous_version_id?: string | null;
evaluation?: EvaluationResult;
[key: string]: unknown;
}
// ─── Proposals ───
export const fetchProposals = (status?: ProposalStatus) =>
apiGet<ImprovementProposal[]>('/improvement/proposals', {
params: status ? { status } : {},
});
export const fetchProposal = (id: string) =>
apiGet<ImprovementProposal>(`/improvement/proposals/${id}`);
export const approveProposal = (id: string) =>
apiPost<ProposalActionResponse>(`/improvement/proposals/${id}/approve`);
export const rejectProposal = (id: string) =>
apiPost<ProposalActionResponse>(`/improvement/proposals/${id}/reject`);
export const rollbackProposal = (id: string) =>
apiPost<ProposalActionResponse>(`/improvement/proposals/${id}/rollback`);
export const activateProposal = (id: string) =>
apiPost<ProposalActionResponse>(`/improvement/proposals/${id}/activate`);
export const deleteProposal = (id: string) =>
apiDelete<{ status: string }>(`/improvement/proposals/${id}`);
// ─── Patterns ───
export const fetchPatterns = () => apiGet<DetectedPattern[]>('/improvement/patterns');
// ─── Signals ───
export const fetchSignals = (days?: number) =>
apiGet<ImprovementSignal[]>('/improvement/signals', {
params: days ? { days } : {},
});
// ─── Drafts ───
export const fetchDraft = (proposalId: string) =>
apiGet<VersionedDraft>(`/improvement/proposals/${proposalId}/draft`);
export const updateDraft = (proposalId: string, config: Record<string, unknown>) =>
apiPut<VersionedDraft>(`/improvement/proposals/${proposalId}/draft`, { config });
-87
View File
@@ -1,87 +0,0 @@
/**
* MiniApp manifest types + workstream block types (Phase I.2 Workstream & MiniApps).
*
* Mirrors backend contracts:
* - app/plugins/manifest.py → MiniAppContribution
* - app/ai/workstream_contract.py → WorkstreamBlock / WorkstreamMessage
* - app/ai/proactive_feed.py → ProactiveSuggestion
*/
// ── MiniApp manifest contribution (backend: MiniAppContribution) ──
export interface MiniAppContribution {
app_id: string;
name: string;
icon: string;
description: string;
render_schema: Record<string, unknown>;
}
/**
* Plugin manifest with optional `miniapps` field.
* Only the fields relevant to MiniApps are declared here; the full manifest
* is typed in store/pluginStore.ts (PluginUiManifest).
*/
export interface PluginManifestWithMiniApps {
name: string;
display_name: string;
version: string;
miniapps?: MiniAppContribution[];
}
// ── Workstream block types (backend: WorkstreamBlock) ──
export type WorkstreamBlockType =
| 'text'
| 'entity_card'
| 'action_card'
| 'evidence_card'
| 'approval_card'
| 'miniapp'
| 'workflow_status'
| 'workflow_handoff'
| 'error';
export interface WorkstreamBlock {
type: WorkstreamBlockType;
content: string;
metadata: Record<string, unknown>;
}
export type WorkstreamActorType = 'human' | 'system' | 'agent' | 'workflow';
export interface WorkstreamMessage {
actor_type: WorkstreamActorType;
actor_id?: string | null;
content: string;
blocks: WorkstreamBlock[];
conversation_id?: string | null;
tenant_id?: string | null;
}
// ── Proactive suggestion (backend: ProactiveSuggestion) ──
export type ProactivePriority = 'low' | 'medium' | 'high' | 'urgent';
export interface ProactiveSuggestion {
id: string;
trigger: string;
title: string;
description: string;
priority: ProactivePriority;
action_type: 'suggestion' | 'action_required' | 'info';
action_url: string;
entity_type?: string | null;
entity_id?: string | null;
blocks: WorkstreamBlock[];
created_at: string;
expires_at?: string | null;
metadata: Record<string, unknown>;
}
export interface ProactiveFeedSettings {
enabled: boolean;
min_priority: ProactivePriority;
max_per_hour: number;
triggers_enabled: Record<string, boolean>;
}
-153
View File
@@ -1,153 +0,0 @@
/**
* Platform API hooks — Phase I.6/I.7 (I-ONB, I-DASH, I-COST, I-USE).
*
* Provides hooks for the setup wizard (onboarding) and the platform dashboard
* (agent status, workflow stats, search metrics, knowledge coverage, cost
* tracking, system health). Backend routes may not be wired yet, so all hooks
* degrade gracefully to an "unavailable" state instead of throwing.
*/
import { useQuery } from '@tanstack/react-query';
import { apiGet } from './client';
// ── Onboarding ──────────────────────────────────────────────────────────────
export interface OnboardingStepStatus {
completed: boolean;
required: boolean;
}
export interface OnboardingStatus {
steps: Record<string, OnboardingStepStatus>;
progress_pct: number;
}
export interface OnboardingGuideStep {
id: string;
title: string;
description: string;
icon?: string;
action_url?: string;
}
export interface OnboardingGuide {
steps: OnboardingGuideStep[];
}
export function useOnboardingStatus() {
return useQuery({
queryKey: ['onboardingStatus'],
queryFn: () => apiGet<OnboardingStatus>('/onboarding/status'),
staleTime: 60 * 1000,
retry: false,
});
}
export function useOnboardingGuide() {
return useQuery({
queryKey: ['onboardingGuide'],
queryFn: () => apiGet<OnboardingGuide>('/onboarding/guide'),
staleTime: 60 * 1000,
retry: false,
});
}
// ── Platform dashboard ──────────────────────────────────────────────────────
export interface PlatformDashboardData {
agents?: {
active_agents?: number;
total_runs?: number;
recent_runs_7d?: number;
error?: string;
};
workflows?: {
active_workflows?: number;
running_instances?: number;
completed_instances?: number;
error?: string;
};
search?: {
total_queries?: number;
avg_latency_ms?: number;
error?: string;
};
knowledge?: {
wiki_articles?: number;
coverage_pct?: number;
error?: string;
};
workstream?: {
messages?: number;
error?: string;
};
system_health?: {
redis?: string;
status?: string;
error?: string;
};
generated_at?: string;
}
export function usePlatformDashboard() {
return useQuery({
queryKey: ['platformDashboard'],
queryFn: () => apiGet<PlatformDashboardData>('/dashboard/platform'),
staleTime: 60 * 1000,
retry: false,
});
}
// ── Cost tracking ───────────────────────────────────────────────────────────
export interface CostDashboardData {
period_days?: number;
total_cost_usd?: number;
by_agent?: Record<string, { cost_usd: number; runs: number }>;
budget?: {
monthly_limit_usd?: number;
utilization_pct?: number;
};
error?: string;
}
export function useCostDashboard(days = 30) {
return useQuery({
queryKey: ['costDashboard', days],
queryFn: () => apiGet<CostDashboardData>(`/dashboard/cost?days=${days}`),
staleTime: 60 * 1000,
retry: false,
});
}
// ── Usage analytics ─────────────────────────────────────────────────────────
export interface UsageAnalyticsData {
period_days?: number;
agent_runs?: {
total?: number;
completed?: number;
failed?: number;
success_rate?: number;
error?: string;
};
workflow_executions?: {
total?: number;
completed?: number;
error?: string;
};
search_queries?: {
total?: number;
error?: string;
};
error?: string;
}
export function useUsageAnalytics(days = 30) {
return useQuery({
queryKey: ['usageAnalytics', days],
queryFn: () => apiGet<UsageAnalyticsData>(`/dashboard/usage?days=${days}`),
staleTime: 60 * 1000,
retry: false,
});
}
@@ -1,128 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { TrendingUp, Lightbulb } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { ProposalCard } from './ProposalCard';
import { PatternInsight } from './PatternInsight';
import {
fetchProposals,
fetchPatterns,
approveProposal,
rejectProposal,
rollbackProposal,
type ImprovementProposal,
type DetectedPattern,
} from '@/api/improvement';
export interface ImprovementCenterProps {
className?: string;
}
export function ImprovementCenter({ className }: ImprovementCenterProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: proposals = [], isLoading: loadingProposals } = useQuery<ImprovementProposal[]>({
queryKey: ['improvement', 'proposals'],
queryFn: () => fetchProposals(),
});
const { data: patterns = [], isLoading: loadingPatterns } = useQuery<DetectedPattern[]>({
queryKey: ['improvement', 'patterns'],
queryFn: fetchPatterns,
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['improvement', 'proposals'] });
queryClient.invalidateQueries({ queryKey: ['improvement', 'patterns'] });
};
const approveMutation = useMutation({
mutationFn: approveProposal,
onSuccess: invalidate,
});
const rejectMutation = useMutation({
mutationFn: rejectProposal,
onSuccess: invalidate,
});
const rollbackMutation = useMutation({
mutationFn: rollbackProposal,
onSuccess: invalidate,
});
const busy = approveMutation.isPending || rejectMutation.isPending || rollbackMutation.isPending;
return (
<div className={className} data-testid="improvement-center">
<div className="mb-6">
<h2 className="flex items-center gap-2 text-xl font-semibold text-secondary-900">
<TrendingUp className="w-5 h-5 text-primary-600" aria-hidden="true" />
{t('improvement.title')}
</h2>
<p className="text-sm text-secondary-500 mt-1">{t('improvement.subtitle')}</p>
</div>
{/* Patterns / Bottlenecks */}
<section className="mb-8" aria-labelledby="improvement-patterns-heading">
<h3 id="improvement-patterns-heading" className="text-base font-semibold text-secondary-800 mb-3">
{t('improvement.patterns')}
</h3>
{loadingPatterns ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Skeleton className="h-40" />
<Skeleton className="h-40" />
</div>
) : patterns.length === 0 ? (
<EmptyState
icon={<Lightbulb className="w-6 h-6" />}
title={t('improvement.noPatterns')}
description={t('improvement.noPatternsDesc')}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{patterns.map((pattern) => (
<PatternInsight key={pattern.id} pattern={pattern} />
))}
</div>
)}
</section>
{/* Proposals */}
<section aria-labelledby="improvement-proposals-heading">
<h3 id="improvement-proposals-heading" className="text-base font-semibold text-secondary-800 mb-3">
{t('improvement.proposals')}
</h3>
{loadingProposals ? (
<div className="grid grid-cols-1 gap-4">
<Skeleton className="h-56" />
<Skeleton className="h-56" />
</div>
) : proposals.length === 0 ? (
<EmptyState
icon={<Lightbulb className="w-6 h-6" />}
title={t('improvement.noProposals')}
description={t('improvement.noProposalsDesc')}
/>
) : (
<div className="grid grid-cols-1 gap-4">
{proposals.map((proposal) => (
<ProposalCard
key={proposal.id}
proposal={proposal}
busy={busy}
onApprove={(id) => approveMutation.mutate(id)}
onReject={(id) => rejectMutation.mutate(id)}
onRollback={(id) => rollbackMutation.mutate(id)}
/>
))}
</div>
)}
</section>
</div>
);
}
@@ -1,70 +0,0 @@
import React from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { TrendingUp, AlertTriangle, Lightbulb } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { DetectedPattern } from '@/api/improvement';
export interface PatternInsightProps {
pattern: DetectedPattern;
}
function formatConfidence(confidence: number): string {
if (confidence === undefined || Number.isNaN(confidence)) return '—';
return `${Math.round(confidence * 100)}%`;
}
export function PatternInsight({ pattern }: PatternInsightProps) {
const { t } = useTranslation();
const confidence = pattern.confidence;
const confidenceVariant =
confidence >= 0.8 ? 'success' : confidence >= 0.6 ? 'warning' : 'secondary';
return (
<Card className="flex flex-col" data-testid={`pattern-insight-${pattern.id}`}>
<div className="flex items-center justify-between gap-2 mb-3">
<h3 className="flex items-center gap-2 text-base font-semibold text-secondary-900">
<Lightbulb className="w-4 h-4 text-primary-500 flex-shrink-0" aria-hidden="true" />
{t(`improvement.patternTypes.${pattern.pattern_type}`)}
</h3>
<Badge variant={confidenceVariant} dot>
{t('improvement.confidence')}: {formatConfidence(confidence)}
</Badge>
</div>
<p className="text-sm text-secondary-700 mb-3">{pattern.description}</p>
<div className="flex items-center gap-4 mb-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary-600" aria-hidden="true" />
<span className="text-sm text-secondary-700">
{t('improvement.occurrences')}:{' '}
<span className="font-semibold text-secondary-900">{pattern.occurrence_count}</span>
</span>
</div>
{pattern.pattern_type === 'error_retries' && (
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-warning-600" aria-hidden="true" />
<span className="text-sm text-warning-700">{t('improvement.bottleneck')}</span>
</div>
)}
</div>
{pattern.evidence_refs.length > 0 && (
<div className="space-y-1">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
{t('improvement.evidence')}
</h4>
<ul className="flex flex-wrap gap-2">
{pattern.evidence_refs.map((ref) => (
<li key={ref}>
<code className="px-2 py-0.5 rounded bg-secondary-100 text-xs text-secondary-700">{ref}</code>
</li>
))}
</ul>
</div>
)}
</Card>
);
}
@@ -1,131 +0,0 @@
import React from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { TrendingUp, AlertTriangle, CheckCircle, XCircle, RotateCcw, Lightbulb } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import type { ImprovementProposal, ProposalStatus } from '@/api/improvement';
export interface ProposalCardProps {
proposal: ImprovementProposal;
onApprove?: (id: string) => void;
onReject?: (id: string) => void;
onRollback?: (id: string) => void;
busy?: boolean;
}
const statusVariant: Record<ProposalStatus, 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'secondary'> = {
draft: 'secondary',
evaluating: 'info',
pending_approval: 'warning',
approved: 'primary',
rejected: 'danger',
active: 'success',
rolled_back: 'default',
expired: 'default',
};
function formatScore(score: number | undefined): string {
if (score === undefined || Number.isNaN(score)) return '—';
return `${Math.round(score)}%`;
}
export function ProposalCard({ proposal, onApprove, onReject, onRollback, busy = false }: ProposalCardProps) {
const { t } = useTranslation();
const score = proposal.evaluation_result?.score;
const canApprove = proposal.status === 'pending_approval' || proposal.status === 'draft' || proposal.status === 'evaluating';
const canReject = proposal.status === 'pending_approval' || proposal.status === 'draft' || proposal.status === 'evaluating';
const canRollback = proposal.status === 'active';
return (
<Card className="flex flex-col" data-testid={`proposal-card-${proposal.id}`}>
<div className="flex items-center justify-between gap-2 mb-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-secondary-900">
<Lightbulb className="w-4 h-4 text-primary-500 flex-shrink-0" aria-hidden="true" />
{proposal.title}
</h3>
<Badge variant={statusVariant[proposal.status]} dot>
{t(`improvement.status.${proposal.status}`)}
</Badge>
</div>
<div className="space-y-4">
<p className="text-sm text-secondary-700">{proposal.description}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
{t('improvement.rationale')}
</h4>
<p className="text-sm text-secondary-700">{proposal.rationale}</p>
</div>
<div className="space-y-1">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
{t('improvement.expectedBenefit')}
</h4>
<p className="text-sm text-secondary-700">{proposal.expected_benefit}</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-md bg-warning-50 border border-warning-200 p-3">
<AlertTriangle className="w-4 h-4 text-warning-600 mt-0.5 flex-shrink-0" aria-hidden="true" />
<div className="space-y-1">
<h4 className="text-xs font-semibold text-warning-700 uppercase tracking-wide">
{t('improvement.riskAssessment')}
</h4>
<p className="text-sm text-warning-800">{proposal.risk_assessment}</p>
</div>
</div>
{proposal.evidence_refs.length > 0 && (
<div className="space-y-1">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
{t('improvement.evidence')}
</h4>
<ul className="flex flex-wrap gap-2">
{proposal.evidence_refs.map((ref) => (
<li key={ref}>
<code className="px-2 py-0.5 rounded bg-secondary-100 text-xs text-secondary-700">{ref}</code>
</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between rounded-md bg-secondary-50 border border-secondary-200 p-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary-600" aria-hidden="true" />
<span className="text-sm font-medium text-secondary-700">{t('improvement.score')}</span>
</div>
<span
className={clsx(
'text-lg font-semibold',
score !== undefined && score >= 80 ? 'text-success-600' : score !== undefined && score >= 60 ? 'text-warning-600' : 'text-secondary-600'
)}
>
{formatScore(score)}
</span>
</div>
</div>
<div className="mt-4 pt-4 border-t border-secondary-200 flex flex-wrap items-center gap-2">
{canApprove && onApprove && (
<Button size="sm" variant="primary" icon={<CheckCircle className="w-4 h-4" />} isLoading={busy} onClick={() => onApprove(proposal.id)}>
{t('improvement.approve')}
</Button>
)}
{canReject && onReject && (
<Button size="sm" variant="danger" icon={<XCircle className="w-4 h-4" />} isLoading={busy} onClick={() => onReject(proposal.id)}>
{t('improvement.reject')}
</Button>
)}
{canRollback && onRollback && (
<Button size="sm" variant="secondary" icon={<RotateCcw className="w-4 h-4" />} isLoading={busy} onClick={() => onRollback(proposal.id)}>
{t('improvement.rollback')}
</Button>
)}
</div>
</Card>
);
}
@@ -54,10 +54,7 @@ function getIcon(name: string): React.ReactNode {
const singleItems: NavSingleItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 0 },
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 10 },
{ to: '/workstream', labelKey: 'nav.workstream', icon: <MessageSquare className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 25 },
{ to: '/wiki', labelKey: 'nav.wiki', icon: <BookOpen className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 30 },
{ to: '/improvement', labelKey: 'nav.improvement', icon: <Lightbulb className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 90 },
{ to: '/onboarding', labelKey: 'nav.onboarding', icon: <Sparkles className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 95 },
];
const bottomItems: NavSingleItem[] = [];
@@ -1,136 +0,0 @@
/**
* SetupWizard — Phase I.6 (I-ONB) setup wizard.
*
* Guides users through 4 steps: Welcome, Create first Agent,
* Create first Workflow, Enable Knowledge & Workstream.
*
* Uses lucide-react icons, Tailwind, and i18n via `t()`.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Sparkles,
Bot,
Workflow,
BookOpen,
ChevronLeft,
ChevronRight,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
interface SetupWizardProps {
open: boolean;
onClose: () => void;
onComplete?: () => void;
}
const STEP_ICONS = [Sparkles, Bot, Workflow, BookOpen];
/**
* Setup wizard with 4 steps. Each step shows a title, description and an
* optional action button that deep-links to the relevant setup page.
*/
export function SetupWizard({ open, onClose, onComplete }: SetupWizardProps) {
const { t } = useTranslation();
const [step, setStep] = useState(0);
if (!open) return null;
const totalSteps = 4;
const isLast = step === totalSteps - 1;
const Icon = STEP_ICONS[step];
const handleNext = () => {
if (isLast) {
onComplete?.();
onClose();
return;
}
setStep((s) => s + 1);
};
const handleBack = () => {
if (step > 0) setStep((s) => s - 1);
};
const stepKey = `setupWizard.step${step + 1}`;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-secondary-900/50 p-4"
role="dialog"
aria-modal="true"
aria-label={t('setupWizard.title')}
data-testid="setup-wizard"
>
<Card className="w-full max-w-lg">
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary-200">
<h2 className="text-lg font-semibold text-secondary-900">
{t('setupWizard.title')}
</h2>
<button
type="button"
onClick={onClose}
className="p-2 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('setupWizard.close')}
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div className="px-6 py-6">
{/* Progress indicator */}
<div className="flex items-center gap-2 mb-6" aria-hidden="true">
{Array.from({ length: totalSteps }).map((_, i) => (
<div
key={i}
className={`h-1.5 flex-1 rounded-full transition-colors ${
i <= step ? 'bg-primary-500' : 'bg-secondary-200'
}`}
/>
))}
</div>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary-100 text-primary-700 flex items-center justify-center">
<Icon className="h-6 w-6" aria-hidden="true" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-secondary-900">
{t(`${stepKey}.title`)}
</h3>
<p className="mt-1 text-sm text-secondary-600">
{t(`${stepKey}.description`)}
</p>
</div>
</div>
</div>
<div className="px-6 py-4 border-t border-secondary-200 flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={handleBack}
disabled={step === 0}
icon={<ChevronLeft className="h-4 w-4" />}
>
{t('setupWizard.back')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleNext}
icon={isLast ? undefined : <ChevronRight className="h-4 w-4" />}
>
{isLast ? t('setupWizard.finish') : t('setupWizard.next')}
</Button>
</div>
</Card>
</div>
);
}
export default SetupWizard;
@@ -1,64 +0,0 @@
/**
* MiniAppBlock — renders a `miniapp` workstream block.
*
* Uses `metadata.app_id` and `metadata.render_schema` to render the MiniApp.
* Falls back to a placeholder state for unknown apps.
*
* Backend: app/ai/workstream_contract.py → build_miniapp_block
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AppWindow, AlertCircle } from 'lucide-react';
import type { WorkstreamBlock } from '@/api/miniapps';
interface MiniAppBlockProps {
block: WorkstreamBlock;
}
/**
* Render a MiniApp block. If the app is unknown or render_schema is empty,
* show a fallback state with the app_id.
*/
export function MiniAppBlock({ block }: MiniAppBlockProps) {
const { t } = useTranslation();
const metadata = block.metadata ?? {};
const appId = typeof metadata.app_id === 'string' ? metadata.app_id : '';
const title = typeof metadata.title === 'string' ? metadata.title : '';
const renderSchema =
metadata.render_schema && typeof metadata.render_schema === 'object'
? (metadata.render_schema as Record<string, unknown>)
: {};
const hasRenderSchema = Object.keys(renderSchema).length > 0;
return (
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-100 text-primary-700 flex items-center justify-center">
<AppWindow className="h-5 w-5" aria-hidden="true" />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-secondary-900">
{title || appId || t('workstream.miniapp.untitled')}
</h4>
{hasRenderSchema ? (
<div className="mt-2 text-sm text-secondary-600">
{t('workstream.miniapp.renderSchema')}
<pre className="mt-2 p-2 rounded bg-secondary-50 text-xs font-mono overflow-x-auto">
{JSON.stringify(renderSchema, null, 2)}
</pre>
</div>
) : (
<div className="mt-2 flex items-center gap-2 text-xs text-secondary-400">
<AlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t('workstream.miniapp.noSchema')}</span>
</div>
)}
</div>
</div>
</div>
);
}
export default MiniAppBlock;
@@ -1,290 +0,0 @@
/**
* MiniAppSDK — standard building blocks for MiniApp rendering.
*
* Provides reusable components used by MiniAppBlock and the Workstream page:
* - EntityCard: link to a CRM entity
* - ActionButtons: list of action buttons (deep links / callbacks)
* - ApprovalCard: approve/reject actions
* - ProgressIndicator: progress bar with label
* - DeepLink: safe external/internal link
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ExternalLink,
CheckCircle2,
XCircle,
ArrowRight,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
// ── EntityCard ──
export interface EntityCardProps {
entityType: string;
entityId: string;
title?: string;
subtitle?: string;
url?: string;
}
/**
* Card linking to a CRM entity (contact, company, mail, etc.).
*/
export function EntityCard({
entityType,
entityId,
title,
subtitle,
url,
}: EntityCardProps) {
const { t } = useTranslation();
const href = url || `/${entityType}/${entityId}`;
const safeHref = hrefSafe(href);
return (
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Badge variant="secondary">{entityType}</Badge>
<h4 className="mt-2 text-sm font-semibold text-secondary-900 truncate">
{title || entityId}
</h4>
{subtitle && (
<p className="mt-1 text-xs text-secondary-500 truncate">{subtitle}</p>
)}
</div>
{safeHref && (
<a
href={safeHref}
className="inline-flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700 focus-visible:ring-2 focus-visible:ring-primary-500 rounded px-2 py-1"
aria-label={t('workstream.sdk.openEntity')}
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
{t('workstream.sdk.open')}
</a>
)}
</div>
</div>
);
};
// ── ActionButtons ──
export interface ActionButtonItem {
label: string;
action: string;
type?: 'primary' | 'secondary' | 'danger';
}
export interface ActionButtonsProps {
actions: ActionButtonItem[];
onAction?: (action: ActionButtonItem) => void;
}
/**
* Renders a list of action buttons. Actions are either deep links (http/https)
* or callbacks handled by the parent via `onAction`.
*/
export function ActionButtons({ actions, onAction }: ActionButtonsProps) {
const { t } = useTranslation();
if (!actions || actions.length === 0) return null;
const handleClick = (action: ActionButtonItem) => {
if (onAction) {
onAction(action);
return;
}
const url = action.action;
if (isSafeUrl(url)) {
window.open(url, '_blank', 'noopener,noreferrer');
}
};
return (
<div className="flex flex-wrap gap-2">
{actions.map((action, idx) => {
const variant =
action.type === 'danger'
? 'danger'
: action.type === 'secondary'
? 'secondary'
: 'primary';
return (
<Button
key={idx}
variant={variant}
size="sm"
onClick={() => handleClick(action)}
>
{action.label || t('workstream.sdk.action')}
</Button>
);
})}
</div>
);
}
// ── ApprovalCard ──
export interface ApprovalCardProps {
approvalId: string;
action: string;
description?: string;
onApprove?: (approvalId: string) => void;
onReject?: (approvalId: string) => void;
isPending?: boolean;
}
/**
* Approval card with approve/reject actions.
*/
export function ApprovalCard({
approvalId,
action,
description,
onApprove,
onReject,
isPending = false,
}: ApprovalCardProps) {
const { t } = useTranslation();
return (
<div className="border border-warning-200 rounded-lg p-4 bg-warning-50 shadow-sm">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-warning-100 text-warning-700 flex items-center justify-center">
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-secondary-900">
{t('workstream.sdk.approvalNeeded')}: {action}
</h4>
{description && (
<p className="mt-1 text-xs text-secondary-600">{description}</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
isLoading={isPending}
onClick={() => onApprove?.(approvalId)}
icon={<CheckCircle2 className="h-4 w-4" />}
>
{t('workstream.sdk.approve')}
</Button>
<Button
variant="danger"
size="sm"
isLoading={isPending}
onClick={() => onReject?.(approvalId)}
icon={<XCircle className="h-4 w-4" />}
>
{t('workstream.sdk.reject')}
</Button>
</div>
</div>
</div>
</div>
);
}
// ── ProgressIndicator ──
export interface ProgressIndicatorProps {
label?: string;
value: number; // 0-100
status?: 'pending' | 'in_progress' | 'completed' | 'error';
}
/**
* Progress bar with optional label and status color.
*/
export function ProgressIndicator({
label,
value,
status = 'in_progress',
}: ProgressIndicatorProps) {
const { t } = useTranslation();
const clamped = Math.max(0, Math.min(100, value));
const barColor =
status === 'error'
? 'bg-danger-500'
: status === 'completed'
? 'bg-success-500'
: 'bg-primary-500';
return (
<div className="w-full">
{label && (
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-secondary-700">{label}</span>
<span className="text-xs text-secondary-400">{clamped}%</span>
</div>
)}
<div
className="w-full h-2 rounded-full bg-secondary-100 overflow-hidden"
role="progressbar"
aria-valuenow={clamped}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label || t('workstream.sdk.progress')}
>
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
);
}
// ── DeepLink ──
export interface DeepLinkProps {
href: string;
label?: string;
external?: boolean;
}
/**
* Safe deep link — only allows http(s) and internal paths.
*/
export function DeepLink({ href, label, external = false }: DeepLinkProps) {
const { t } = useTranslation();
const safeHref = hrefSafe(href);
if (!safeHref) {
return <span className="text-xs text-secondary-400">{label || href}</span>;
}
return (
<a
href={safeHref}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 hover:underline"
>
{label || href}
{external && <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />}
</a>
);
}
// ── Helpers ──
function hrefSafe(href: string): string | null {
if (!href) return null;
if (href.startsWith('/')) return href;
try {
const parsed = new URL(href);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return href;
} catch {
return null;
}
return null;
}
function isSafeUrl(url: string): boolean {
return hrefSafe(url) !== null;
}
@@ -1,155 +0,0 @@
/**
* ProactiveFeed — contextual suggestions/actions in the workstream.
*
* Renders ProactiveSuggestion items with priority, client-side dedupe and
* cooldown (mirrors backend app/ai/proactive_feed.py). Suggestions are
* non-intrusive: no popups, just a feed section.
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Sparkles, Info, ArrowRight, X } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import type { ProactiveSuggestion, ProactivePriority } from '@/api/miniapps';
interface ProactiveFeedProps {
suggestions: ProactiveSuggestion[];
/** Cooldown in ms per trigger; default 300000 (5 min). */
cooldowns?: Record<string, number>;
/** Minimum priority to show. */
minPriority?: ProactivePriority;
onDismiss?: (id: string) => void;
onAction?: (suggestion: ProactiveSuggestion) => void;
}
const PRIORITY_ORDER: Record<ProactivePriority, number> = {
low: 0,
medium: 1,
high: 2,
urgent: 3,
};
const PRIORITY_VARIANT: Record<
ProactivePriority,
'secondary' | 'info' | 'warning' | 'danger'
> = {
low: 'secondary',
medium: 'info',
high: 'warning',
urgent: 'danger',
};
const ACTION_TYPE_ICON: Record<string, React.ReactNode> = {
suggestion: <Sparkles className="h-4 w-4" aria-hidden="true" />,
action_required: <ArrowRight className="h-4 w-4" aria-hidden="true" />,
info: <Info className="h-4 w-4" aria-hidden="true" />,
};
/**
* Client-side dedupe + cooldown keyed by trigger + entity_id.
* Persisted in a module-level map so it survives re-renders within a session.
*/
function dedupeKey(s: ProactiveSuggestion): string {
return `${s.trigger}:${s.entity_id ?? 'none'}`;
}
function isCooledDown(s: ProactiveSuggestion, cooldowns: Record<string, number>): boolean {
const key = dedupeKey(s);
const last = lastAt[key];
if (last === undefined) return false;
const cooldown = cooldowns[s.trigger] ?? cooldowns.default ?? 300000;
return Date.now() - last < cooldown;
}
function markShown(s: ProactiveSuggestion): void {
lastAt[dedupeKey(s)] = Date.now();
}
const lastAt: Record<string, number> = {};
/**
* Renders a non-intrusive feed of proactive suggestions.
*/
export function ProactiveFeed({
suggestions,
cooldowns = {},
minPriority = 'low',
onDismiss,
onAction,
}: ProactiveFeedProps) {
const { t } = useTranslation();
const visible = useMemo(() => {
const minLevel = PRIORITY_ORDER[minPriority] ?? 0;
return suggestions
.filter((s) => PRIORITY_ORDER[s.priority] >= minLevel)
.filter((s) => !isCooledDown(s, cooldowns))
.sort((a, b) => PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority]);
}, [suggestions, cooldowns, minPriority]);
// Mark visible suggestions as shown (cooldown tracking)
React.useEffect(() => {
visible.forEach(markShown);
}, [visible]);
if (visible.length === 0) return null;
return (
<section
className="space-y-2"
aria-label={t('workstream.proactive.title')}
>
<div className="flex items-center gap-2 px-1">
<Sparkles className="h-4 w-4 text-primary-500" aria-hidden="true" />
<h3 className="text-sm font-semibold text-secondary-800">
{t('workstream.proactive.title')}
</h3>
</div>
{visible.map((s) => (
<div
key={s.id}
className="border border-secondary-200 rounded-lg p-3 bg-white shadow-sm flex items-start gap-3"
>
<div className="flex-shrink-0 mt-0.5 text-primary-500">
{ACTION_TYPE_ICON[s.action_type] ?? (
<Sparkles className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-secondary-900">
{s.title}
</span>
<Badge variant={PRIORITY_VARIANT[s.priority]}>
{t(`workstream.proactive.priority.${s.priority}`)}
</Badge>
</div>
{s.description && (
<p className="mt-1 text-xs text-secondary-500">{s.description}</p>
)}
{s.action_url && (
<button
onClick={() => onAction?.(s)}
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700 hover:underline"
>
{t('workstream.proactive.view')}
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</button>
)}
</div>
{onDismiss && (
<button
onClick={() => onDismiss(s.id)}
className="flex-shrink-0 p-1 rounded text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100"
aria-label={t('workstream.proactive.dismiss')}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
))}
</section>
);
}
export default ProactiveFeed;
@@ -1,260 +0,0 @@
/**
* WorkstreamBlockRenderer — Phase I.7 (I-UI) universal block renderer.
*
* Renders every workstream block type (text, entity_card, action_card,
* evidence_card, approval_card, miniapp, workflow_status, workflow_handoff,
* error) with consistent loading / error / empty states.
*
* Reuses MiniAppBlock and the MiniAppSDK building blocks.
*
* Backend: app/ai/workstream_contract.py
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Link2,
AlertTriangle,
Workflow,
ArrowRight,
Loader2,
MessageSquare,
} from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
import { MiniAppBlock } from '@/components/workstream/MiniAppBlock';
import {
EntityCard,
ActionButtons,
ApprovalCard,
ProgressIndicator,
DeepLink,
type ActionButtonItem,
} from '@/components/workstream/MiniAppSDK';
import type { WorkstreamBlock } from '@/api/miniapps';
interface WorkstreamBlockRendererProps {
block: WorkstreamBlock;
onApprove?: (approvalId: string) => void;
onReject?: (approvalId: string) => void;
onAction?: (action: ActionButtonItem) => void;
}
/**
* Render a single workstream block by type.
*/
export function WorkstreamBlockRenderer({
block,
onApprove,
onReject,
onAction,
}: WorkstreamBlockRendererProps) {
const { t } = useTranslation();
const meta = block.metadata ?? {};
switch (block.type) {
case 'text':
return (
<div className="text-sm whitespace-pre-wrap break-words text-secondary-800">
{block.content || String(meta.text ?? '')}
</div>
);
case 'entity_card':
return (
<EntityCard
entityType={String(meta.entity_type ?? '')}
entityId={String(meta.entity_id ?? '')}
title={String(meta.title ?? block.content)}
subtitle={String(meta.subtitle ?? '')}
url={String(meta.url ?? '')}
/>
);
case 'action_card': {
const actions: ActionButtonItem[] = Array.isArray(meta.actions)
? (meta.actions as ActionButtonItem[])
: [];
return (
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm space-y-2">
<h4 className="text-sm font-semibold text-secondary-900">
{String(meta.title ?? block.content)}
</h4>
{meta.description ? (
<p className="text-sm text-secondary-600">
{String(meta.description)}
</p>
) : null}
<ActionButtons actions={actions} onAction={onAction} />
</div>
);
}
case 'evidence_card':
return (
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary-500" aria-hidden="true" />
<h4 className="text-sm font-semibold text-secondary-900">
{String(meta.title ?? block.content)}
</h4>
{typeof meta.confidence === 'number' && (
<Badge variant="secondary">
{Math.round(meta.confidence * 100)}%
</Badge>
)}
</div>
{meta.snippet ? (
<p className="mt-2 text-xs text-secondary-500">
{String(meta.snippet)}
</p>
) : null}
{meta.url ? (
<div className="mt-2">
<DeepLink
href={String(meta.url)}
label={t('workstream.block.openSource')}
external
/>
</div>
) : null}
</div>
);
case 'approval_card':
return (
<ApprovalCard
approvalId={String(meta.approval_id ?? '')}
action={String(meta.action ?? block.content)}
description={String(meta.description ?? '')}
onApprove={onApprove}
onReject={onReject}
/>
);
case 'miniapp':
return <MiniAppBlock block={block} />;
case 'workflow_status':
return (
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
<div className="flex items-center gap-2">
<Workflow className="h-4 w-4 text-primary-500" aria-hidden="true" />
<h4 className="text-sm font-semibold text-secondary-900">
{String(meta.workflow_name ?? block.content)}
</h4>
<Badge variant="info">{String(meta.status ?? '')}</Badge>
</div>
{meta.step_name ? (
<p className="mt-2 text-xs text-secondary-500">
{t('workstream.block.step')}: {String(meta.step_name)}
</p>
) : null}
{typeof meta.step_index === 'number' && (
<div className="mt-3">
<ProgressIndicator
label={t('workstream.block.progress')}
value={Math.min(100, (meta.step_index + 1) * 10)}
status="in_progress"
/>
</div>
)}
</div>
);
case 'workflow_handoff':
return (
<div className="border border-warning-200 rounded-lg p-4 bg-warning-50 shadow-sm">
<div className="flex items-center gap-2">
<ArrowRight className="h-4 w-4 text-warning-700" aria-hidden="true" />
<h4 className="text-sm font-semibold text-secondary-900">
{t('workstream.block.handoff')}: {String(meta.handoff_type ?? '')}
</h4>
</div>
{meta.description ? (
<p className="mt-2 text-xs text-secondary-600">
{String(meta.description)}
</p>
) : null}
</div>
);
case 'error':
return (
<div className="border border-danger-200 rounded-lg p-4 bg-danger-50 shadow-sm flex items-start gap-2">
<AlertTriangle
className="h-4 w-4 text-danger-600 mt-0.5"
aria-hidden="true"
/>
<div className="text-sm text-danger-700">
{block.content || String(meta.error ?? '')}
</div>
</div>
);
default:
return (
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
{t('workstream.block.unknown', { type: block.type })}
</div>
);
}
}
interface WorkstreamBlockListProps {
blocks: WorkstreamBlock[];
isLoading?: boolean;
onApprove?: (approvalId: string) => void;
onReject?: (approvalId: string) => void;
onAction?: (action: ActionButtonItem) => void;
}
/**
* Render a list of blocks with consistent loading / empty states.
*/
export function WorkstreamBlockList({
blocks,
isLoading = false,
onApprove,
onReject,
onAction,
}: WorkstreamBlockListProps) {
const { t } = useTranslation();
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2
className="animate-spin h-6 w-6 text-primary-500"
aria-hidden="true"
/>
</div>
);
}
if (!blocks || blocks.length === 0) {
return (
<EmptyState
icon={<MessageSquare className="h-8 w-8" aria-hidden="true" />}
title={t('workstream.empty.title')}
description={t('workstream.empty.description')}
/>
);
}
return (
<div className="space-y-3">
{blocks.map((block, idx) => (
<WorkstreamBlockRenderer
key={idx}
block={block}
onApprove={onApprove}
onReject={onReject}
onAction={onAction}
/>
))}
</div>
);
}
export default WorkstreamBlockRenderer;
+1 -110
View File
@@ -20,10 +20,7 @@
"mcpSettings": "MCP Einstellungen",
"reports": "Reports",
"tasks": "Aufgaben",
"workstream": "Workstream",
"wiki": "Wiki",
"improvement": "Verbesserungen",
"onboarding": "Onboarding"
"wiki": "Wiki"
},
"auth": {
"login": "Anmelden",
@@ -1289,111 +1286,5 @@
"preview": "Vorschau",
"split": "Geteilt",
"writeLabel": "Markdown-Inhalt"
},
"workstream": {
"title": "Workstream",
"empty": {
"title": "Keine Nachrichten",
"description": "Es sind noch keine Workstream-Nachrichten vorhanden."
},
"actor": {
"human": "Mensch",
"system": "System",
"agent": "Agent",
"workflow": "Workflow"
},
"block": {
"step": "Schritt",
"progress": "Fortschritt",
"handoff": "Übergabe",
"openSource": "Quelle öffnen",
"unknown": "Unbekannter Block-Typ: {{type}}"
},
"miniapp": {
"untitled": "Unbenannte Mini-App",
"renderSchema": "Render-Schema",
"noSchema": "Kein Render-Schema für diese Mini-App vorhanden."
},
"sdk": {
"openEntity": "Entität öffnen",
"open": "Öffnen",
"action": "Aktion",
"approvalNeeded": "Freigabe erforderlich",
"approve": "Genehmigen",
"reject": "Ablehnen",
"progress": "Fortschritt"
},
"proactive": {
"title": "Vorschläge",
"view": "Ansehen",
"dismiss": "Verwerfen",
"priority": {
"low": "Niedrig",
"medium": "Mittel",
"high": "Hoch",
"urgent": "Dringend"
}
}
},
"setupWizard": {
"title": "Setup-Assistent",
"close": "Schließen",
"back": "Zurück",
"next": "Weiter",
"finish": "Fertigstellen",
"step1": {
"title": "Willkommen bei LeoCRM",
"description": "Starten Sie mit Ihrer KI-gestützten CRM-Plattform."
},
"step2": {
"title": "Ersten Agenten erstellen",
"description": "Richten Sie einen KI-Agenten ein, der bei E-Mail-Triage, Kontaktanreicherung oder Follow-ups hilft."
},
"step3": {
"title": "Ersten Workflow erstellen",
"description": "Automatisieren Sie wiederkehrende Aufgaben mit Workflows. Starten Sie mit einer Vorlage oder erstellen Sie eigene."
},
"step4": {
"title": "Knowledge & Workstream aktivieren",
"description": "Erstellen Sie Wiki-Artikel und verbinden Sie Menschen, Agenten und Workflows in einem einheitlichen Stream."
}
},
"improvement": {
"title": "Improvement Center",
"subtitle": "Kontrollierte Selbstverbesserung — Vorschläge, Muster und Wirkungsmessung.",
"proposals": "Verbesserungsvorschläge",
"patterns": "Erkannte Muster & Engpässe",
"evidence": "Evidenz",
"approve": "Genehmigen",
"reject": "Ablehnen",
"rollback": "Zurücksetzen",
"score": "Bewertung",
"confidence": "Konfidenz",
"occurrences": "Vorkommen",
"bottleneck": "Engpass",
"rationale": "Begründung",
"expectedBenefit": "Erwarteter Nutzen",
"riskAssessment": "Risikobewertung",
"noPatterns": "Keine Muster erkannt",
"noPatternsDesc": "Es wurden noch keine Verbesserungsmuster oder Engpässe erkannt.",
"noProposals": "Keine Vorschläge",
"noProposalsDesc": "Es liegen noch keine Verbesserungsvorschläge vor.",
"status": {
"draft": "Entwurf",
"evaluating": "Wird bewertet",
"pending_approval": "Freigabe ausstehend",
"approved": "Genehmigt",
"rejected": "Abgelehnt",
"active": "Aktiv",
"rolled_back": "Zurückgesetzt",
"expired": "Abgelaufen"
},
"patternTypes": {
"repetitive_sequence": "Wiederkehrende Sequenz",
"frequent_corrections": "Häufige Korrekturen",
"rejected_suggestions": "Abgelehnte Vorschläge",
"error_retries": "Fehler & Wiederholungen",
"repetitive_handoffs": "Wiederkehrende Übergaben"
}
}
}
+1 -110
View File
@@ -20,10 +20,7 @@
"mcpSettings": "MCP Settings",
"reports": "Reports",
"tasks": "Tasks",
"workstream": "Workstream",
"wiki": "Wiki",
"improvement": "Improvements",
"onboarding": "Onboarding"
"wiki": "Wiki"
},
"auth": {
"login": "Sign In",
@@ -1289,111 +1286,5 @@
"preview": "Preview",
"split": "Split",
"writeLabel": "Markdown content"
},
"workstream": {
"title": "Workstream",
"empty": {
"title": "No messages",
"description": "There are no workstream messages yet."
},
"actor": {
"human": "Human",
"system": "System",
"agent": "Agent",
"workflow": "Workflow"
},
"block": {
"step": "Step",
"progress": "Progress",
"handoff": "Handoff",
"openSource": "Open source",
"unknown": "Unknown block type: {{type}}"
},
"miniapp": {
"untitled": "Untitled Mini-App",
"renderSchema": "Render schema",
"noSchema": "No render schema available for this Mini-App."
},
"sdk": {
"openEntity": "Open entity",
"open": "Open",
"action": "Action",
"approvalNeeded": "Approval needed",
"approve": "Approve",
"reject": "Reject",
"progress": "Progress"
},
"proactive": {
"title": "Suggestions",
"view": "View",
"dismiss": "Dismiss",
"priority": {
"low": "Low",
"medium": "Medium",
"high": "High",
"urgent": "Urgent"
}
}
},
"setupWizard": {
"title": "Setup Wizard",
"close": "Close",
"back": "Back",
"next": "Next",
"finish": "Finish",
"step1": {
"title": "Welcome to LeoCRM",
"description": "Get started with your AI-powered CRM platform."
},
"step2": {
"title": "Create Your First Agent",
"description": "Set up an AI agent to help with email triage, contact enrichment, or follow-ups."
},
"step3": {
"title": "Create Your First Workflow",
"description": "Automate repetitive tasks with workflows. Start with a template or build your own."
},
"step4": {
"title": "Enable Knowledge & Workstream",
"description": "Create wiki articles and connect humans, agents, and workflows in a unified stream."
}
},
"improvement": {
"title": "Improvement Center",
"subtitle": "Controlled self-improvement — proposals, patterns and impact measurement.",
"proposals": "Improvement Proposals",
"patterns": "Detected Patterns & Bottlenecks",
"evidence": "Evidence",
"approve": "Approve",
"reject": "Reject",
"rollback": "Rollback",
"score": "Score",
"confidence": "Confidence",
"occurrences": "Occurrences",
"bottleneck": "Bottleneck",
"rationale": "Rationale",
"expectedBenefit": "Expected Benefit",
"riskAssessment": "Risk Assessment",
"noPatterns": "No patterns detected",
"noPatternsDesc": "No improvement patterns or bottlenecks have been detected yet.",
"noProposals": "No proposals",
"noProposalsDesc": "There are no improvement proposals yet.",
"status": {
"draft": "Draft",
"evaluating": "Evaluating",
"pending_approval": "Pending Approval",
"approved": "Approved",
"rejected": "Rejected",
"active": "Active",
"rolled_back": "Rolled Back",
"expired": "Expired"
},
"patternTypes": {
"repetitive_sequence": "Repetitive Sequence",
"frequent_corrections": "Frequent Corrections",
"rejected_suggestions": "Rejected Suggestions",
"error_retries": "Errors & Retries",
"repetitive_handoffs": "Repetitive Handoffs"
}
}
}
-223
View File
@@ -1,26 +1,9 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Bot,
Workflow,
Search,
BookOpen,
DollarSign,
HeartPulse,
Loader2,
} from 'lucide-react';
import { StatCard } from '@/components/shared/StatCard';
import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
import { DashboardGrid } from '@/components/dashboard/DashboardGrid';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { useUnifiedContacts, useAuditLog } from '@/api/hooks';
import { useDashboardWidgets } from '@/api/dashboard';
import {
usePlatformDashboard,
useCostDashboard,
useUsageAnalytics,
} from '@/api/platform';
import { formatDateTime } from '@/utils/date';
export function DashboardPage() {
@@ -30,22 +13,6 @@ export function DashboardPage() {
const { data: auditData, isError: auditError } = useAuditLog(1, 5);
const { data: widgetsData, isError: widgetsError } = useDashboardWidgets();
const {
data: platformData,
isLoading: platformLoading,
isError: platformError,
} = usePlatformDashboard();
const {
data: costData,
isLoading: costLoading,
isError: costError,
} = useCostDashboard(30);
const {
data: usageData,
isLoading: usageLoading,
isError: usageError,
} = useUsageAnalytics(30);
const totalCompanies = companiesData?.total ?? 0;
const totalContacts = contactsData?.total ?? 0;
@@ -78,20 +45,6 @@ export function DashboardPage() {
const widgets = widgetsData?.items ?? [];
const agents = platformData?.agents;
const workflows = platformData?.workflows;
const search = platformData?.search;
const knowledge = platformData?.knowledge;
const systemHealth = platformData?.system_health;
const searchQueries = usageData?.search_queries;
const costUsd = costData?.total_cost_usd ?? 0;
const budget = costData?.budget;
const budgetUtilization = budget?.utilization_pct ?? 0;
const healthLabel = systemHealth?.status ?? 'unknown';
const healthVariant = healthStatusVariant(healthLabel);
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="dashboard-page">
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('dashboard.title')}</h1>
@@ -119,155 +72,6 @@ export function DashboardPage() {
/>
</div>
{/* Platform Dashboard — Phase I.7 (I-DASH) */}
<section className="mb-8" aria-label={t('dashboard.platform.title')}>
<h2 className="text-lg font-semibold text-secondary-800 mb-4">
{t('dashboard.platform.title')}
</h2>
{platformLoading || costLoading || usageLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin h-6 w-6 text-primary-500" aria-hidden="true" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Agent status */}
<Card title={t('dashboard.platform.agents')}>
{agents?.error || platformError ? (
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
) : (
<div className="space-y-3">
<MetricRow
icon={<Bot className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.activeAgents')}
value={agents?.active_agents ?? 0}
/>
<MetricRow
icon={<Bot className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.totalRuns')}
value={agents?.total_runs ?? 0}
/>
<MetricRow
icon={<Bot className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.recentRuns7d')}
value={agents?.recent_runs_7d ?? 0}
/>
</div>
)}
</Card>
{/* Workflow stats */}
<Card title={t('dashboard.platform.workflows')}>
{workflows?.error || platformError ? (
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
) : (
<div className="space-y-3">
<MetricRow
icon={<Workflow className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.activeWorkflows')}
value={workflows?.active_workflows ?? 0}
/>
<MetricRow
icon={<Workflow className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.runningInstances')}
value={workflows?.running_instances ?? 0}
/>
<MetricRow
icon={<Workflow className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.completedInstances')}
value={workflows?.completed_instances ?? 0}
/>
</div>
)}
</Card>
{/* Search metrics */}
<Card title={t('dashboard.platform.search')}>
{usageError || searchQueries?.error ? (
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
) : (
<div className="space-y-3">
<MetricRow
icon={<Search className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.totalQueries')}
value={searchQueries?.total ?? search?.total_queries ?? 0}
/>
<MetricRow
icon={<Search className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.avgLatency')}
value={
typeof search?.avg_latency_ms === 'number'
? `${search.avg_latency_ms} ms`
: '—'
}
/>
</div>
)}
</Card>
{/* Knowledge coverage */}
<Card title={t('dashboard.platform.knowledge')}>
{platformError || knowledge?.error ? (
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
) : (
<div className="space-y-3">
<MetricRow
icon={<BookOpen className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.wikiArticles')}
value={knowledge?.wiki_articles ?? 0}
/>
<MetricRow
icon={<BookOpen className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.coverage')}
value={
typeof knowledge?.coverage_pct === 'number'
? `${knowledge.coverage_pct}%`
: '—'
}
/>
</div>
)}
</Card>
{/* Cost tracking */}
<Card title={t('dashboard.platform.cost')}>
{costError || costData?.error ? (
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
) : (
<div className="space-y-3">
<MetricRow
icon={<DollarSign className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.totalCost')}
value={`$${costUsd.toFixed(2)}`}
/>
{budget?.monthly_limit_usd ? (
<MetricRow
icon={<DollarSign className="h-4 w-4" aria-hidden="true" />}
label={t('dashboard.platform.budgetUtilization')}
value={`${budgetUtilization.toFixed(1)}%`}
/>
) : null}
</div>
)}
</Card>
{/* System health */}
<Card title={t('dashboard.platform.systemHealth')}>
{platformError ? (
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
) : (
<div className="flex items-center gap-2">
<HeartPulse className="h-4 w-4 text-primary-500" aria-hidden="true" />
<Badge variant={healthVariant} dot>
{t(`dashboard.platform.health.${healthLabel}`)}
</Badge>
</div>
)}
</Card>
</div>
)}
</section>
{/* Dynamic Plugin Widgets */}
{widgetsError ? (
<p className="text-sm text-secondary-500 mb-6" data-testid="dashboard-widgets-unavailable">
@@ -291,31 +95,4 @@ export function DashboardPage() {
);
}
function MetricRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
}) {
return (
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-sm text-secondary-600">
<span className="text-primary-500" aria-hidden="true">{icon}</span>
{label}
</span>
<span className="text-sm font-semibold text-secondary-900">{value}</span>
</div>
);
}
function healthStatusVariant(status: string): 'success' | 'warning' | 'danger' | 'secondary' {
if (status === 'healthy') return 'success';
if (status === 'degraded') return 'warning';
if (status === 'down') return 'danger';
return 'secondary';
}
export default DashboardPage;
-26
View File
@@ -1,26 +0,0 @@
/**
* OnboardingPage Route wrapper for SetupWizard component.
* Renders the SetupWizard as a full-page dialog.
*/
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { SetupWizard } from '@/components/onboarding/SetupWizard';
export function OnboardingPage() {
const navigate = useNavigate();
const handleClose = useCallback(() => {
navigate('/dashboard');
}, [navigate]);
const handleComplete = useCallback(() => {
navigate('/dashboard');
}, [navigate]);
return (
<SetupWizard open={true} onClose={handleClose} onComplete={handleComplete} />
);
}
export default OnboardingPage;
-296
View File
@@ -1,296 +0,0 @@
/**
* Workstream page central view rendering all workstream block types.
*
* Block types: text, entity_card, action_card, evidence_card, approval_card,
* miniapp, workflow_status, workflow_handoff.
*
* Backend: app/ai/workstream_contract.py, app/workflows/workstream.py
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
MessageSquare,
Link2,
AlertTriangle,
Workflow,
ArrowRight,
Loader2,
} from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
import { MiniAppBlock } from '@/components/workstream/MiniAppBlock';
import {
EntityCard,
ActionButtons,
ApprovalCard,
ProgressIndicator,
DeepLink,
type ActionButtonItem,
} from '@/components/workstream/MiniAppSDK';
import { ProactiveFeed } from '@/components/workstream/ProactiveFeed';
import type {
WorkstreamMessage,
WorkstreamBlock,
ProactiveSuggestion,
} from '@/api/miniapps';
interface WorkstreamPageProps {
messages?: WorkstreamMessage[];
suggestions?: ProactiveSuggestion[];
isLoading?: boolean;
onApprove?: (approvalId: string) => void;
onReject?: (approvalId: string) => void;
onAction?: (action: ActionButtonItem) => void;
onSuggestionAction?: (suggestion: ProactiveSuggestion) => void;
onDismissSuggestion?: (id: string) => void;
}
const ACTOR_VARIANT: Record<
string,
'secondary' | 'primary' | 'info' | 'success'
> = {
human: 'secondary',
system: 'info',
agent: 'primary',
workflow: 'success',
};
/**
* Render a single workstream block by type.
*/
function BlockView({
block,
onApprove,
onReject,
onAction,
}: {
block: WorkstreamBlock;
onApprove?: (approvalId: string) => void;
onReject?: (approvalId: string) => void;
onAction?: (action: ActionButtonItem) => void;
}) {
const { t } = useTranslation();
const meta = block.metadata ?? {};
switch (block.type) {
case 'text':
return (
<div className="text-sm whitespace-pre-wrap break-words text-secondary-800">
{block.content || String(meta.text ?? '')}
</div>
);
case 'entity_card':
return (
<EntityCard
entityType={String(meta.entity_type ?? '')}
entityId={String(meta.entity_id ?? '')}
title={String(meta.title ?? block.content)}
subtitle={String(meta.subtitle ?? '')}
url={String(meta.url ?? '')}
/>
);
case 'action_card': {
const actions: ActionButtonItem[] = Array.isArray(meta.actions)
? (meta.actions as ActionButtonItem[])
: [];
return (
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm space-y-2">
<h4 className="text-sm font-semibold text-secondary-900">
{String(meta.title ?? block.content)}
</h4>
{meta.description ? (
<p className="text-sm text-secondary-600">
{String(meta.description)}
</p>
) : null}
<ActionButtons actions={actions} onAction={onAction} />
</div>
);
}
case 'evidence_card':
return (
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary-500" aria-hidden="true" />
<h4 className="text-sm font-semibold text-secondary-900">
{String(meta.title ?? block.content)}
</h4>
{typeof meta.confidence === 'number' && (
<Badge variant="secondary">
{Math.round(meta.confidence * 100)}%
</Badge>
)}
</div>
{meta.snippet ? (
<p className="mt-2 text-xs text-secondary-500">
{String(meta.snippet)}
</p>
) : null}
{meta.url ? (
<div className="mt-2">
<DeepLink href={String(meta.url)} label={t('workstream.block.openSource')} external />
</div>
) : null}
</div>
);
case 'approval_card':
return (
<ApprovalCard
approvalId={String(meta.approval_id ?? '')}
action={String(meta.action ?? block.content)}
description={String(meta.description ?? '')}
onApprove={onApprove}
onReject={onReject}
/>
);
case 'miniapp':
return <MiniAppBlock block={block} />;
case 'workflow_status':
return (
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
<div className="flex items-center gap-2">
<Workflow className="h-4 w-4 text-primary-500" aria-hidden="true" />
<h4 className="text-sm font-semibold text-secondary-900">
{String(meta.workflow_name ?? block.content)}
</h4>
<Badge variant="info">{String(meta.status ?? '')}</Badge>
</div>
{meta.step_name ? (
<p className="mt-2 text-xs text-secondary-500">
{t('workstream.block.step')}: {String(meta.step_name)}
</p>
) : null}
{typeof meta.step_index === 'number' && (
<div className="mt-3">
<ProgressIndicator
label={t('workstream.block.progress')}
value={Math.min(100, (meta.step_index + 1) * 10)}
status="in_progress"
/>
</div>
)}
</div>
);
case 'workflow_handoff':
return (
<div className="border border-warning-200 rounded-lg p-4 bg-warning-50 shadow-sm">
<div className="flex items-center gap-2">
<ArrowRight className="h-4 w-4 text-warning-700" aria-hidden="true" />
<h4 className="text-sm font-semibold text-secondary-900">
{t('workstream.block.handoff')}: {String(meta.handoff_type ?? '')}
</h4>
</div>
{meta.description ? (
<p className="mt-2 text-xs text-secondary-600">
{String(meta.description)}
</p>
) : null}
</div>
);
case 'error':
return (
<div className="border border-danger-200 rounded-lg p-4 bg-danger-50 shadow-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-danger-600 mt-0.5" aria-hidden="true" />
<div className="text-sm text-danger-700">
{block.content || String(meta.error ?? '')}
</div>
</div>
);
default:
return (
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
{t('workstream.block.unknown', { type: block.type })}
</div>
);
}
}
/**
* Central Workstream page.
*/
export function WorkstreamPage({
messages = [],
suggestions = [],
isLoading = false,
onApprove,
onReject,
onAction,
onSuggestionAction,
onDismissSuggestion,
}: WorkstreamPageProps) {
const { t } = useTranslation();
return (
<div className="max-w-3xl mx-auto p-4 space-y-6">
<header className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-primary-500" aria-hidden="true" />
<h1 className="text-lg font-semibold text-secondary-900">
{t('workstream.title')}
</h1>
</header>
<ProactiveFeed
suggestions={suggestions}
onAction={onSuggestionAction}
onDismiss={onDismissSuggestion}
/>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin h-6 w-6 text-primary-500" aria-hidden="true" />
</div>
) : messages.length === 0 ? (
<EmptyState
icon={<MessageSquare className="h-8 w-8" aria-hidden="true" />}
title={t('workstream.empty.title')}
description={t('workstream.empty.description')}
/>
) : (
<div className="space-y-4">
{messages.map((message, idx) => (
<article
key={message.actor_id ? `${message.actor_id}-${idx}` : idx}
className="border border-secondary-200 rounded-lg bg-white shadow-sm p-4"
>
<div className="flex items-center gap-2 mb-3">
<Badge variant={ACTOR_VARIANT[message.actor_type] ?? 'secondary'}>
{t(`workstream.actor.${message.actor_type}`)}
</Badge>
{message.content && (
<span className="text-sm text-secondary-600 truncate">
{message.content}
</span>
)}
</div>
{message.blocks && message.blocks.length > 0 && (
<div className="space-y-3">
{message.blocks.map((block, bidx) => (
<BlockView
key={bidx}
block={block}
onApprove={onApprove}
onReject={onReject}
onAction={onAction}
/>
))}
</div>
)}
</article>
))}
</div>
)}
</div>
);
}
export default WorkstreamPage;
-6
View File
@@ -88,10 +88,7 @@ const LogsOverviewPage = React.lazy(() => import('@/pages/logs/LogsOverview').th
const LogsPlaceholderPage = React.lazy(() => import('@/pages/logs/LogsPlaceholder').then(m => ({ default: m.LogsPlaceholderPage })));
const HelpApiDocsPage = React.lazy(() => import('@/pages/help/HelpApiDocs').then(m => ({ default: m.HelpApiDocsPage })));
const ApiDocsPage = React.lazy(() => import('@/pages/ApiDocs').then(m => ({ default: m.ApiDocsPage })));
const WorkstreamPage = React.lazy(() => import('@/pages/Workstream').then(m => ({ default: m.WorkstreamPage })));
const WikiPage = React.lazy(() => import('@/pages/Wiki').then(m => ({ default: m.WikiPage })));
const ImprovementCenterPage = React.lazy(() => import('@/components/improvement/ImprovementCenter').then(m => ({ default: m.ImprovementCenter })));
const OnboardingPage = React.lazy(() => import('@/pages/Onboarding').then(m => ({ default: m.OnboardingPage })));
/** Centered spinner fallback for lazy-loaded routes */
function PageLoader() {
@@ -270,10 +267,7 @@ const router = createBrowserRouter([
{ path: '/tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> },
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
{ path: '/workstream', element: withSuspense(<WorkstreamPage />) },
{ path: '/wiki', element: withSuspense(<WikiPage />) },
{ path: '/improvement', element: withSuspense(<ImprovementCenterPage />) },
{ path: '/onboarding', element: withSuspense(<OnboardingPage />) },
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },
],
-560
View File
@@ -1,560 +0,0 @@
"""Tests for Phase I — Integration tools: I-AW, I-AK."""
from __future__ import annotations
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestIntegrationTools:
"""Test the integration tools module (I-AW, I-AK)."""
def test_all_tools_importable(self):
"""All integration tools are importable."""
from app.ai.integration_tools import (
start_workflow_tool,
check_workflow_status_tool,
ask_knowledge_tool,
search_knowledge_tool,
register_integration_tools,
)
assert callable(start_workflow_tool)
assert callable(check_workflow_status_tool)
assert callable(ask_knowledge_tool)
assert callable(search_knowledge_tool)
assert callable(register_integration_tools)
@pytest.mark.asyncio
async def test_start_workflow_tool_returns_result(self):
"""start_workflow_tool returns instance info on success."""
from app.ai.integration_tools import start_workflow_tool
with patch("app.services.workflow_service.create_instance", new_callable=AsyncMock) as mock_create:
mock_create.return_value = {"id": "inst-123", "status": "pending"}
result = await start_workflow_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
workflow_id="wf-123", context={"key": "value"},
)
assert result["instance_id"] == "inst-123"
assert result["status"] == "pending"
assert result["workflow_id"] == "wf-123"
@pytest.mark.asyncio
async def test_start_workflow_tool_handles_not_found(self):
"""start_workflow_tool returns error when workflow not found."""
from app.ai.integration_tools import start_workflow_tool
with patch("app.services.workflow_service.create_instance", new_callable=AsyncMock) as mock_create:
mock_create.return_value = None
result = await start_workflow_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
workflow_id="nonexistent",
)
assert result["error"] == "Workflow not found"
assert result["status"] == "not_found"
@pytest.mark.asyncio
async def test_ask_knowledge_tool_delegates_to_ask_knowledge(self):
"""ask_knowledge_tool delegates to knowledge_lifecycle.ask_knowledge."""
from app.ai.integration_tools import ask_knowledge_tool
with patch("app.ai.knowledge_lifecycle.ask_knowledge", new_callable=AsyncMock) as mock_ask:
mock_ask.return_value = {"answer": "Test answer", "evidence": [], "query": "test"}
result = await ask_knowledge_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
query="test query",
)
assert result["answer"] == "Test answer"
mock_ask.assert_called_once()
@pytest.mark.asyncio
async def test_search_knowledge_tool_handles_no_search(self):
"""search_knowledge_tool returns error when search not available."""
from app.ai.integration_tools import search_knowledge_tool
with patch("app.plugins.builtins.unified_search.contracts.UnifiedSearchContract") as mock_contract:
mock_contract.get_function = MagicMock(return_value=None)
result = await search_knowledge_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
query="test",
)
assert result["error"] == "Search not available"
assert result["results"] == []
@pytest.mark.asyncio
async def test_check_workflow_status_tool_handles_not_found(self):
"""check_workflow_status_tool returns error when instance not found."""
from app.ai.integration_tools import check_workflow_status_tool
with patch("app.services.workflow_service.get_instance", new_callable=AsyncMock) as mock_get:
mock_get.return_value = None
result = await check_workflow_status_tool(
db=MagicMock(), tenant_id=uuid.uuid4(),
instance_id=str(uuid.uuid4()),
)
assert result["error"] == "Instance not found"
assert result["status"] == "not_found"
# ─── I-APPR-LOOP: Agent Loop Human-in-the-Loop Approval ──────────────────────
class TestAgentLoopApproval:
"""Test the I-APPR-LOOP approval integration in run_react_loop."""
def test_run_react_loop_has_approval_params(self):
"""run_react_loop has require_approval and approval_tools parameters."""
import inspect
from app.ai.agent_loop import run_react_loop
sig = inspect.signature(run_react_loop)
assert "require_approval" in sig.parameters
assert "approval_tools" in sig.parameters
assert sig.parameters["require_approval"].default is False
assert sig.parameters["approval_tools"].default is None
def test_react_result_has_waiting_for_approval_status(self):
"""ReActResult supports waiting_for_approval status."""
from app.ai.agent_loop import ReActResult
result = ReActResult(final_content="", status="waiting_for_approval")
assert result.status == "waiting_for_approval"
assert result.final_content == ""
# ─── I-MCP: MCP-Exposure ─────────────────────────────────────────────────────
class TestMCPExposure:
"""Test the MCP exposure layer (I-MCP)."""
def test_mcp_tools_count(self):
"""6 MCP tools are defined."""
from app.ai.mcp_exposure import MCP_TOOLS
assert len(MCP_TOOLS) == 6
def test_mcp_tool_names(self):
"""MCP tools have correct names."""
from app.ai.mcp_exposure import MCP_TOOLS
names = {t["name"] for t in MCP_TOOLS}
assert names == {"search", "ask_knowledge", "start_workflow", "check_workflow_status", "list_agents", "create_task"}
def test_get_mcp_tools_returns_schemas(self):
"""get_mcp_tools returns tool schemas without internal fields."""
from app.ai.mcp_exposure import get_mcp_tools
tools = get_mcp_tools()
for t in tools:
assert "name" in t
assert "description" in t
assert "input_schema" in t
assert "required_permission" not in t # Internal field not exposed
assert "handler" not in t # Internal field not exposed
def test_get_mcp_tool_existing(self):
"""get_mcp_tool returns tool definition for existing tool."""
from app.ai.mcp_exposure import get_mcp_tool
tool = get_mcp_tool("search")
assert tool is not None
assert tool["name"] == "search"
assert tool["required_permission"] == "contacts:read"
def test_get_mcp_tool_nonexistent(self):
"""get_mcp_tool returns None for unknown tool."""
from app.ai.mcp_exposure import get_mcp_tool
assert get_mcp_tool("nonexistent") is None
@pytest.mark.asyncio
async def test_execute_mcp_tool_unknown(self):
"""execute_mcp_tool returns error for unknown tool."""
from app.ai.mcp_exposure import execute_mcp_tool
result = await execute_mcp_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
tool_name="nonexistent", arguments={},
)
assert result["status"] == "not_found"
@pytest.mark.asyncio
async def test_execute_mcp_tool_permission_denied(self):
"""execute_mcp_tool returns forbidden when permission missing."""
from app.ai.mcp_exposure import execute_mcp_tool
result = await execute_mcp_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
tool_name="start_workflow", arguments={"workflow_id": "test"},
user_permissions={"permissions": [], "denied_permissions": [], "is_system_admin": False},
)
assert result["status"] == "forbidden"
@pytest.mark.asyncio
async def test_execute_mcp_tool_search(self):
"""execute_mcp_tool delegates to search_knowledge_tool for 'search'."""
from app.ai.mcp_exposure import execute_mcp_tool
with patch("app.ai.integration_tools.search_knowledge_tool", new_callable=AsyncMock) as mock_search:
mock_search.return_value = {"results": [], "total": 0, "query": "test"}
result = await execute_mcp_tool(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
tool_name="search", arguments={"query": "test"},
user_permissions={"is_system_admin": True},
)
assert result["query"] == "test"
mock_search.assert_called_once()
# ─── I-WORK-BASE/ACTOR/HANDOFF: Workstream Contract ──────────────────────────
class TestWorkstreamContract:
"""Test the workstream contract module (I-WORK-BASE, I-WORK-ACTOR, I-WORK-HANDOFF)."""
def test_workstream_block_dataclass(self):
"""WorkstreamBlock dataclass works correctly."""
from app.ai.workstream_contract import WorkstreamBlock
block = WorkstreamBlock(type="text", content="Hello", metadata={"key": "value"})
assert block.type == "text"
assert block.content == "Hello"
d = block.to_dict()
assert d["type"] == "text"
assert d["content"] == "Hello"
def test_workstream_message_dataclass(self):
"""WorkstreamMessage dataclass works correctly."""
from app.ai.workstream_contract import WorkstreamMessage, WorkstreamBlock
msg = WorkstreamMessage(actor_type="agent", actor_id="abc-123", content="Test", blocks=[WorkstreamBlock(type="text")])
assert msg.actor_type == "agent"
assert msg.actor_id == "abc-123"
d = msg.to_dict()
assert d["actor_type"] == "agent"
assert len(d["blocks"]) == 1
def test_build_entity_card(self):
"""build_entity_card creates correct block."""
from app.ai.workstream_contract import build_entity_card
block = build_entity_card("contact", "123", "John Doe", "CEO", "/contacts/123")
assert block.type == "entity_card"
assert block.metadata["entity_type"] == "contact"
assert block.metadata["title"] == "John Doe"
def test_build_action_card(self):
"""build_action_card creates correct block."""
from app.ai.workstream_contract import build_action_card
block = build_action_card("Approve?", [{"label": "Yes", "action": "approve"}], "Please approve")
assert block.type == "action_card"
assert len(block.metadata["actions"]) == 1
def test_build_evidence_card(self):
"""build_evidence_card creates correct block."""
from app.ai.workstream_contract import build_evidence_card
block = build_evidence_card("wiki", "456", "Article", "Snippet", "/wiki/456", 0.9)
assert block.type == "evidence_card"
assert block.metadata["confidence"] == 0.9
def test_build_approval_card(self):
"""build_approval_card creates correct block."""
from app.ai.workstream_contract import build_approval_card
block = build_approval_card("appr-123", "send_email", "Please approve")
assert block.type == "approval_card"
assert block.metadata["approval_id"] == "appr-123"
def test_build_miniapp_block(self):
"""build_miniapp_block creates correct block."""
from app.ai.workstream_contract import build_miniapp_block
block = build_miniapp_block("calendar-app", "Calendar", {"type": "form"})
assert block.type == "miniapp"
assert block.metadata["app_id"] == "calendar-app"
@pytest.mark.asyncio
async def test_post_to_workstream_fallback(self):
"""post_to_workstream falls back to notification when CommContract unavailable."""
from app.ai.workstream_contract import post_to_workstream, WorkstreamMessage, WorkstreamBlock
with patch("app.core.notifications.post_system_message", new_callable=AsyncMock):
result = await post_to_workstream(
db=MagicMock(), tenant_id=uuid.uuid4(),
message=WorkstreamMessage(actor_type="human", actor_id=str(uuid.uuid4()), content="Test", blocks=[WorkstreamBlock(type="text")]),
)
assert result is None # Fallback
@pytest.mark.asyncio
async def test_create_handoff_creates_task(self):
"""create_handoff creates a Task with task_type='handoff'."""
from app.ai.workstream_contract import create_handoff
with patch("app.plugins.builtins.tasks.services.create_task", new_callable=AsyncMock) as mock_create:
mock_create.return_value = {"id": "task-123", "title": "Handoff: review_needed"}
with patch("app.ai.workstream_contract.post_to_workstream", new_callable=AsyncMock):
result = await create_handoff(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
handoff_type="review_needed", description="Please review",
)
assert result["task"]["id"] == "task-123"
mock_create.assert_called_once()
# Verify task_type is 'handoff'
call_args = mock_create.call_args
assert call_args[0][3]["task_type"] == "handoff"
# ─── I-WORK-PROACTIVE: Proactive Workstream Feed ─────────────────────────────
class TestProactiveFeed:
"""Test the proactive feed module (I-WORK-PROACTIVE)."""
def test_proactive_suggestion_dataclass(self):
"""ProactiveSuggestion dataclass works correctly."""
from app.ai.proactive_feed import ProactiveSuggestion
s = ProactiveSuggestion(trigger="mail.received", title="Test", priority="medium")
assert s.trigger == "mail.received"
assert s.priority == "medium"
d = s.to_dict()
assert d["trigger"] == "mail.received"
def test_get_cooldown_known_trigger(self):
"""get_cooldown returns correct cooldown for known triggers."""
from app.ai.proactive_feed import get_cooldown
assert get_cooldown("mail.received") == 300
assert get_cooldown("contact.created") == 600
assert get_cooldown("workflow.completed") == 60
def test_get_cooldown_unknown_trigger(self):
"""get_cooldown returns default for unknown triggers."""
from app.ai.proactive_feed import get_cooldown
assert get_cooldown("unknown.trigger") == 300
def test_is_cooled_down_initial(self):
"""is_cooled_down returns False for first-time trigger."""
from app.ai.proactive_feed import is_cooled_down
assert is_cooled_down(uuid.uuid4(), "mail.received") is False
def test_mark_suggested_sets_cooldown(self):
"""mark_suggested sets cooldown for the trigger."""
from app.ai.proactive_feed import is_cooled_down, mark_suggested
tid = uuid.uuid4()
mark_suggested(tid, "mail.received", "msg-123")
assert is_cooled_down(tid, "mail.received", "msg-123") is True
def test_filter_by_user_settings_enabled(self):
"""filter_by_user_settings filters by enabled flag."""
from app.ai.proactive_feed import ProactiveSuggestion, filter_by_user_settings
suggestions = [ProactiveSuggestion(trigger="test", priority="medium")]
assert len(filter_by_user_settings(suggestions, {"enabled": True})) == 1
assert len(filter_by_user_settings(suggestions, {"enabled": False})) == 0
def test_filter_by_user_settings_min_priority(self):
"""filter_by_user_settings filters by min_priority."""
from app.ai.proactive_feed import ProactiveSuggestion, filter_by_user_settings
suggestions = [
ProactiveSuggestion(trigger="test", priority="low"),
ProactiveSuggestion(trigger="test", priority="medium"),
ProactiveSuggestion(trigger="test", priority="high"),
]
filtered = filter_by_user_settings(suggestions, {"enabled": True, "min_priority": "medium"})
assert len(filtered) == 2 # medium + high
@pytest.mark.asyncio
async def test_generate_suggestions_mail_received(self):
"""generate_suggestions creates suggestion for mail.received trigger."""
from app.ai.proactive_feed import generate_suggestions
suggestions = await generate_suggestions(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
trigger="mail.received", payload={"entity_id": str(uuid.uuid4()), "sender": "test@example.com"},
)
assert len(suggestions) == 1
assert suggestions[0].trigger == "mail.received"
assert suggestions[0].priority == "medium"
@pytest.mark.asyncio
async def test_generate_suggestions_cooldown_blocks(self):
"""generate_suggestions returns empty list when in cooldown."""
from app.ai.proactive_feed import generate_suggestions, mark_suggested
tid = uuid.uuid4()
eid = str(uuid.uuid4())
mark_suggested(tid, "mail.received", eid)
suggestions = await generate_suggestions(
db=MagicMock(), tenant_id=tid, user_id=uuid.uuid4(),
trigger="mail.received", payload={"entity_id": eid},
)
assert len(suggestions) == 0
# ─── I-DASH/I-COST/I-USE: Dashboard & Analytics ──────────────────────────────
class TestDashboardAnalytics:
"""Test the dashboard & analytics module (I-DASH, I-COST, I-USE)."""
def test_dashboard_functions_importable(self):
"""All dashboard functions are importable."""
from app.ai.dashboard import get_platform_dashboard, get_cost_dashboard, get_usage_analytics
assert callable(get_platform_dashboard)
assert callable(get_cost_dashboard)
assert callable(get_usage_analytics)
@pytest.mark.asyncio
async def test_get_platform_dashboard_returns_dict(self):
"""get_platform_dashboard returns a dict with expected keys."""
from app.ai.dashboard import get_platform_dashboard
# Mock all DB queries to return 0
mock_db = AsyncMock()
mock_db.scalar = AsyncMock(return_value=0)
mock_db.execute = AsyncMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
result = await get_platform_dashboard(mock_db, uuid.uuid4())
assert isinstance(result, dict)
assert "agents" in result
assert "workflows" in result
assert "knowledge" in result
assert "system_health" in result
assert "generated_at" in result
@pytest.mark.asyncio
async def test_get_cost_dashboard_returns_dict(self):
"""get_cost_dashboard returns a dict with cost data."""
from app.ai.dashboard import get_cost_dashboard
mock_db = AsyncMock()
mock_db.scalar = AsyncMock(return_value=0.0)
mock_db.execute = AsyncMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
result = await get_cost_dashboard(mock_db, uuid.uuid4(), days=30)
assert isinstance(result, dict)
assert "total_cost_usd" in result
assert "by_agent" in result
assert "period_days" in result
assert result["period_days"] == 30
@pytest.mark.asyncio
async def test_get_usage_analytics_returns_dict(self):
"""get_usage_analytics returns a dict with usage data."""
from app.ai.dashboard import get_usage_analytics
mock_db = AsyncMock()
mock_db.scalar = AsyncMock(return_value=0)
result = await get_usage_analytics(mock_db, uuid.uuid4(), days=7)
assert isinstance(result, dict)
assert "agent_runs" in result
assert "workflow_executions" in result
assert result["period_days"] == 7
# ─── I-DSGVO/I-DSAR/I-COMP-EXPORT: DSGVO & Compliance ────────────────────────
class TestDSGVOExport:
"""Test the DSGVO export module (I-DSGVO, I-DSAR, I-COMP-EXPORT)."""
def test_dsgvo_functions_importable(self):
"""All DSGVO functions are importable."""
from app.ai.dsgvo_export import export_user_data, create_dsar_request, export_compliance_evidence
assert callable(export_user_data)
assert callable(create_dsar_request)
assert callable(export_compliance_evidence)
@pytest.mark.asyncio
async def test_export_user_data_returns_dict(self):
"""export_user_data returns structured dict with expected sections."""
from app.ai.dsgvo_export import export_user_data
mock_db = AsyncMock()
mock_db.get = AsyncMock(return_value=None)
mock_db.execute = AsyncMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
result = await export_user_data(mock_db, uuid.uuid4(), uuid.uuid4())
assert isinstance(result, dict)
assert "export_metadata" in result
assert "core" in result
assert "agents" in result
assert "audit" in result
assert result["export_metadata"]["export_type"] == "dsgvo_data_subject_access"
@pytest.mark.asyncio
async def test_create_dsar_request_creates_task(self):
"""create_dsar_request creates a Task with task_type='dsar'."""
from app.ai.dsgvo_export import create_dsar_request
with patch("app.plugins.builtins.tasks.services.create_task", new_callable=AsyncMock) as mock_create:
mock_create.return_value = {"id": "task-dsar-123", "title": "DSAR: access"}
result = await create_dsar_request(
db=MagicMock(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4(),
subject_user_id=uuid.uuid4(), request_type="access",
)
assert result["task"]["id"] == "task-dsar-123"
assert result["request_type"] == "access"
call_args = mock_create.call_args
assert call_args[0][3]["task_type"] == "dsar"
@pytest.mark.asyncio
async def test_export_compliance_evidence_returns_dict(self):
"""export_compliance_evidence returns structured evidence package."""
from app.ai.dsgvo_export import export_compliance_evidence
mock_db = AsyncMock()
mock_db.execute = AsyncMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
result = await export_compliance_evidence(mock_db, uuid.uuid4(), days=90)
assert isinstance(result, dict)
assert "export_metadata" in result
assert "agent_definitions" in result
assert "approval_records" in result
assert "technical_policies" in result
assert result["export_metadata"]["export_type"] == "compliance_evidence"
assert result["export_metadata"]["period_days"] == 90
def test_technical_policies_structure(self):
"""Technical policies have expected structure."""
# This is tested via export_compliance_evidence but we can check the helper
from app.ai.dsgvo_export import _get_sensitive_fields
fields = _get_sensitive_fields()
assert isinstance(fields, dict)
assert len(fields) > 0
# ─── I-ONB: Onboarding ──────────────────────────────────────────────────────
class TestOnboarding:
"""Test the onboarding module (I-ONB)."""
def test_onboarding_functions_importable(self):
"""All onboarding functions are importable."""
from app.ai.onboarding import get_onboarding_status, get_onboarding_guide
assert callable(get_onboarding_status)
assert callable(get_onboarding_guide)
@pytest.mark.asyncio
async def test_get_onboarding_status_returns_dict(self):
"""get_onboarding_status returns a dict with steps and progress."""
from app.ai.onboarding import get_onboarding_status
mock_db = AsyncMock()
mock_db.scalar = AsyncMock(return_value=0)
result = await get_onboarding_status(mock_db, uuid.uuid4(), uuid.uuid4())
assert isinstance(result, dict)
assert "steps" in result
assert "progress_pct" in result
assert "welcome" in result["steps"]
assert "create_agent" in result["steps"]
assert result["steps"]["welcome"]["completed"] is True
def test_get_onboarding_guide_returns_steps(self):
"""get_onboarding_guide returns a list of steps."""
from app.ai.onboarding import get_onboarding_guide
result = get_onboarding_guide()
assert isinstance(result, dict)
assert "steps" in result
assert len(result["steps"]) == 5
step_ids = [s["id"] for s in result["steps"]]
assert "welcome" in step_ids
assert "create_agent" in step_ids
assert "create_workflow" in step_ids
assert "enable_knowledge" in step_ids
assert "enable_workstream" in step_ids
-270
View File
@@ -1,270 +0,0 @@
"""Tests for Phase J — Controlled Self-Improvement."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestImprovementSignals:
"""Test J-SIGNAL: Improvement signal collection."""
def test_signal_dataclass(self):
from app.ai.self_improvement import ImprovementSignal, SignalType
s = ImprovementSignal(signal_type=SignalType.AGENT_RUN, source_ref="agent_run:123", tenant_id="t1")
assert s.signal_type == SignalType.AGENT_RUN
assert s.source_ref == "agent_run:123"
d = s.to_dict()
assert d["signal_type"] == "agent_run"
def test_all_signal_types(self):
from app.ai.self_improvement import SignalType
assert SignalType.AGENT_RUN.value == "agent_run"
assert SignalType.WORKFLOW_RUN.value == "workflow_run"
assert SignalType.USER_CORRECTION.value == "user_correction"
assert SignalType.HANDOFF.value == "handoff"
assert SignalType.ERROR_RETRY.value == "error_retry"
@pytest.mark.asyncio
async def test_collect_signals_returns_list(self):
from app.ai.self_improvement import collect_signals
mock_db = AsyncMock()
mock_db.execute = AsyncMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
signals = await collect_signals(mock_db, uuid.uuid4(), days=30)
assert isinstance(signals, list)
class TestPatternDetection:
"""Test J-PATTERN: Pattern/bottleneck detection."""
def test_detect_patterns_empty(self):
from app.ai.self_improvement import detect_patterns
assert detect_patterns([]) == []
def test_detect_error_retries(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [
ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error"),
ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error"),
ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error"),
]
patterns = detect_patterns(signals)
assert len(patterns) == 1
assert patterns[0].pattern_type == "error_retries"
assert patterns[0].occurrence_count == 3
def test_detect_frequent_corrections(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [
ImprovementSignal(signal_type=SignalType.USER_CORRECTION, outcome="corrected"),
ImprovementSignal(signal_type=SignalType.USER_CORRECTION, outcome="corrected"),
ImprovementSignal(signal_type=SignalType.USER_CORRECTION, outcome="corrected"),
]
patterns = detect_patterns(signals)
assert len(patterns) == 1
assert patterns[0].pattern_type == "frequent_corrections"
def test_detect_repetitive_handoffs(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [
ImprovementSignal(signal_type=SignalType.HANDOFF, outcome="handoff"),
ImprovementSignal(signal_type=SignalType.HANDOFF, outcome="handoff"),
ImprovementSignal(signal_type=SignalType.HANDOFF, outcome="handoff"),
]
patterns = detect_patterns(signals)
assert len(patterns) == 1
assert patterns[0].pattern_type == "repetitive_handoffs"
def test_detect_rejected_suggestions(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [
ImprovementSignal(signal_type=SignalType.PROACTIVE_SUGGESTION, outcome="dismissed") for _ in range(5)
]
patterns = detect_patterns(signals)
assert len(patterns) == 1
assert patterns[0].pattern_type == "rejected_suggestions"
def test_pattern_confidence_capped(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error") for _ in range(50)]
patterns = detect_patterns(signals)
assert patterns[0].confidence <= 0.9
class TestImprovementProposal:
"""Test J-PROP: Improvement proposal creation."""
def test_proposal_dataclass(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus
p = ImprovementProposal(proposal_type=ProposalType.AGENT, title="Test")
assert p.proposal_type == ProposalType.AGENT
assert p.status == ProposalStatus.DRAFT
d = p.to_dict()
assert d["proposal_type"] == "agent"
assert d["status"] == "draft"
def test_create_proposal_from_pattern(self):
from app.ai.self_improvement import DetectedPattern, create_proposal, ProposalType
pattern = DetectedPattern(pattern_type="error_retries", description="3 errors", confidence=0.8, occurrence_count=3)
proposal = create_proposal(pattern, ProposalType.AGENT, "Fix agent errors")
assert proposal.title == "Fix agent errors"
assert proposal.proposal_type == ProposalType.AGENT
assert len(proposal.evidence_refs) == 0 # pattern had no refs
assert proposal.status.value == "draft"
def test_all_proposal_types(self):
from app.ai.self_improvement import ProposalType
assert ProposalType.AGENT.value == "agent"
assert ProposalType.SKILL.value == "skill"
assert ProposalType.WORKFLOW.value == "workflow"
assert ProposalType.PLUGIN_PATCH.value == "plugin_patch"
def test_all_proposal_statuses(self):
from app.ai.self_improvement import ProposalStatus
assert ProposalStatus.DRAFT.value == "draft"
assert ProposalStatus.PENDING_APPROVAL.value == "pending_approval"
assert ProposalStatus.APPROVED.value == "approved"
assert ProposalStatus.ACTIVE.value == "active"
assert ProposalStatus.ROLLED_BACK.value == "rolled_back"
class TestVersionedDraft:
"""Test J-DRAFT: Versioned draft creation."""
def test_draft_dataclass(self):
from app.ai.self_improvement import VersionedDraft
d = VersionedDraft(proposal_id="p1", version=1, config={"key": "value"})
assert d.version == 1
assert d.config == {"key": "value"}
assert d.previous_version_id is None
def test_create_draft_first_version(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, create_draft
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, draft_config={"model": "gpt-4o"})
draft = create_draft(proposal)
assert draft.version == 1
assert draft.config == {"model": "gpt-4o"}
assert draft.previous_version_id is None
def test_create_draft_incremented_version(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, create_draft, VersionedDraft
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, draft_config={"model": "gpt-4o-mini"})
prev = VersionedDraft(proposal_id="p1", version=1, config={"model": "gpt-4o"})
draft = create_draft(proposal, prev)
assert draft.version == 2
assert draft.previous_version_id == prev.id
class TestEvaluation:
"""Test J-EVAL: Evaluation/sandbox."""
@pytest.mark.asyncio
async def test_evaluate_proposal_returns_result(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, VersionedDraft, evaluate_proposal, ImprovementSignal, SignalType
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT)
draft = VersionedDraft(proposal_id=proposal.id, version=1)
signals = [ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error") for _ in range(10)]
result = await evaluate_proposal(proposal, draft, signals)
assert result["test_cases"] == 10
assert result["passed"] == 10
assert result["score"] == 100.0
assert result["recommendation"] == "approve"
@pytest.mark.asyncio
async def test_evaluate_empty_signals(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, VersionedDraft, evaluate_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT)
draft = VersionedDraft(proposal_id=proposal.id, version=1)
result = await evaluate_proposal(proposal, draft, [])
assert result["test_cases"] == 0
assert result["score"] == 0.0
class TestApprovalActivation:
"""Test J-APPROVAL, J-ACTIVATE: Approval and activation."""
@pytest.mark.asyncio
async def test_request_approval(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, request_approval
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT)
with patch("app.core.approval.create_approval_request", new_callable=AsyncMock) as mock_approval:
mock_approval.return_value = MagicMock(id=uuid.uuid4())
result = await request_approval(AsyncMock(), uuid.uuid4(), uuid.uuid4(), proposal, {"score": 80})
assert result["status"] == "pending_approval"
assert proposal.status == ProposalStatus.PENDING_APPROVAL
@pytest.mark.asyncio
async def test_activate_without_approval_rejected(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, VersionedDraft, activate_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, status=ProposalStatus.DRAFT)
draft = VersionedDraft(proposal_id=proposal.id, version=1)
result = await activate_proposal(AsyncMock(), uuid.uuid4(), proposal, draft)
assert result["status"] == "rejected"
@pytest.mark.asyncio
async def test_activate_approved_proposal(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, VersionedDraft, activate_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, status=ProposalStatus.APPROVED)
draft = VersionedDraft(proposal_id=proposal.id, version=1)
result = await activate_proposal(AsyncMock(), uuid.uuid4(), proposal, draft)
assert result["status"] == "active"
assert result["rollback_available"] is True
assert proposal.status == ProposalStatus.ACTIVE
@pytest.mark.asyncio
async def test_rollback_active_proposal(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, VersionedDraft, rollback_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, status=ProposalStatus.ACTIVE)
prev_draft = VersionedDraft(proposal_id=proposal.id, version=1)
result = await rollback_proposal(AsyncMock(), uuid.uuid4(), proposal, prev_draft)
assert result["status"] == "rolled_back"
assert proposal.status == ProposalStatus.ROLLED_BACK
@pytest.mark.asyncio
async def test_rollback_non_active_rejected(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, rollback_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, status=ProposalStatus.DRAFT)
result = await rollback_proposal(AsyncMock(), uuid.uuid4(), proposal)
assert result["status"] == "rejected"
class TestImpactMeasurement:
"""Test J-MEASURE: Pre/post impact measurement."""
@pytest.mark.asyncio
async def test_measure_impact_not_active(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, measure_impact
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT)
result = await measure_impact(AsyncMock(), uuid.uuid4(), proposal)
assert result["status"] == "not_active"
@pytest.mark.asyncio
async def test_measure_impact_active(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, measure_impact
proposal = ImprovementProposal(
proposal_type=ProposalType.AGENT,
status=ProposalStatus.ACTIVE,
activated_at=datetime.now(UTC) - timedelta(days=3),
measurement_before={"total_runs": 10, "errors": 5, "cost_usd": 1.0, "error_rate": 50.0},
)
mock_db = MagicMock()
mock_scalar = AsyncMock(side_effect=[20, 2, 0.5])
mock_db.scalar = mock_scalar
result = await measure_impact(mock_db, uuid.uuid4(), proposal, days=7)
assert isinstance(result, dict)
assert "proposal_id" in result
assert "period_days" in result
assert result["period_days"] == 7
# After may contain error if mock DB queries fail, or metrics if they succeed
assert "after" in result
@pytest.mark.asyncio
async def test_capture_baseline(self):
from app.ai.self_improvement import capture_baseline
mock_db = MagicMock()
mock_scalar = AsyncMock(side_effect=[10, 2, 0.5])
mock_db.scalar = mock_scalar
result = await capture_baseline(mock_db, uuid.uuid4(), days=7)
assert isinstance(result, dict)
# Result may contain metrics or error depending on mock DB behavior
assert len(result) > 0