feat(G): G-WORK/G-HUMAN-DEC/G-UI-TEMPL/G-DOC — workflow workstream, decision guard, template gallery (3 templates), API docs, 43 tests passing
This commit is contained in:
@@ -578,3 +578,94 @@ async def reject_workflow_step(
|
||||
instance_id=instance_id,
|
||||
is_system_admin=current_user.get("is_system_admin", False),
|
||||
)
|
||||
|
||||
|
||||
# ─── G-UI-TEMPL: Template Gallery ─────────────────────────────────────────────
|
||||
|
||||
|
||||
WORKFLOW_TEMPLATES = [
|
||||
{
|
||||
"id": "welcome_email",
|
||||
"name": "Welcome Email",
|
||||
"description": "Send a welcome email when a new contact is created",
|
||||
"trigger_event": "contact.after_create",
|
||||
"steps": [
|
||||
{"name": "Wait 1 hour", "type": "wait", "config": {"duration_seconds": 3600}},
|
||||
{"name": "Send Welcome", "type": "mail", "config": {
|
||||
"to": "{{context.email}}",
|
||||
"subject": "Welcome to our service!",
|
||||
"body": "Hello {{context.name}},\n\nWelcome aboard! We're excited to have you.\n\nBest regards,\nThe Team",
|
||||
}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "contact_followup",
|
||||
"name": "Contact Follow-Up",
|
||||
"description": "Create a follow-up task 3 days after contact creation",
|
||||
"trigger_event": "contact.after_create",
|
||||
"steps": [
|
||||
{"name": "Wait 3 days", "type": "wait", "config": {"duration_seconds": 259200}},
|
||||
{"name": "Create Follow-Up Task", "type": "crm", "config": {
|
||||
"action": "create_contact",
|
||||
"data": {"title": "Follow up with {{context.name}}", "priority": "medium"},
|
||||
}},
|
||||
{"name": "Notify Owner", "type": "notification", "config": {
|
||||
"title": "Follow-up reminder",
|
||||
"body": "Time to follow up with {{context.name}}",
|
||||
}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "approval_chain",
|
||||
"name": "Approval Chain",
|
||||
"description": "Two-step approval: manager approves, then sends notification",
|
||||
"trigger_event": "manual",
|
||||
"steps": [
|
||||
{"name": "Manager Approval", "type": "approval", "config": {}},
|
||||
{"name": "Send Result", "type": "mail", "config": {
|
||||
"to": "{{context.initiator_email}}",
|
||||
"subject": "Your request has been approved",
|
||||
"body": "Your request has been approved by management.",
|
||||
}},
|
||||
{"name": "Log Completion", "type": "event", "config": {
|
||||
"event_name": "approval_chain.completed",
|
||||
"payload": {},
|
||||
}},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_workflow_templates(
|
||||
current_user: dict = Depends(require_permission("workflows:read")),
|
||||
):
|
||||
"""List available workflow templates (G-UI-TEMPL)."""
|
||||
return {"items": WORKFLOW_TEMPLATES, "total": len(WORKFLOW_TEMPLATES)}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/instantiate", status_code=status.HTTP_201_CREATED)
|
||||
async def instantiate_template(
|
||||
template_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("workflows:write")),
|
||||
):
|
||||
"""Instantiate a workflow template — creates a workflow from the template."""
|
||||
template = next((t for t in WORKFLOW_TEMPLATES if t["id"] == template_id), None)
|
||||
if template is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"detail": "Template not found", "code": "not_found"},
|
||||
)
|
||||
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
data = {
|
||||
"name": template["name"],
|
||||
"description": template["description"],
|
||||
"trigger_event": template["trigger_event"],
|
||||
"steps": template["steps"],
|
||||
"is_active": True,
|
||||
}
|
||||
return await workflow_service.create_workflow(db, tenant_id, user_id, data)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Automated-Decision Guard (G-HUMAN-DEC).
|
||||
|
||||
For AI use cases configured as requiring human review, workflow/agent
|
||||
results must not automatically trigger defined person-/risk-relevant
|
||||
external effects before the required human review/approval policy is
|
||||
fulfilled.
|
||||
|
||||
This guard checks the AIUseCaseMetadata of an agent or workflow step
|
||||
and blocks automatic execution of high-risk actions unless approval
|
||||
has been granted.
|
||||
|
||||
No blanket enforcement for normal CRM automation — only applies to
|
||||
explicitly configured AI use cases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Risk levels for automated decisions
|
||||
RISK_LEVELS = {
|
||||
"none": 0,
|
||||
"low": 1,
|
||||
"medium": 2,
|
||||
"high": 3,
|
||||
"critical": 4,
|
||||
}
|
||||
|
||||
# Actions that require human review when risk_level >= medium
|
||||
HIGH_RISK_ACTIONS = {
|
||||
"send_email",
|
||||
"send_external_message",
|
||||
"delete_entity",
|
||||
"modify_permissions",
|
||||
"execute_payment",
|
||||
"publish_content",
|
||||
"modify_contract",
|
||||
"data_export",
|
||||
}
|
||||
|
||||
|
||||
def requires_human_review(
|
||||
ai_use_case_metadata: dict[str, Any] | None,
|
||||
action: str,
|
||||
) -> bool:
|
||||
"""Check whether an action requires human review based on AI use case config.
|
||||
|
||||
Args:
|
||||
ai_use_case_metadata: The ai_use_case_metadata from AgentDefinition
|
||||
or workflow step config. May contain:
|
||||
- ``risk_level``: none|low|medium|high|critical
|
||||
- ``requires_approval``: bool
|
||||
- ``auto_execute``: bool (if False, always requires review)
|
||||
- ``reviewed_actions``: list of actions that need review
|
||||
action: The action being performed (e.g. "send_email", "delete_entity").
|
||||
|
||||
Returns:
|
||||
True if human review is required before the action can execute.
|
||||
"""
|
||||
if not ai_use_case_metadata:
|
||||
return False # No AI use case config → no guard
|
||||
|
||||
# If auto_execute is explicitly False, always require review
|
||||
if ai_use_case_metadata.get("auto_execute") is False:
|
||||
return True
|
||||
|
||||
# If requires_approval is True, always require review
|
||||
if ai_use_case_metadata.get("requires_approval") is True:
|
||||
return True
|
||||
|
||||
# Check risk level
|
||||
risk_level = ai_use_case_metadata.get("risk_level", "none")
|
||||
risk_score = RISK_LEVELS.get(risk_level, 0)
|
||||
|
||||
# Medium or higher risk always requires review
|
||||
if risk_score >= RISK_LEVELS["medium"]:
|
||||
return True
|
||||
|
||||
# Check if this specific action is in the reviewed_actions list
|
||||
reviewed_actions = ai_use_case_metadata.get("reviewed_actions", [])
|
||||
if action in reviewed_actions:
|
||||
return True
|
||||
|
||||
# Check if action is inherently high-risk
|
||||
if action in HIGH_RISK_ACTIONS and risk_score >= RISK_LEVELS["low"]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def check_decision_guard(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
step_config: dict[str, Any],
|
||||
action: str,
|
||||
ai_use_case_metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Check the automated-decision guard for a workflow step.
|
||||
|
||||
Returns a dict with:
|
||||
- ``allowed``: bool — whether the action can proceed automatically
|
||||
- ``requires_approval``: bool — whether human approval is needed
|
||||
- ``reason``: str — explanation if blocked
|
||||
- ``approval_action``: str — action to create approval for
|
||||
|
||||
If the guard blocks, the workflow engine should pause and create
|
||||
an ApprovalRequest instead of executing the action.
|
||||
"""
|
||||
# Merge step-level and agent-level metadata
|
||||
step_metadata = step_config.get("ai_use_case_metadata")
|
||||
metadata = ai_use_case_metadata or step_metadata
|
||||
|
||||
if not requires_human_review(metadata, action):
|
||||
return {
|
||||
"allowed": True,
|
||||
"requires_approval": False,
|
||||
"reason": None,
|
||||
}
|
||||
|
||||
# Human review required
|
||||
risk_level = (metadata or {}).get("risk_level", "medium")
|
||||
return {
|
||||
"allowed": False,
|
||||
"requires_approval": True,
|
||||
"reason": (
|
||||
f"Action '{action}' requires human review "
|
||||
f"(risk level: {risk_level})"
|
||||
),
|
||||
"approval_action": action,
|
||||
"risk_level": risk_level,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"requires_human_review",
|
||||
"check_decision_guard",
|
||||
"RISK_LEVELS",
|
||||
"HIGH_RISK_ACTIONS",
|
||||
]
|
||||
@@ -0,0 +1,221 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user