Files

374 lines
13 KiB
Python

#!/usr/bin/env python
"""
Seed or rollback the Multica organization collaboration workflow and agents.
Usage:
python scripts/seed_multica_org_agents.py --dry-run
python scripts/seed_multica_org_agents.py --apply
python scripts/seed_multica_org_agents.py --rollback
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import Any, Dict
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
PROJECT_ROOT = Path(__file__).resolve().parent.parent
FIXTURE_PATH = PROJECT_ROOT / "ai_platform" / "fixtures" / "multica_org_agents.json"
def load_fixture() -> Dict[str, Any]:
with FIXTURE_PATH.open("r", encoding="utf-8") as fixture_file:
return json.load(fixture_file)
def build_workflow_payload(fixture: Dict[str, Any]) -> Dict[str, Any]:
workflow = deepcopy(fixture["workflow"])
definition = workflow["definition"]
workflow["published_definition"] = deepcopy(definition)
workflow["published_at"] = datetime.utcnow()
return workflow
def build_agent_payload(agent_fixture: Dict[str, Any], workflow_id: str | None) -> Dict[str, Any]:
payload = deepcopy(agent_fixture)
workflow_code = payload.pop("workflow_code", None)
if workflow_code:
payload["workflow_id"] = workflow_id
return payload
def _find_parallel_merge(
node_by_id: Dict[str, Dict[str, Any]],
outgoing_edges: Dict[str, list[str]],
branch_starts: list[str],
) -> str | None:
branch_merges: list[str] = []
for start in branch_starts:
current = start
seen: set[str] = set()
while current and current not in seen:
seen.add(current)
next_nodes = outgoing_edges.get(current, [])
if not next_nodes or len(next_nodes) > 1:
break
current = next_nodes[0]
if (node_by_id.get(current) or {}).get("type") == "merge":
branch_merges.append(current)
break
unique_merges = set(branch_merges)
return branch_merges[0] if len(unique_merges) == 1 and len(branch_merges) == len(branch_starts) else None
def _walk_workflow_nodes(
node_by_id: Dict[str, Dict[str, Any]],
outgoing_edges: Dict[str, list[str]],
start_id: str,
) -> set[str]:
visited: set[str] = set()
current = start_id
while current and current not in visited:
visited.add(current)
node_type = (node_by_id.get(current) or {}).get("type")
if node_type == "end":
break
if node_type == "parallel":
branch_starts = outgoing_edges.get(current, [])
for branch_start in branch_starts:
branch_current = branch_start
branch_seen: set[str] = set()
while branch_current and branch_current not in branch_seen:
if (node_by_id.get(branch_current) or {}).get("type") == "merge":
break
visited.add(branch_current)
branch_seen.add(branch_current)
branch_next_nodes = outgoing_edges.get(branch_current, [])
branch_current = branch_next_nodes[0] if branch_next_nodes else None
current = _find_parallel_merge(node_by_id, outgoing_edges, branch_starts)
continue
next_nodes = outgoing_edges.get(current, [])
current = next_nodes[0] if next_nodes else None
return visited
def validate_fixture(fixture: Dict[str, Any]) -> Dict[str, int]:
agents = fixture.get("agents", [])
definition = fixture.get("workflow", {}).get("definition", {})
nodes = definition.get("nodes", [])
edges = definition.get("edges", [])
agent_codes = {agent.get("code") for agent in agents}
workflow_agent_codes = {
(node.get("data") or {}).get("agent_code")
for node in nodes
if isinstance(node, dict) and (node.get("data") or {}).get("agent_code")
}
node_by_id = {node.get("id"): node for node in nodes if isinstance(node, dict)}
node_types = {node.get("type") for node in nodes}
outgoing_edges: Dict[str, list[str]] = {}
for edge in edges:
if not isinstance(edge, dict):
continue
source = edge.get("source")
target = edge.get("target")
if source and target:
outgoing_edges.setdefault(source, []).append(target)
required_persona_fields = {
"role",
"skills",
"constraints",
"background",
"examples",
}
required_node_types = {"start", "end", "template", "parallel", "merge"}
multi_outgoing_node_types = {"parallel", "condition", "intent", "choice"}
start_node = next((node for node in nodes if node.get("type") == "start"), None)
missing_workflow_agents = sorted(workflow_agent_codes - agent_codes)
missing_persona_fields = {
agent.get("code"): sorted(required_persona_fields - set((agent.get("persona") or {}).keys()))
for agent in agents
if required_persona_fields - set((agent.get("persona") or {}).keys())
}
missing_node_types = sorted(required_node_types - node_types)
invalid_multi_outgoing = {
source: targets
for source, targets in outgoing_edges.items()
if len(targets) > 1
and (node_by_id.get(source) or {}).get("type") not in multi_outgoing_node_types
}
reachable_nodes = _walk_workflow_nodes(node_by_id, outgoing_edges, start_node.get("id")) if start_node else set()
unreachable_nodes = sorted(set(node_by_id) - reachable_nodes)
invalid_parallel_merges = {
source: targets
for source, targets in outgoing_edges.items()
if (node_by_id.get(source) or {}).get("type") == "parallel"
and not _find_parallel_merge(node_by_id, outgoing_edges, targets)
}
project_manager = next(
(agent for agent in agents if agent.get("code") == "project_manager"),
None,
)
project_manager_workflow = (project_manager or {}).get("workflow_code")
if (
missing_workflow_agents
or missing_persona_fields
or missing_node_types
or invalid_multi_outgoing
or unreachable_nodes
or invalid_parallel_merges
or project_manager_workflow != "multica_org_collaboration_flow"
):
raise ValueError(
f"Fixture validation failed: missing_workflow_agents={missing_workflow_agents}, "
f"missing_persona_fields={missing_persona_fields}, "
f"missing_node_types={missing_node_types}, "
f"invalid_multi_outgoing={invalid_multi_outgoing}, "
f"unreachable_nodes={unreachable_nodes}, "
f"invalid_parallel_merges={invalid_parallel_merges}, "
f"project_manager_workflow={project_manager_workflow}"
)
return {
"agents": len(agents),
"workflow_nodes": len(nodes),
"workflow_edges": len(edges),
"workflow_agent_refs": len(workflow_agent_codes),
}
async def resolve_default_chat_model_id(session) -> str | None:
from sqlalchemy import select
from ai_platform.models.model import LLMModel
result = await session.execute(
select(LLMModel)
.where(
LLMModel.is_deleted == False,
LLMModel.is_active == True,
LLMModel.model_type == "chat",
)
.order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc())
)
model = result.scalars().first()
return str(model.id) if model else None
async def upsert_workflow(session, workflow_payload: Dict[str, Any]) -> AIWorkflow:
from sqlalchemy import select
from ai_platform.models.workflow import AIWorkflow, AIWorkflowVersion
code = workflow_payload["code"]
result = await session.execute(select(AIWorkflow).where(AIWorkflow.code == code))
workflow = result.scalar_one_or_none()
if workflow:
for key, value in workflow_payload.items():
setattr(workflow, key, value)
workflow.is_deleted = False
else:
workflow = AIWorkflow(**workflow_payload)
session.add(workflow)
await session.flush()
result = await session.execute(
select(AIWorkflowVersion).where(
AIWorkflowVersion.workflow_id == workflow.id,
AIWorkflowVersion.version == workflow.version,
)
)
version = result.scalar_one_or_none()
version_payload = {
"workflow_id": workflow.id,
"version": workflow.version,
"definition": deepcopy(workflow.definition),
"description": "Seeded Multica organization collaboration workflow",
"published_at": workflow.published_at,
}
if version:
for key, value in version_payload.items():
setattr(version, key, value)
version.is_deleted = False
else:
session.add(AIWorkflowVersion(**version_payload))
return workflow
async def upsert_agents(session, fixture: Dict[str, Any], workflow_id: str) -> int:
from sqlalchemy import select
from ai_platform.models.agent import Agent
from app.base_model import generate_nanoid
count = 0
default_chat_model_id = await resolve_default_chat_model_id(session)
for agent_fixture in fixture["agents"]:
payload = build_agent_payload(agent_fixture, workflow_id)
if (
payload.get("mode", "autonomous") == "autonomous"
and not payload.get("model_id")
and default_chat_model_id
):
payload["model_id"] = default_chat_model_id
result = await session.execute(select(Agent).where(Agent.code == payload["code"]))
agent = result.scalar_one_or_none()
if agent:
for key, value in payload.items():
setattr(agent, key, value)
agent.is_deleted = False
else:
session.add(Agent(id=generate_nanoid(), **payload))
count += 1
return count
async def apply_seed(dry_run: bool) -> Dict[str, Any]:
fixture = load_fixture()
counts = validate_fixture(fixture)
workflow_payload = build_workflow_payload(fixture)
if dry_run:
return {
"action": "dry-run",
"workflow_code": workflow_payload["code"],
"agent_count": len(fixture["agents"]),
**counts,
}
from app.database import AsyncSessionLocal
async with AsyncSessionLocal() as session:
workflow = await upsert_workflow(session, workflow_payload)
agent_count = await upsert_agents(session, fixture, workflow.id)
await session.commit()
action = "applied"
return {
"action": action,
"workflow_code": workflow_payload["code"],
"agent_count": agent_count,
**counts,
}
async def rollback_seed(dry_run: bool) -> Dict[str, Any]:
from sqlalchemy import select
from ai_platform.models.agent import Agent
from ai_platform.models.workflow import AIWorkflow, AIWorkflowVersion
from app.database import AsyncSessionLocal
fixture = load_fixture()
workflow_code = fixture["workflow"]["code"]
agent_codes = [agent["code"] for agent in fixture["agents"]]
async with AsyncSessionLocal() as session:
result = await session.execute(select(AIWorkflow).where(AIWorkflow.code == workflow_code))
workflow = result.scalar_one_or_none()
workflow_count = 0
version_count = 0
if workflow:
workflow.is_deleted = True
workflow_count = 1
versions = await session.execute(
select(AIWorkflowVersion).where(AIWorkflowVersion.workflow_id == workflow.id)
)
for version in versions.scalars().all():
version.is_deleted = True
version_count += 1
agents = await session.execute(select(Agent).where(Agent.code.in_(agent_codes)))
agent_count = 0
for agent in agents.scalars().all():
agent.is_deleted = True
agent.workflow_id = None
agent_count += 1
if dry_run:
await session.rollback()
action = "rollback-dry-run"
else:
await session.commit()
action = "rolled-back"
return {
"action": action,
"workflow_code": workflow_code,
"workflow_count": workflow_count,
"workflow_version_count": version_count,
"agent_count": agent_count,
}
async def main() -> None:
parser = argparse.ArgumentParser(description="Seed Multica organization collaboration agents")
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--dry-run", action="store_true", help="Validate and simulate the seed without committing")
mode.add_argument("--apply", action="store_true", help="Apply the seed to the configured database")
mode.add_argument("--rollback", action="store_true", help="Soft-delete seeded workflow, versions, and agents")
args = parser.parse_args()
if args.rollback:
result = await rollback_seed(dry_run=False)
else:
result = await apply_seed(dry_run=args.dry_run)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
if __name__ == "__main__":
asyncio.run(main())