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