Artifex/project_brain/planning.py
2026-08-15 13:50:24 +07:00

268 lines
11 KiB
Python

from __future__ import annotations
import json
from dataclasses import dataclass, field
from django.db import transaction
from django.utils import timezone
from control_plane.events.bus import EventBus
from control_plane.events.models import EventType
from control_plane.projects.models import Feature, Milestone, Project, ProjectPlan, ProjectStatus, Task, TaskDependency, TaskStatus
@dataclass(frozen=True)
class PlannedTask:
task_id: str
goal: str
task_type: str = "implementation"
acceptance_criteria: list[str] = field(default_factory=list)
priority: int = 50
dependencies: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class PlannedFeature:
key: str
title: str
description: str = ""
acceptance_criteria: list[str] = field(default_factory=list)
tasks: list[PlannedTask] = field(default_factory=list)
@dataclass(frozen=True)
class PlannedMilestone:
key: str
title: str
goal: str
verification_contract: dict[str, object] = field(default_factory=dict)
features: list[PlannedFeature] = field(default_factory=list)
@dataclass(frozen=True)
class ProjectPlanContract:
goal: str
scope: str = ""
stack: dict[str, object] = field(default_factory=dict)
architecture: dict[str, object] = field(default_factory=dict)
constraints: dict[str, object] = field(default_factory=dict)
acceptance_criteria: list[str] = field(default_factory=list)
permissions: dict[str, object] = field(default_factory=dict)
budget: dict[str, object] = field(default_factory=dict)
open_decisions: list[str] = field(default_factory=list)
milestones: list[PlannedMilestone] = field(default_factory=list)
class PlanValidationError(ValueError):
pass
def parse_project_plan_response(content: str) -> ProjectPlanContract:
try:
payload = json.loads(content)
except json.JSONDecodeError as exc:
raise PlanValidationError("Sol planning response must be valid JSON") from exc
if not isinstance(payload, dict):
raise PlanValidationError("Plan response must be a JSON object")
raw_plan = payload.get("project_plan", payload)
if not isinstance(raw_plan, dict):
raise PlanValidationError("project_plan must be an object")
milestones: list[PlannedMilestone] = []
for raw_milestone in _required_list(raw_plan, "milestones"):
features: list[PlannedFeature] = []
for raw_feature in _required_list(raw_milestone, "features"):
tasks: list[PlannedTask] = []
for raw_task in _required_list(raw_feature, "tasks"):
tasks.append(
PlannedTask(
task_id=_required_str(raw_task, "id"),
goal=_required_str(raw_task, "goal"),
task_type=str(raw_task.get("type", "implementation")),
acceptance_criteria=_required_nonempty_str_list(raw_task, "acceptance_criteria"),
priority=int(raw_task.get("priority", 50)),
dependencies=[str(item) for item in raw_task.get("dependencies", [])],
)
)
features.append(
PlannedFeature(
key=_required_str(raw_feature, "key"),
title=_required_str(raw_feature, "title"),
description=str(raw_feature.get("description", "")),
acceptance_criteria=[str(item) for item in raw_feature.get("acceptance_criteria", [])],
tasks=tasks,
)
)
milestones.append(
PlannedMilestone(
key=_required_str(raw_milestone, "key"),
title=_required_str(raw_milestone, "title"),
goal=_required_str(raw_milestone, "goal"),
verification_contract=dict(raw_milestone.get("verification_contract", {})),
features=features,
)
)
contract = ProjectPlanContract(
goal=_required_str(raw_plan, "goal"),
scope=str(raw_plan.get("scope", "")),
stack=dict(raw_plan.get("stack", {})),
architecture=dict(raw_plan.get("architecture", {})),
constraints=dict(raw_plan.get("constraints", {})),
acceptance_criteria=_required_nonempty_str_list(raw_plan, "acceptance_criteria"),
permissions=dict(raw_plan.get("permissions", {})),
budget=dict(raw_plan.get("budget", {})),
open_decisions=[str(item) for item in raw_plan.get("open_decisions", [])],
milestones=milestones,
)
validate_project_plan(contract)
return contract
def validate_project_plan(contract: ProjectPlanContract) -> None:
if not contract.goal.strip():
raise PlanValidationError("Project goal is required")
if not contract.acceptance_criteria:
raise PlanValidationError("Project acceptance criteria are required")
task_ids: set[str] = set()
dependencies: dict[str, list[str]] = {}
milestone_keys: set[str] = set()
feature_refs: set[tuple[str, str]] = set()
for milestone in contract.milestones:
if milestone.key in milestone_keys:
raise PlanValidationError(f"Duplicate milestone key: {milestone.key}")
milestone_keys.add(milestone.key)
if not milestone.features:
raise PlanValidationError(f"Milestone {milestone.key} must contain features")
for feature in milestone.features:
feature_ref = (milestone.key, feature.key)
if feature_ref in feature_refs:
raise PlanValidationError(f"Duplicate feature key in milestone: {feature.key}")
feature_refs.add(feature_ref)
if not feature.tasks:
raise PlanValidationError(f"Feature {feature.key} must contain tasks")
for task in feature.tasks:
if task.task_id in task_ids:
raise PlanValidationError(f"Duplicate task id: {task.task_id}")
if not task.acceptance_criteria:
raise PlanValidationError(f"Task {task.task_id} missing acceptance criteria")
task_ids.add(task.task_id)
dependencies[task.task_id] = task.dependencies
for task_id, deps in dependencies.items():
for dep in deps:
if dep not in task_ids:
raise PlanValidationError(f"Task {task_id} depends on nonexistent task {dep}")
_assert_acyclic(dependencies)
def _assert_acyclic(dependencies: dict[str, list[str]]) -> None:
visiting: set[str] = set()
visited: set[str] = set()
def visit(task_id: str) -> None:
if task_id in visited:
return
if task_id in visiting:
raise PlanValidationError("Task dependencies contain a cycle")
visiting.add(task_id)
for dependency in dependencies.get(task_id, []):
visit(dependency)
visiting.remove(task_id)
visited.add(task_id)
for task_id in dependencies:
visit(task_id)
def _required_str(value: dict[str, object], key: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item.strip():
raise PlanValidationError(f"Missing required string: {key}")
return item
def _required_list(value: dict[str, object], key: str) -> list[dict[str, object]]:
item = value.get(key)
if not isinstance(item, list) or not item:
raise PlanValidationError(f"Missing required non-empty list: {key}")
if not all(isinstance(entry, dict) for entry in item):
raise PlanValidationError(f"{key} entries must be objects")
return item
def _required_nonempty_str_list(value: dict[str, object], key: str) -> list[str]:
item = value.get(key)
if not isinstance(item, list) or not item or not all(isinstance(entry, str) and entry.strip() for entry in item):
raise PlanValidationError(f"Missing required non-empty string list: {key}")
return [str(entry) for entry in item]
class ProjectPlanBuilder:
def __init__(self, bus: EventBus | None = None) -> None:
self.bus = bus or EventBus()
def apply(self, project: Project, contract: ProjectPlanContract) -> ProjectPlan:
validate_project_plan(contract)
with transaction.atomic():
return self._apply(project, contract)
def _apply(self, project: Project, contract: ProjectPlanContract) -> ProjectPlan:
version = project.current_plan_version + 1
plan = ProjectPlan.objects.create(
project=project,
version=version,
goal=contract.goal,
scope=contract.scope,
stack=contract.stack,
architecture=contract.architecture,
constraints=contract.constraints,
acceptance_criteria=contract.acceptance_criteria,
permissions=contract.permissions,
budget=contract.budget,
open_decisions=contract.open_decisions,
approved_at=timezone.now(),
)
project.current_plan_version = version
project.goal = contract.goal
project.status = ProjectStatus.READY
project.save(update_fields=["current_plan_version", "goal", "status", "updated_at"])
self.bus.publish(EventType.PLAN_APPROVED, project=project, payload={"plan_id": str(plan.id), "version": version})
task_by_external_id: dict[str, Task] = {}
dependency_specs: list[tuple[Task, list[str]]] = []
for order, milestone_contract in enumerate(contract.milestones):
milestone = Milestone.objects.create(
project=project,
plan=plan,
key=milestone_contract.key,
title=milestone_contract.title,
goal=milestone_contract.goal,
verification_contract=milestone_contract.verification_contract,
order=order,
)
self.bus.publish(EventType.MILESTONE_CREATED, project=project, payload={"milestone_id": str(milestone.id), "key": milestone.key})
for feature_contract in milestone_contract.features:
feature = Feature.objects.create(
project=project,
milestone=milestone,
title=feature_contract.title,
description=feature_contract.description,
acceptance_criteria=feature_contract.acceptance_criteria,
)
for task_contract in feature_contract.tasks:
task = Task.objects.create(
project=project,
milestone=milestone,
feature=feature,
task_type=task_contract.task_type,
status=TaskStatus.READY,
priority=task_contract.priority,
goal=task_contract.goal,
acceptance_criteria=task_contract.acceptance_criteria,
)
task_by_external_id[task_contract.task_id] = task
dependency_specs.append((task, task_contract.dependencies))
self.bus.publish(EventType.TASK_CREATED, project=project, task=task, payload={"task_id": str(task.id)})
self.bus.publish(EventType.TASK_READY, project=project, task=task, payload={"task_id": str(task.id)})
for task, dependencies in dependency_specs:
for dependency in dependencies:
TaskDependency.objects.create(task=task, depends_on=task_by_external_id[dependency])
return plan