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,
|
||||
workflows,
|
||||
workspaces,
|
||||
platform,
|
||||
)
|
||||
|
||||
# ── Graceful shutdown signal ─────────────────────────────────────────────────
|
||||
@@ -581,7 +580,6 @@ def create_app() -> FastAPI:
|
||||
app.include_router(outbox.router)
|
||||
app.include_router(api_tokens.router)
|
||||
app.include_router(approvals.router)
|
||||
app.include_router(platform.router)
|
||||
|
||||
# ── Register plugin routes for all discovered plugins ──
|
||||
# Routes are registered at app creation time so OpenAPI docs are complete.
|
||||
|
||||
@@ -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[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 0 },
|
||||
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 10 },
|
||||
{ to: '/workstream', labelKey: 'nav.workstream', icon: <MessageSquare className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 25 },
|
||||
{ to: '/wiki', labelKey: 'nav.wiki', icon: <BookOpen className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 30 },
|
||||
{ to: '/improvement', labelKey: 'nav.improvement', icon: <Lightbulb className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 90 },
|
||||
{ to: '/onboarding', labelKey: 'nav.onboarding', icon: <Sparkles className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 95 },
|
||||
];
|
||||
|
||||
const bottomItems: NavSingleItem[] = [];
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* SetupWizard — Phase I.6 (I-ONB) setup wizard.
|
||||
*
|
||||
* Guides users through 4 steps: Welcome, Create first Agent,
|
||||
* Create first Workflow, Enable Knowledge & Workstream.
|
||||
*
|
||||
* Uses lucide-react icons, Tailwind, and i18n via `t()`.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Sparkles,
|
||||
Bot,
|
||||
Workflow,
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface SetupWizardProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const STEP_ICONS = [Sparkles, Bot, Workflow, BookOpen];
|
||||
|
||||
/**
|
||||
* Setup wizard with 4 steps. Each step shows a title, description and an
|
||||
* optional action button that deep-links to the relevant setup page.
|
||||
*/
|
||||
export function SetupWizard({ open, onClose, onComplete }: SetupWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const totalSteps = 4;
|
||||
const isLast = step === totalSteps - 1;
|
||||
const Icon = STEP_ICONS[step];
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLast) {
|
||||
onComplete?.();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setStep((s) => s + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 0) setStep((s) => s - 1);
|
||||
};
|
||||
|
||||
const stepKey = `setupWizard.step${step + 1}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-secondary-900/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('setupWizard.title')}
|
||||
data-testid="setup-wizard"
|
||||
>
|
||||
<Card className="w-full max-w-lg">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary-200">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">
|
||||
{t('setupWizard.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('setupWizard.close')}
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-2 mb-6" aria-hidden="true">
|
||||
{Array.from({ length: totalSteps }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
||||
i <= step ? 'bg-primary-500' : 'bg-secondary-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-primary-100 text-primary-700 flex items-center justify-center">
|
||||
<Icon className="h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">
|
||||
{t(`${stepKey}.title`)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-600">
|
||||
{t(`${stepKey}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-secondary-200 flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
disabled={step === 0}
|
||||
icon={<ChevronLeft className="h-4 w-4" />}
|
||||
>
|
||||
{t('setupWizard.back')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
icon={isLast ? undefined : <ChevronRight className="h-4 w-4" />}
|
||||
>
|
||||
{isLast ? t('setupWizard.finish') : t('setupWizard.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupWizard;
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* MiniAppBlock — renders a `miniapp` workstream block.
|
||||
*
|
||||
* Uses `metadata.app_id` and `metadata.render_schema` to render the MiniApp.
|
||||
* Falls back to a placeholder state for unknown apps.
|
||||
*
|
||||
* Backend: app/ai/workstream_contract.py → build_miniapp_block
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppWindow, AlertCircle } from 'lucide-react';
|
||||
import type { WorkstreamBlock } from '@/api/miniapps';
|
||||
|
||||
interface MiniAppBlockProps {
|
||||
block: WorkstreamBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a MiniApp block. If the app is unknown or render_schema is empty,
|
||||
* show a fallback state with the app_id.
|
||||
*/
|
||||
export function MiniAppBlock({ block }: MiniAppBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const metadata = block.metadata ?? {};
|
||||
const appId = typeof metadata.app_id === 'string' ? metadata.app_id : '';
|
||||
const title = typeof metadata.title === 'string' ? metadata.title : '';
|
||||
const renderSchema =
|
||||
metadata.render_schema && typeof metadata.render_schema === 'object'
|
||||
? (metadata.render_schema as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const hasRenderSchema = Object.keys(renderSchema).length > 0;
|
||||
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-100 text-primary-700 flex items-center justify-center">
|
||||
<AppWindow className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{title || appId || t('workstream.miniapp.untitled')}
|
||||
</h4>
|
||||
{hasRenderSchema ? (
|
||||
<div className="mt-2 text-sm text-secondary-600">
|
||||
{t('workstream.miniapp.renderSchema')}
|
||||
<pre className="mt-2 p-2 rounded bg-secondary-50 text-xs font-mono overflow-x-auto">
|
||||
{JSON.stringify(renderSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-secondary-400">
|
||||
<AlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t('workstream.miniapp.noSchema')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MiniAppBlock;
|
||||
@@ -1,290 +0,0 @@
|
||||
/**
|
||||
* MiniAppSDK — standard building blocks for MiniApp rendering.
|
||||
*
|
||||
* Provides reusable components used by MiniAppBlock and the Workstream page:
|
||||
* - EntityCard: link to a CRM entity
|
||||
* - ActionButtons: list of action buttons (deep links / callbacks)
|
||||
* - ApprovalCard: approve/reject actions
|
||||
* - ProgressIndicator: progress bar with label
|
||||
* - DeepLink: safe external/internal link
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
// ── EntityCard ──
|
||||
|
||||
export interface EntityCardProps {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card linking to a CRM entity (contact, company, mail, etc.).
|
||||
*/
|
||||
export function EntityCard({
|
||||
entityType,
|
||||
entityId,
|
||||
title,
|
||||
subtitle,
|
||||
url,
|
||||
}: EntityCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const href = url || `/${entityType}/${entityId}`;
|
||||
const safeHref = hrefSafe(href);
|
||||
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Badge variant="secondary">{entityType}</Badge>
|
||||
<h4 className="mt-2 text-sm font-semibold text-secondary-900 truncate">
|
||||
{title || entityId}
|
||||
</h4>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-xs text-secondary-500 truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{safeHref && (
|
||||
<a
|
||||
href={safeHref}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700 focus-visible:ring-2 focus-visible:ring-primary-500 rounded px-2 py-1"
|
||||
aria-label={t('workstream.sdk.openEntity')}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t('workstream.sdk.open')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── ActionButtons ──
|
||||
|
||||
export interface ActionButtonItem {
|
||||
label: string;
|
||||
action: string;
|
||||
type?: 'primary' | 'secondary' | 'danger';
|
||||
}
|
||||
|
||||
export interface ActionButtonsProps {
|
||||
actions: ActionButtonItem[];
|
||||
onAction?: (action: ActionButtonItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a list of action buttons. Actions are either deep links (http/https)
|
||||
* or callbacks handled by the parent via `onAction`.
|
||||
*/
|
||||
export function ActionButtons({ actions, onAction }: ActionButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!actions || actions.length === 0) return null;
|
||||
|
||||
const handleClick = (action: ActionButtonItem) => {
|
||||
if (onAction) {
|
||||
onAction(action);
|
||||
return;
|
||||
}
|
||||
const url = action.action;
|
||||
if (isSafeUrl(url)) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{actions.map((action, idx) => {
|
||||
const variant =
|
||||
action.type === 'danger'
|
||||
? 'danger'
|
||||
: action.type === 'secondary'
|
||||
? 'secondary'
|
||||
: 'primary';
|
||||
return (
|
||||
<Button
|
||||
key={idx}
|
||||
variant={variant}
|
||||
size="sm"
|
||||
onClick={() => handleClick(action)}
|
||||
>
|
||||
{action.label || t('workstream.sdk.action')}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ApprovalCard ──
|
||||
|
||||
export interface ApprovalCardProps {
|
||||
approvalId: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
onApprove?: (approvalId: string) => void;
|
||||
onReject?: (approvalId: string) => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval card with approve/reject actions.
|
||||
*/
|
||||
export function ApprovalCard({
|
||||
approvalId,
|
||||
action,
|
||||
description,
|
||||
onApprove,
|
||||
onReject,
|
||||
isPending = false,
|
||||
}: ApprovalCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border border-warning-200 rounded-lg p-4 bg-warning-50 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-warning-100 text-warning-700 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{t('workstream.sdk.approvalNeeded')}: {action}
|
||||
</h4>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-secondary-600">{description}</p>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => onApprove?.(approvalId)}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
>
|
||||
{t('workstream.sdk.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => onReject?.(approvalId)}
|
||||
icon={<XCircle className="h-4 w-4" />}
|
||||
>
|
||||
{t('workstream.sdk.reject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ProgressIndicator ──
|
||||
|
||||
export interface ProgressIndicatorProps {
|
||||
label?: string;
|
||||
value: number; // 0-100
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress bar with optional label and status color.
|
||||
*/
|
||||
export function ProgressIndicator({
|
||||
label,
|
||||
value,
|
||||
status = 'in_progress',
|
||||
}: ProgressIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const clamped = Math.max(0, Math.min(100, value));
|
||||
const barColor =
|
||||
status === 'error'
|
||||
? 'bg-danger-500'
|
||||
: status === 'completed'
|
||||
? 'bg-success-500'
|
||||
: 'bg-primary-500';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-medium text-secondary-700">{label}</span>
|
||||
<span className="text-xs text-secondary-400">{clamped}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="w-full h-2 rounded-full bg-secondary-100 overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuenow={clamped}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label || t('workstream.sdk.progress')}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${barColor}`}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── DeepLink ──
|
||||
|
||||
export interface DeepLinkProps {
|
||||
href: string;
|
||||
label?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe deep link — only allows http(s) and internal paths.
|
||||
*/
|
||||
export function DeepLink({ href, label, external = false }: DeepLinkProps) {
|
||||
const { t } = useTranslation();
|
||||
const safeHref = hrefSafe(href);
|
||||
if (!safeHref) {
|
||||
return <span className="text-xs text-secondary-400">{label || href}</span>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 hover:underline"
|
||||
>
|
||||
{label || href}
|
||||
{external && <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function hrefSafe(href: string): string | null {
|
||||
if (!href) return null;
|
||||
if (href.startsWith('/')) return href;
|
||||
try {
|
||||
const parsed = new URL(href);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSafeUrl(url: string): boolean {
|
||||
return hrefSafe(url) !== null;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* ProactiveFeed — contextual suggestions/actions in the workstream.
|
||||
*
|
||||
* Renders ProactiveSuggestion items with priority, client-side dedupe and
|
||||
* cooldown (mirrors backend app/ai/proactive_feed.py). Suggestions are
|
||||
* non-intrusive: no popups, just a feed section.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sparkles, Info, ArrowRight, X } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { ProactiveSuggestion, ProactivePriority } from '@/api/miniapps';
|
||||
|
||||
interface ProactiveFeedProps {
|
||||
suggestions: ProactiveSuggestion[];
|
||||
/** Cooldown in ms per trigger; default 300000 (5 min). */
|
||||
cooldowns?: Record<string, number>;
|
||||
/** Minimum priority to show. */
|
||||
minPriority?: ProactivePriority;
|
||||
onDismiss?: (id: string) => void;
|
||||
onAction?: (suggestion: ProactiveSuggestion) => void;
|
||||
}
|
||||
|
||||
const PRIORITY_ORDER: Record<ProactivePriority, number> = {
|
||||
low: 0,
|
||||
medium: 1,
|
||||
high: 2,
|
||||
urgent: 3,
|
||||
};
|
||||
|
||||
const PRIORITY_VARIANT: Record<
|
||||
ProactivePriority,
|
||||
'secondary' | 'info' | 'warning' | 'danger'
|
||||
> = {
|
||||
low: 'secondary',
|
||||
medium: 'info',
|
||||
high: 'warning',
|
||||
urgent: 'danger',
|
||||
};
|
||||
|
||||
const ACTION_TYPE_ICON: Record<string, React.ReactNode> = {
|
||||
suggestion: <Sparkles className="h-4 w-4" aria-hidden="true" />,
|
||||
action_required: <ArrowRight className="h-4 w-4" aria-hidden="true" />,
|
||||
info: <Info className="h-4 w-4" aria-hidden="true" />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Client-side dedupe + cooldown keyed by trigger + entity_id.
|
||||
* Persisted in a module-level map so it survives re-renders within a session.
|
||||
*/
|
||||
function dedupeKey(s: ProactiveSuggestion): string {
|
||||
return `${s.trigger}:${s.entity_id ?? 'none'}`;
|
||||
}
|
||||
|
||||
function isCooledDown(s: ProactiveSuggestion, cooldowns: Record<string, number>): boolean {
|
||||
const key = dedupeKey(s);
|
||||
const last = lastAt[key];
|
||||
if (last === undefined) return false;
|
||||
const cooldown = cooldowns[s.trigger] ?? cooldowns.default ?? 300000;
|
||||
return Date.now() - last < cooldown;
|
||||
}
|
||||
|
||||
function markShown(s: ProactiveSuggestion): void {
|
||||
lastAt[dedupeKey(s)] = Date.now();
|
||||
}
|
||||
|
||||
const lastAt: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* Renders a non-intrusive feed of proactive suggestions.
|
||||
*/
|
||||
export function ProactiveFeed({
|
||||
suggestions,
|
||||
cooldowns = {},
|
||||
minPriority = 'low',
|
||||
onDismiss,
|
||||
onAction,
|
||||
}: ProactiveFeedProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const minLevel = PRIORITY_ORDER[minPriority] ?? 0;
|
||||
return suggestions
|
||||
.filter((s) => PRIORITY_ORDER[s.priority] >= minLevel)
|
||||
.filter((s) => !isCooledDown(s, cooldowns))
|
||||
.sort((a, b) => PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority]);
|
||||
}, [suggestions, cooldowns, minPriority]);
|
||||
|
||||
// Mark visible suggestions as shown (cooldown tracking)
|
||||
React.useEffect(() => {
|
||||
visible.forEach(markShown);
|
||||
}, [visible]);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-2"
|
||||
aria-label={t('workstream.proactive.title')}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Sparkles className="h-4 w-4 text-primary-500" aria-hidden="true" />
|
||||
<h3 className="text-sm font-semibold text-secondary-800">
|
||||
{t('workstream.proactive.title')}
|
||||
</h3>
|
||||
</div>
|
||||
{visible.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="border border-secondary-200 rounded-lg p-3 bg-white shadow-sm flex items-start gap-3"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5 text-primary-500">
|
||||
{ACTION_TYPE_ICON[s.action_type] ?? (
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-secondary-900">
|
||||
{s.title}
|
||||
</span>
|
||||
<Badge variant={PRIORITY_VARIANT[s.priority]}>
|
||||
{t(`workstream.proactive.priority.${s.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
{s.description && (
|
||||
<p className="mt-1 text-xs text-secondary-500">{s.description}</p>
|
||||
)}
|
||||
{s.action_url && (
|
||||
<button
|
||||
onClick={() => onAction?.(s)}
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700 hover:underline"
|
||||
>
|
||||
{t('workstream.proactive.view')}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={() => onDismiss(s.id)}
|
||||
className="flex-shrink-0 p-1 rounded text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100"
|
||||
aria-label={t('workstream.proactive.dismiss')}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProactiveFeed;
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* WorkstreamBlockRenderer — Phase I.7 (I-UI) universal block renderer.
|
||||
*
|
||||
* Renders every workstream block type (text, entity_card, action_card,
|
||||
* evidence_card, approval_card, miniapp, workflow_status, workflow_handoff,
|
||||
* error) with consistent loading / error / empty states.
|
||||
*
|
||||
* Reuses MiniAppBlock and the MiniAppSDK building blocks.
|
||||
*
|
||||
* Backend: app/ai/workstream_contract.py
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Link2,
|
||||
AlertTriangle,
|
||||
Workflow,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { MiniAppBlock } from '@/components/workstream/MiniAppBlock';
|
||||
import {
|
||||
EntityCard,
|
||||
ActionButtons,
|
||||
ApprovalCard,
|
||||
ProgressIndicator,
|
||||
DeepLink,
|
||||
type ActionButtonItem,
|
||||
} from '@/components/workstream/MiniAppSDK';
|
||||
import type { WorkstreamBlock } from '@/api/miniapps';
|
||||
|
||||
interface WorkstreamBlockRendererProps {
|
||||
block: WorkstreamBlock;
|
||||
onApprove?: (approvalId: string) => void;
|
||||
onReject?: (approvalId: string) => void;
|
||||
onAction?: (action: ActionButtonItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single workstream block by type.
|
||||
*/
|
||||
export function WorkstreamBlockRenderer({
|
||||
block,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAction,
|
||||
}: WorkstreamBlockRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
const meta = block.metadata ?? {};
|
||||
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<div className="text-sm whitespace-pre-wrap break-words text-secondary-800">
|
||||
{block.content || String(meta.text ?? '')}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'entity_card':
|
||||
return (
|
||||
<EntityCard
|
||||
entityType={String(meta.entity_type ?? '')}
|
||||
entityId={String(meta.entity_id ?? '')}
|
||||
title={String(meta.title ?? block.content)}
|
||||
subtitle={String(meta.subtitle ?? '')}
|
||||
url={String(meta.url ?? '')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'action_card': {
|
||||
const actions: ActionButtonItem[] = Array.isArray(meta.actions)
|
||||
? (meta.actions as ActionButtonItem[])
|
||||
: [];
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm space-y-2">
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{String(meta.title ?? block.content)}
|
||||
</h4>
|
||||
{meta.description ? (
|
||||
<p className="text-sm text-secondary-600">
|
||||
{String(meta.description)}
|
||||
</p>
|
||||
) : null}
|
||||
<ActionButtons actions={actions} onAction={onAction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'evidence_card':
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-primary-500" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{String(meta.title ?? block.content)}
|
||||
</h4>
|
||||
{typeof meta.confidence === 'number' && (
|
||||
<Badge variant="secondary">
|
||||
{Math.round(meta.confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{meta.snippet ? (
|
||||
<p className="mt-2 text-xs text-secondary-500">
|
||||
{String(meta.snippet)}
|
||||
</p>
|
||||
) : null}
|
||||
{meta.url ? (
|
||||
<div className="mt-2">
|
||||
<DeepLink
|
||||
href={String(meta.url)}
|
||||
label={t('workstream.block.openSource')}
|
||||
external
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'approval_card':
|
||||
return (
|
||||
<ApprovalCard
|
||||
approvalId={String(meta.approval_id ?? '')}
|
||||
action={String(meta.action ?? block.content)}
|
||||
description={String(meta.description ?? '')}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'miniapp':
|
||||
return <MiniAppBlock block={block} />;
|
||||
|
||||
case 'workflow_status':
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4 text-primary-500" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{String(meta.workflow_name ?? block.content)}
|
||||
</h4>
|
||||
<Badge variant="info">{String(meta.status ?? '')}</Badge>
|
||||
</div>
|
||||
{meta.step_name ? (
|
||||
<p className="mt-2 text-xs text-secondary-500">
|
||||
{t('workstream.block.step')}: {String(meta.step_name)}
|
||||
</p>
|
||||
) : null}
|
||||
{typeof meta.step_index === 'number' && (
|
||||
<div className="mt-3">
|
||||
<ProgressIndicator
|
||||
label={t('workstream.block.progress')}
|
||||
value={Math.min(100, (meta.step_index + 1) * 10)}
|
||||
status="in_progress"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'workflow_handoff':
|
||||
return (
|
||||
<div className="border border-warning-200 rounded-lg p-4 bg-warning-50 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRight className="h-4 w-4 text-warning-700" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{t('workstream.block.handoff')}: {String(meta.handoff_type ?? '')}
|
||||
</h4>
|
||||
</div>
|
||||
{meta.description ? (
|
||||
<p className="mt-2 text-xs text-secondary-600">
|
||||
{String(meta.description)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div className="border border-danger-200 rounded-lg p-4 bg-danger-50 shadow-sm flex items-start gap-2">
|
||||
<AlertTriangle
|
||||
className="h-4 w-4 text-danger-600 mt-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="text-sm text-danger-700">
|
||||
{block.content || String(meta.error ?? '')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
|
||||
{t('workstream.block.unknown', { type: block.type })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkstreamBlockListProps {
|
||||
blocks: WorkstreamBlock[];
|
||||
isLoading?: boolean;
|
||||
onApprove?: (approvalId: string) => void;
|
||||
onReject?: (approvalId: string) => void;
|
||||
onAction?: (action: ActionButtonItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a list of blocks with consistent loading / empty states.
|
||||
*/
|
||||
export function WorkstreamBlockList({
|
||||
blocks,
|
||||
isLoading = false,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAction,
|
||||
}: WorkstreamBlockListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2
|
||||
className="animate-spin h-6 w-6 text-primary-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="h-8 w-8" aria-hidden="true" />}
|
||||
title={t('workstream.empty.title')}
|
||||
description={t('workstream.empty.description')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, idx) => (
|
||||
<WorkstreamBlockRenderer
|
||||
key={idx}
|
||||
block={block}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkstreamBlockRenderer;
|
||||
@@ -20,10 +20,7 @@
|
||||
"mcpSettings": "MCP Einstellungen",
|
||||
"reports": "Reports",
|
||||
"tasks": "Aufgaben",
|
||||
"workstream": "Workstream",
|
||||
"wiki": "Wiki",
|
||||
"improvement": "Verbesserungen",
|
||||
"onboarding": "Onboarding"
|
||||
"wiki": "Wiki"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -1289,111 +1286,5 @@
|
||||
"preview": "Vorschau",
|
||||
"split": "Geteilt",
|
||||
"writeLabel": "Markdown-Inhalt"
|
||||
},
|
||||
"workstream": {
|
||||
"title": "Workstream",
|
||||
"empty": {
|
||||
"title": "Keine Nachrichten",
|
||||
"description": "Es sind noch keine Workstream-Nachrichten vorhanden."
|
||||
},
|
||||
"actor": {
|
||||
"human": "Mensch",
|
||||
"system": "System",
|
||||
"agent": "Agent",
|
||||
"workflow": "Workflow"
|
||||
},
|
||||
"block": {
|
||||
"step": "Schritt",
|
||||
"progress": "Fortschritt",
|
||||
"handoff": "Übergabe",
|
||||
"openSource": "Quelle öffnen",
|
||||
"unknown": "Unbekannter Block-Typ: {{type}}"
|
||||
},
|
||||
"miniapp": {
|
||||
"untitled": "Unbenannte Mini-App",
|
||||
"renderSchema": "Render-Schema",
|
||||
"noSchema": "Kein Render-Schema für diese Mini-App vorhanden."
|
||||
},
|
||||
"sdk": {
|
||||
"openEntity": "Entität öffnen",
|
||||
"open": "Öffnen",
|
||||
"action": "Aktion",
|
||||
"approvalNeeded": "Freigabe erforderlich",
|
||||
"approve": "Genehmigen",
|
||||
"reject": "Ablehnen",
|
||||
"progress": "Fortschritt"
|
||||
},
|
||||
"proactive": {
|
||||
"title": "Vorschläge",
|
||||
"view": "Ansehen",
|
||||
"dismiss": "Verwerfen",
|
||||
"priority": {
|
||||
"low": "Niedrig",
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch",
|
||||
"urgent": "Dringend"
|
||||
}
|
||||
}
|
||||
},
|
||||
"setupWizard": {
|
||||
"title": "Setup-Assistent",
|
||||
"close": "Schließen",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"finish": "Fertigstellen",
|
||||
"step1": {
|
||||
"title": "Willkommen bei LeoCRM",
|
||||
"description": "Starten Sie mit Ihrer KI-gestützten CRM-Plattform."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Ersten Agenten erstellen",
|
||||
"description": "Richten Sie einen KI-Agenten ein, der bei E-Mail-Triage, Kontaktanreicherung oder Follow-ups hilft."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Ersten Workflow erstellen",
|
||||
"description": "Automatisieren Sie wiederkehrende Aufgaben mit Workflows. Starten Sie mit einer Vorlage oder erstellen Sie eigene."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Knowledge & Workstream aktivieren",
|
||||
"description": "Erstellen Sie Wiki-Artikel und verbinden Sie Menschen, Agenten und Workflows in einem einheitlichen Stream."
|
||||
}
|
||||
},
|
||||
"improvement": {
|
||||
"title": "Improvement Center",
|
||||
"subtitle": "Kontrollierte Selbstverbesserung — Vorschläge, Muster und Wirkungsmessung.",
|
||||
"proposals": "Verbesserungsvorschläge",
|
||||
"patterns": "Erkannte Muster & Engpässe",
|
||||
"evidence": "Evidenz",
|
||||
"approve": "Genehmigen",
|
||||
"reject": "Ablehnen",
|
||||
"rollback": "Zurücksetzen",
|
||||
"score": "Bewertung",
|
||||
"confidence": "Konfidenz",
|
||||
"occurrences": "Vorkommen",
|
||||
"bottleneck": "Engpass",
|
||||
"rationale": "Begründung",
|
||||
"expectedBenefit": "Erwarteter Nutzen",
|
||||
"riskAssessment": "Risikobewertung",
|
||||
"noPatterns": "Keine Muster erkannt",
|
||||
"noPatternsDesc": "Es wurden noch keine Verbesserungsmuster oder Engpässe erkannt.",
|
||||
"noProposals": "Keine Vorschläge",
|
||||
"noProposalsDesc": "Es liegen noch keine Verbesserungsvorschläge vor.",
|
||||
"status": {
|
||||
"draft": "Entwurf",
|
||||
"evaluating": "Wird bewertet",
|
||||
"pending_approval": "Freigabe ausstehend",
|
||||
"approved": "Genehmigt",
|
||||
"rejected": "Abgelehnt",
|
||||
"active": "Aktiv",
|
||||
"rolled_back": "Zurückgesetzt",
|
||||
"expired": "Abgelaufen"
|
||||
},
|
||||
"patternTypes": {
|
||||
"repetitive_sequence": "Wiederkehrende Sequenz",
|
||||
"frequent_corrections": "Häufige Korrekturen",
|
||||
"rejected_suggestions": "Abgelehnte Vorschläge",
|
||||
"error_retries": "Fehler & Wiederholungen",
|
||||
"repetitive_handoffs": "Wiederkehrende Übergaben"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,7 @@
|
||||
"mcpSettings": "MCP Settings",
|
||||
"reports": "Reports",
|
||||
"tasks": "Tasks",
|
||||
"workstream": "Workstream",
|
||||
"wiki": "Wiki",
|
||||
"improvement": "Improvements",
|
||||
"onboarding": "Onboarding"
|
||||
"wiki": "Wiki"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign In",
|
||||
@@ -1289,111 +1286,5 @@
|
||||
"preview": "Preview",
|
||||
"split": "Split",
|
||||
"writeLabel": "Markdown content"
|
||||
},
|
||||
"workstream": {
|
||||
"title": "Workstream",
|
||||
"empty": {
|
||||
"title": "No messages",
|
||||
"description": "There are no workstream messages yet."
|
||||
},
|
||||
"actor": {
|
||||
"human": "Human",
|
||||
"system": "System",
|
||||
"agent": "Agent",
|
||||
"workflow": "Workflow"
|
||||
},
|
||||
"block": {
|
||||
"step": "Step",
|
||||
"progress": "Progress",
|
||||
"handoff": "Handoff",
|
||||
"openSource": "Open source",
|
||||
"unknown": "Unknown block type: {{type}}"
|
||||
},
|
||||
"miniapp": {
|
||||
"untitled": "Untitled Mini-App",
|
||||
"renderSchema": "Render schema",
|
||||
"noSchema": "No render schema available for this Mini-App."
|
||||
},
|
||||
"sdk": {
|
||||
"openEntity": "Open entity",
|
||||
"open": "Open",
|
||||
"action": "Action",
|
||||
"approvalNeeded": "Approval needed",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"proactive": {
|
||||
"title": "Suggestions",
|
||||
"view": "View",
|
||||
"dismiss": "Dismiss",
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"urgent": "Urgent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"setupWizard": {
|
||||
"title": "Setup Wizard",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"finish": "Finish",
|
||||
"step1": {
|
||||
"title": "Welcome to LeoCRM",
|
||||
"description": "Get started with your AI-powered CRM platform."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Create Your First Agent",
|
||||
"description": "Set up an AI agent to help with email triage, contact enrichment, or follow-ups."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Create Your First Workflow",
|
||||
"description": "Automate repetitive tasks with workflows. Start with a template or build your own."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Enable Knowledge & Workstream",
|
||||
"description": "Create wiki articles and connect humans, agents, and workflows in a unified stream."
|
||||
}
|
||||
},
|
||||
"improvement": {
|
||||
"title": "Improvement Center",
|
||||
"subtitle": "Controlled self-improvement — proposals, patterns and impact measurement.",
|
||||
"proposals": "Improvement Proposals",
|
||||
"patterns": "Detected Patterns & Bottlenecks",
|
||||
"evidence": "Evidence",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"rollback": "Rollback",
|
||||
"score": "Score",
|
||||
"confidence": "Confidence",
|
||||
"occurrences": "Occurrences",
|
||||
"bottleneck": "Bottleneck",
|
||||
"rationale": "Rationale",
|
||||
"expectedBenefit": "Expected Benefit",
|
||||
"riskAssessment": "Risk Assessment",
|
||||
"noPatterns": "No patterns detected",
|
||||
"noPatternsDesc": "No improvement patterns or bottlenecks have been detected yet.",
|
||||
"noProposals": "No proposals",
|
||||
"noProposalsDesc": "There are no improvement proposals yet.",
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"evaluating": "Evaluating",
|
||||
"pending_approval": "Pending Approval",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"active": "Active",
|
||||
"rolled_back": "Rolled Back",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"patternTypes": {
|
||||
"repetitive_sequence": "Repetitive Sequence",
|
||||
"frequent_corrections": "Frequent Corrections",
|
||||
"rejected_suggestions": "Rejected Suggestions",
|
||||
"error_retries": "Errors & Retries",
|
||||
"repetitive_handoffs": "Repetitive Handoffs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Bot,
|
||||
Workflow,
|
||||
Search,
|
||||
BookOpen,
|
||||
DollarSign,
|
||||
HeartPulse,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { StatCard } from '@/components/shared/StatCard';
|
||||
import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
|
||||
import { DashboardGrid } from '@/components/dashboard/DashboardGrid';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useUnifiedContacts, useAuditLog } from '@/api/hooks';
|
||||
import { useDashboardWidgets } from '@/api/dashboard';
|
||||
import {
|
||||
usePlatformDashboard,
|
||||
useCostDashboard,
|
||||
useUsageAnalytics,
|
||||
} from '@/api/platform';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
|
||||
export function DashboardPage() {
|
||||
@@ -30,22 +13,6 @@ export function DashboardPage() {
|
||||
const { data: auditData, isError: auditError } = useAuditLog(1, 5);
|
||||
const { data: widgetsData, isError: widgetsError } = useDashboardWidgets();
|
||||
|
||||
const {
|
||||
data: platformData,
|
||||
isLoading: platformLoading,
|
||||
isError: platformError,
|
||||
} = usePlatformDashboard();
|
||||
const {
|
||||
data: costData,
|
||||
isLoading: costLoading,
|
||||
isError: costError,
|
||||
} = useCostDashboard(30);
|
||||
const {
|
||||
data: usageData,
|
||||
isLoading: usageLoading,
|
||||
isError: usageError,
|
||||
} = useUsageAnalytics(30);
|
||||
|
||||
const totalCompanies = companiesData?.total ?? 0;
|
||||
const totalContacts = contactsData?.total ?? 0;
|
||||
|
||||
@@ -78,20 +45,6 @@ export function DashboardPage() {
|
||||
|
||||
const widgets = widgetsData?.items ?? [];
|
||||
|
||||
const agents = platformData?.agents;
|
||||
const workflows = platformData?.workflows;
|
||||
const search = platformData?.search;
|
||||
const knowledge = platformData?.knowledge;
|
||||
const systemHealth = platformData?.system_health;
|
||||
const searchQueries = usageData?.search_queries;
|
||||
|
||||
const costUsd = costData?.total_cost_usd ?? 0;
|
||||
const budget = costData?.budget;
|
||||
const budgetUtilization = budget?.utilization_pct ?? 0;
|
||||
|
||||
const healthLabel = systemHealth?.status ?? 'unknown';
|
||||
const healthVariant = healthStatusVariant(healthLabel);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="dashboard-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('dashboard.title')}</h1>
|
||||
@@ -119,155 +72,6 @@ export function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platform Dashboard — Phase I.7 (I-DASH) */}
|
||||
<section className="mb-8" aria-label={t('dashboard.platform.title')}>
|
||||
<h2 className="text-lg font-semibold text-secondary-800 mb-4">
|
||||
{t('dashboard.platform.title')}
|
||||
</h2>
|
||||
|
||||
{platformLoading || costLoading || usageLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin h-6 w-6 text-primary-500" aria-hidden="true" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Agent status */}
|
||||
<Card title={t('dashboard.platform.agents')}>
|
||||
{agents?.error || platformError ? (
|
||||
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<MetricRow
|
||||
icon={<Bot className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.activeAgents')}
|
||||
value={agents?.active_agents ?? 0}
|
||||
/>
|
||||
<MetricRow
|
||||
icon={<Bot className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.totalRuns')}
|
||||
value={agents?.total_runs ?? 0}
|
||||
/>
|
||||
<MetricRow
|
||||
icon={<Bot className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.recentRuns7d')}
|
||||
value={agents?.recent_runs_7d ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Workflow stats */}
|
||||
<Card title={t('dashboard.platform.workflows')}>
|
||||
{workflows?.error || platformError ? (
|
||||
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<MetricRow
|
||||
icon={<Workflow className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.activeWorkflows')}
|
||||
value={workflows?.active_workflows ?? 0}
|
||||
/>
|
||||
<MetricRow
|
||||
icon={<Workflow className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.runningInstances')}
|
||||
value={workflows?.running_instances ?? 0}
|
||||
/>
|
||||
<MetricRow
|
||||
icon={<Workflow className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.completedInstances')}
|
||||
value={workflows?.completed_instances ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Search metrics */}
|
||||
<Card title={t('dashboard.platform.search')}>
|
||||
{usageError || searchQueries?.error ? (
|
||||
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<MetricRow
|
||||
icon={<Search className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.totalQueries')}
|
||||
value={searchQueries?.total ?? search?.total_queries ?? 0}
|
||||
/>
|
||||
<MetricRow
|
||||
icon={<Search className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.avgLatency')}
|
||||
value={
|
||||
typeof search?.avg_latency_ms === 'number'
|
||||
? `${search.avg_latency_ms} ms`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Knowledge coverage */}
|
||||
<Card title={t('dashboard.platform.knowledge')}>
|
||||
{platformError || knowledge?.error ? (
|
||||
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<MetricRow
|
||||
icon={<BookOpen className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.wikiArticles')}
|
||||
value={knowledge?.wiki_articles ?? 0}
|
||||
/>
|
||||
<MetricRow
|
||||
icon={<BookOpen className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.coverage')}
|
||||
value={
|
||||
typeof knowledge?.coverage_pct === 'number'
|
||||
? `${knowledge.coverage_pct}%`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Cost tracking */}
|
||||
<Card title={t('dashboard.platform.cost')}>
|
||||
{costError || costData?.error ? (
|
||||
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<MetricRow
|
||||
icon={<DollarSign className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.totalCost')}
|
||||
value={`$${costUsd.toFixed(2)}`}
|
||||
/>
|
||||
{budget?.monthly_limit_usd ? (
|
||||
<MetricRow
|
||||
icon={<DollarSign className="h-4 w-4" aria-hidden="true" />}
|
||||
label={t('dashboard.platform.budgetUtilization')}
|
||||
value={`${budgetUtilization.toFixed(1)}%`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* System health */}
|
||||
<Card title={t('dashboard.platform.systemHealth')}>
|
||||
{platformError ? (
|
||||
<p className="text-sm text-secondary-500">{t('dashboard.platform.unavailable')}</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<HeartPulse className="h-4 w-4 text-primary-500" aria-hidden="true" />
|
||||
<Badge variant={healthVariant} dot>
|
||||
{t(`dashboard.platform.health.${healthLabel}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Dynamic Plugin Widgets */}
|
||||
{widgetsError ? (
|
||||
<p className="text-sm text-secondary-500 mb-6" data-testid="dashboard-widgets-unavailable">
|
||||
@@ -291,31 +95,4 @@ export function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-secondary-600">
|
||||
<span className="text-primary-500" aria-hidden="true">{icon}</span>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-secondary-900">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function healthStatusVariant(status: string): 'success' | 'warning' | 'danger' | 'secondary' {
|
||||
if (status === 'healthy') return 'success';
|
||||
if (status === 'degraded') return 'warning';
|
||||
if (status === 'down') return 'danger';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -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 HelpApiDocsPage = React.lazy(() => import('@/pages/help/HelpApiDocs').then(m => ({ default: m.HelpApiDocsPage })));
|
||||
const ApiDocsPage = React.lazy(() => import('@/pages/ApiDocs').then(m => ({ default: m.ApiDocsPage })));
|
||||
const WorkstreamPage = React.lazy(() => import('@/pages/Workstream').then(m => ({ default: m.WorkstreamPage })));
|
||||
const WikiPage = React.lazy(() => import('@/pages/Wiki').then(m => ({ default: m.WikiPage })));
|
||||
const ImprovementCenterPage = React.lazy(() => import('@/components/improvement/ImprovementCenter').then(m => ({ default: m.ImprovementCenter })));
|
||||
const OnboardingPage = React.lazy(() => import('@/pages/Onboarding').then(m => ({ default: m.OnboardingPage })));
|
||||
|
||||
/** Centered spinner fallback for lazy-loaded routes */
|
||||
function PageLoader() {
|
||||
@@ -270,10 +267,7 @@ const router = createBrowserRouter([
|
||||
{ path: '/tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> },
|
||||
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
|
||||
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
||||
{ path: '/workstream', element: withSuspense(<WorkstreamPage />) },
|
||||
{ path: '/wiki', element: withSuspense(<WikiPage />) },
|
||||
{ path: '/improvement', element: withSuspense(<ImprovementCenterPage />) },
|
||||
{ path: '/onboarding', element: withSuspense(<OnboardingPage />) },
|
||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },
|
||||
],
|
||||
|
||||
@@ -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