545 lines
39 KiB
Python
545 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from control_plane.agents.models import ProgenySignal
|
|
from control_plane.events.bus import EventBus
|
|
from control_plane.projects.models import (
|
|
CommitRecord,
|
|
Decision,
|
|
ExtensionCandidate,
|
|
ExtensionPlan,
|
|
Exploration,
|
|
ExplorationOpportunity,
|
|
EvolutionCandidate,
|
|
EvolutionPlan,
|
|
Feature,
|
|
Finding,
|
|
Milestone,
|
|
Project,
|
|
ProjectPlan,
|
|
RoadmapItem,
|
|
StewardFinding,
|
|
Task,
|
|
TaskDependency,
|
|
TaskStatus,
|
|
)
|
|
from control_plane.verification.models import Review, TestRun, Verification, VerificationLevel, VerificationResult
|
|
from graph.langgraph_runtime import LangGraphRuntime
|
|
from graph.models import GraphRun
|
|
from graph.task_nodes import TaskExecutionServices, task_execution_registry
|
|
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
|
|
from project_brain.planning import ProjectPlanContract, parse_project_plan_response
|
|
|
|
|
|
class LifecyclePlanningError(ValueError):
|
|
pass
|
|
|
|
|
|
class ProjectContextMixin:
|
|
def project_context(self, project: Project) -> dict[str, object]:
|
|
files: dict[str, str] = {}
|
|
tests: dict[str, str] = {}
|
|
if project.repository_path:
|
|
root = Path(project.repository_path)
|
|
for path in sorted(root.rglob("*.py"))[:40]:
|
|
if any(part in path.parts for part in [".git", "__pycache__", "migrations", ".venv", "venv", "node_modules", ".pytest_cache"]):
|
|
continue
|
|
relative = path.relative_to(root).as_posix()
|
|
content = path.read_text(encoding="utf-8", errors="ignore")[:3000]
|
|
if relative.startswith("tests") or "test" in path.name:
|
|
tests[relative] = content
|
|
else:
|
|
files[relative] = content
|
|
return {
|
|
"project": {"id": str(project.id), "name": project.name, "goal": project.goal, "architecture_summary": project.architecture_summary},
|
|
"decisions": list(project.decisions.order_by("-created_at").values("decision_type", "decision", "reason")[:20]),
|
|
"roadmap_items": list(project.roadmap_items.order_by("-created_at").values("title", "description", "status", "source")[:20]),
|
|
"findings": list(project.findings.order_by("-created_at").values("finding_type", "severity", "title", "status")[:20]),
|
|
"steward_findings": list(project.steward_findings.order_by("-created_at").values("finding_type", "severity", "title", "status", "recommended_action")[:20]),
|
|
"features": list(project.features.order_by("created_at").values("title", "description", "status", "acceptance_criteria")[:50]),
|
|
"tasks": list(project.tasks.order_by("-created_at").values("task_type", "status", "goal", "acceptance_criteria")[:50]),
|
|
"files": files,
|
|
"tests": tests,
|
|
}
|
|
|
|
def _json_from_sol(self, router: ModelRouter | None, prompt: str, *, fallback: dict[str, object], project: Project | None = None) -> dict[str, object]:
|
|
if router is None:
|
|
return fallback
|
|
response = router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, prompt=prompt, project=project))
|
|
try:
|
|
payload = json.loads(response.content)
|
|
except json.JSONDecodeError as exc:
|
|
raise LifecyclePlanningError("Sol lifecycle planning response must be JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise LifecyclePlanningError("Sol lifecycle planning response must be a JSON object")
|
|
return payload
|
|
|
|
def _json_from_project_brain(self, router: ModelRouter | None, prompt: str, *, fallback: dict[str, object], project: Project, category: str) -> dict[str, object]:
|
|
try:
|
|
return self._json_from_sol(router, prompt, fallback=fallback, project=project)
|
|
except Exception as exc:
|
|
create_project_planning_signal(project, f"Sol {category} planning response invalid; using bounded fallback plan.", {"error": str(exc), "category": category})
|
|
return fallback
|
|
|
|
|
|
class ExtensionService(ProjectContextMixin):
|
|
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
|
|
self.router = router
|
|
self.bus = bus or EventBus()
|
|
|
|
def create_candidate(
|
|
self,
|
|
project: Project,
|
|
*,
|
|
title: str,
|
|
description: str,
|
|
rationale: str = "",
|
|
source: str = "user",
|
|
expected_value: str = "",
|
|
affected_areas: list[str] | None = None,
|
|
estimated_complexity: str = "MEDIUM",
|
|
risk: str = "MEDIUM",
|
|
confidence: float = 0.5,
|
|
evidence: dict[str, object] | None = None,
|
|
source_steward_finding: StewardFinding | None = None,
|
|
source_opportunity: ExplorationOpportunity | None = None,
|
|
source_roadmap_item: RoadmapItem | None = None,
|
|
) -> ExtensionCandidate:
|
|
return ExtensionCandidate.objects.create(
|
|
project=project,
|
|
title=title,
|
|
description=description,
|
|
rationale=rationale,
|
|
source=source,
|
|
expected_value=expected_value,
|
|
affected_areas=affected_areas or [],
|
|
estimated_complexity=estimated_complexity,
|
|
risk=risk,
|
|
confidence=confidence,
|
|
evidence=evidence or {},
|
|
source_steward_finding=source_steward_finding,
|
|
source_opportunity=source_opportunity,
|
|
source_roadmap_item=source_roadmap_item,
|
|
)
|
|
|
|
def plan_with_project_brain(self, candidate: ExtensionCandidate) -> ExtensionPlan:
|
|
context = self.project_context(candidate.project)
|
|
fallback = self._fallback_extension_plan(candidate, context)
|
|
payload = self._json_from_project_brain(
|
|
self.router,
|
|
"Plan an EXTENSION for an existing project. Return JSON with extension_plan and project_plan. Do not treat this as greenfield.\n"
|
|
+ json.dumps({"candidate": self._candidate_payload(candidate), "context": context}, default=str),
|
|
fallback=fallback,
|
|
project=candidate.project,
|
|
category="extension",
|
|
)
|
|
raw_plan = payload.get("extension_plan", payload)
|
|
if not isinstance(raw_plan, dict):
|
|
raise LifecyclePlanningError("extension_plan must be an object")
|
|
project_plan_payload = raw_plan.get("project_plan") or payload.get("project_plan")
|
|
if not isinstance(project_plan_payload, dict):
|
|
create_project_planning_signal(candidate.project, "Sol extension plan omitted project_plan; using bounded fallback plan.", {"candidate_id": str(candidate.id)})
|
|
raw_plan = dict(fallback["extension_plan"])
|
|
project_plan_payload = dict(raw_plan["project_plan"])
|
|
try:
|
|
parse_project_plan_response(json.dumps(project_plan_payload))
|
|
except Exception as exc:
|
|
create_project_planning_signal(candidate.project, "Sol extension project_plan failed validation; using bounded fallback plan.", {"candidate_id": str(candidate.id), "error": str(exc)})
|
|
raw_plan = dict(fallback["extension_plan"])
|
|
project_plan_payload = dict(raw_plan["project_plan"])
|
|
parse_project_plan_response(json.dumps(project_plan_payload))
|
|
plan = ExtensionPlan.objects.create(
|
|
candidate=candidate,
|
|
project=candidate.project,
|
|
status="PLANNED",
|
|
strategy=str(raw_plan.get("strategy", candidate.description)),
|
|
plan=raw_plan,
|
|
acceptance_criteria=[str(item) for item in raw_plan.get("acceptance_criteria", project_plan_payload.get("acceptance_criteria", []))],
|
|
context_snapshot=context,
|
|
)
|
|
candidate.status = "PLANNING"
|
|
candidate.save(update_fields=["status", "updated_at"])
|
|
self.bus.publish("EXTENSION_PLAN_CREATED", project=candidate.project, payload={"candidate_id": str(candidate.id), "plan_id": str(plan.id)})
|
|
return plan
|
|
|
|
def approve_plan(self, plan: ExtensionPlan) -> ExtensionPlan:
|
|
plan.status = "APPROVED"
|
|
plan.approved_at = timezone.now()
|
|
plan.save(update_fields=["status", "approved_at", "updated_at"])
|
|
plan.candidate.status = "READY"
|
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
|
return plan
|
|
|
|
def materialize_project_dag(self, plan: ExtensionPlan) -> ProjectPlan:
|
|
if plan.project_plan_id:
|
|
return plan.project_plan
|
|
contract = parse_project_plan_response(json.dumps(plan.plan.get("project_plan", {})))
|
|
project_plan = self._materialize_contract(plan.project, contract, prefix=f"EXT-{str(plan.id)[:8]}")
|
|
plan.project_plan = project_plan
|
|
plan.status = "MATERIALIZED"
|
|
plan.save(update_fields=["project_plan", "status", "updated_at"])
|
|
plan.candidate.status = "BUILDING"
|
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
|
return project_plan
|
|
|
|
def execute(self, plan: ExtensionPlan, router: ModelRouter, *, test_command: list[str] | None = None) -> list[GraphRun]:
|
|
from graph.bootstrap import champion_task_execution_graph_v1
|
|
|
|
project_plan = self.materialize_project_dag(plan)
|
|
runs: list[GraphRun] = []
|
|
for task in Task.objects.filter(milestone__plan=project_plan).exclude(status=TaskStatus.COMPLETE).order_by("priority", "created_at"):
|
|
graph_version = champion_task_execution_graph_v1()
|
|
graph_run = GraphRun.objects.create(execution_graph_version=graph_version, project=task.project, milestone=task.milestone, feature=task.feature, task=task, current_node=graph_version.graph_spec["entry"], metadata={"extension_plan_id": str(plan.id), "extension_candidate_id": str(plan.candidate_id)})
|
|
LangGraphRuntime(task_execution_registry(TaskExecutionServices(router, bus=self.bus, test_command=test_command or ["python", "-m", "pytest"])), bus=self.bus).run_until_terminal_or_paused(graph_run)
|
|
for commit in task.commits.all():
|
|
commit.extension_candidate = plan.candidate
|
|
commit.save(update_fields=["extension_candidate", "updated_at"])
|
|
runs.append(graph_run)
|
|
return runs
|
|
|
|
def verify_extension(self, plan: ExtensionPlan) -> Verification:
|
|
project_plan = plan.project_plan
|
|
tasks = Task.objects.filter(milestone__plan=project_plan) if project_plan else Task.objects.none()
|
|
task_count = tasks.count()
|
|
completed = task_count > 0 and not tasks.exclude(status=TaskStatus.COMPLETE).exists()
|
|
tests_pass = not TestRun.objects.filter(task__in=tasks).exclude(status="PASS").exists()
|
|
review_failures = Review.objects.filter(task__in=tasks).exclude(status="PASS")
|
|
judge_failures = Verification.objects.filter(task__in=tasks, level=VerificationLevel.TASK).exclude(result=VerificationResult.PASS)
|
|
criteria = plan.acceptance_criteria or plan.plan.get("acceptance_criteria", [])
|
|
criteria_present = bool(criteria)
|
|
passed = completed and tests_pass and not review_failures.exists() and not judge_failures.exists() and criteria_present
|
|
verification = Verification.objects.create(
|
|
project=plan.project,
|
|
milestone=tasks.first().milestone if tasks.exists() else None,
|
|
level=VerificationLevel.MILESTONE,
|
|
result=VerificationResult.PASS if passed else VerificationResult.FAIL,
|
|
contract={"extension_plan_id": str(plan.id), "acceptance_criteria": criteria},
|
|
evidence=[{"task_count": task_count, "all_tasks_complete": completed, "tests_pass": tests_pass, "review_failures": review_failures.count(), "judge_failures": judge_failures.count()}],
|
|
summary="Extension acceptance contract satisfied" if passed else "Extension acceptance contract failed",
|
|
)
|
|
plan.verification = verification
|
|
plan.status = "COMPLETE" if passed else "FAILED"
|
|
plan.completed_at = timezone.now()
|
|
plan.save(update_fields=["verification", "status", "completed_at", "updated_at"])
|
|
plan.candidate.status = "COMPLETE" if passed else "BUILDING"
|
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
|
return verification
|
|
|
|
def _fallback_extension_plan(self, candidate: ExtensionCandidate, context: dict[str, object]) -> dict[str, object]:
|
|
task_goal = candidate.metadata.get("task_goal") or candidate.description or candidate.title
|
|
acceptance = candidate.metadata.get("acceptance_criteria") or [f"{candidate.title} capability is present", "Deterministic tests pass"]
|
|
return {
|
|
"extension_plan": {
|
|
"strategy": f"Add bounded scope to existing project: {candidate.title}",
|
|
"acceptance_criteria": acceptance,
|
|
"project_plan": {
|
|
"goal": candidate.project.goal,
|
|
"scope": candidate.description,
|
|
"acceptance_criteria": acceptance,
|
|
"milestones": [
|
|
{
|
|
"key": "EXTEND",
|
|
"title": candidate.title,
|
|
"goal": candidate.description or candidate.title,
|
|
"verification_contract": {"extension_candidate_id": str(candidate.id)},
|
|
"features": [{"key": "F1", "title": candidate.title, "description": candidate.description, "acceptance_criteria": acceptance, "tasks": [{"id": "T1", "goal": str(task_goal), "type": "implementation", "acceptance_criteria": acceptance, "priority": 50, "dependencies": []}]}],
|
|
}
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
def _candidate_payload(self, candidate: ExtensionCandidate) -> dict[str, object]:
|
|
return {"id": str(candidate.id), "title": candidate.title, "description": candidate.description, "rationale": candidate.rationale, "expected_value": candidate.expected_value, "affected_areas": candidate.affected_areas, "evidence": candidate.evidence}
|
|
|
|
def _materialize_contract(self, project: Project, contract: ProjectPlanContract, *, prefix: str) -> ProjectPlan:
|
|
with transaction.atomic():
|
|
version = project.current_plan_version + 1
|
|
project_plan = ProjectPlan.objects.create(project=project, version=version, goal=project.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.save(update_fields=["current_plan_version", "updated_at"])
|
|
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=project_plan, key=f"{prefix}-{milestone_contract.key}"[:50], title=milestone_contract.title, goal=milestone_contract.goal, verification_contract=milestone_contract.verification_contract, order=order)
|
|
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))
|
|
for task, deps in dependency_specs:
|
|
for dep in deps:
|
|
TaskDependency.objects.create(task=task, depends_on=task_by_external_id[dep])
|
|
return project_plan
|
|
|
|
|
|
class EvolutionService(ProjectContextMixin):
|
|
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
|
|
self.router = router
|
|
self.bus = bus or EventBus()
|
|
|
|
def create_candidate(self, project: Project, *, target: str, objective: str, baseline_measurement: dict[str, object], desired_direction: str, target_measurement: dict[str, object] | None = None, rationale: str = "", source: str = "user", evidence: dict[str, object] | None = None, risk: str = "MEDIUM", confidence: float = 0.5, source_steward_finding: StewardFinding | None = None, source_opportunity: ExplorationOpportunity | None = None, source_roadmap_item: RoadmapItem | None = None) -> EvolutionCandidate:
|
|
if not baseline_measurement:
|
|
raise ValidationError("EVOLVE requires a measurable baseline; route to INVESTIGATE or EXPLORE instead.")
|
|
return EvolutionCandidate.objects.create(project=project, target=target, objective=objective, baseline_measurement=baseline_measurement, desired_direction=desired_direction, target_measurement=target_measurement or {}, rationale=rationale, source=source, evidence=evidence or {}, risk=risk, confidence=confidence, source_steward_finding=source_steward_finding, source_opportunity=source_opportunity, source_roadmap_item=source_roadmap_item)
|
|
|
|
def plan_with_project_brain(self, candidate: EvolutionCandidate) -> EvolutionPlan:
|
|
if not candidate.baseline_measurement:
|
|
raise ValidationError("EvolutionCandidate has no baseline measurement.")
|
|
context = self.project_context(candidate.project)
|
|
fallback = self._fallback_evolution_plan(candidate)
|
|
payload = self._json_from_project_brain(self.router, "Plan an EVOLUTION for an existing project. Return JSON with evolution_plan and project_plan. Must preserve measurable baseline and threshold.\n" + json.dumps({"candidate": self._candidate_payload(candidate), "context": context}, default=str), fallback=fallback, project=candidate.project, category="evolution")
|
|
raw_plan = payload.get("evolution_plan", payload)
|
|
if not isinstance(raw_plan, dict):
|
|
raise LifecyclePlanningError("evolution_plan must be an object")
|
|
project_plan_payload = raw_plan.get("project_plan") or payload.get("project_plan")
|
|
if not isinstance(project_plan_payload, dict):
|
|
create_project_planning_signal(candidate.project, "Sol evolution plan omitted project_plan; using bounded fallback plan.", {"candidate_id": str(candidate.id)})
|
|
raw_plan = dict(fallback["evolution_plan"])
|
|
project_plan_payload = dict(raw_plan["project_plan"])
|
|
try:
|
|
parse_project_plan_response(json.dumps(project_plan_payload))
|
|
except Exception as exc:
|
|
create_project_planning_signal(candidate.project, "Sol evolution project_plan failed validation; using bounded fallback plan.", {"candidate_id": str(candidate.id), "error": str(exc)})
|
|
raw_plan = dict(fallback["evolution_plan"])
|
|
project_plan_payload = dict(raw_plan["project_plan"])
|
|
parse_project_plan_response(json.dumps(project_plan_payload))
|
|
plan = EvolutionPlan.objects.create(candidate=candidate, project=candidate.project, status="PLANNED", baseline=dict(raw_plan.get("baseline", candidate.baseline_measurement)), hypothesis=str(raw_plan.get("hypothesis", candidate.objective)), intervention=str(raw_plan.get("intervention", "Implement targeted project improvement")), measurement_method=dict(raw_plan.get("measurement_method", {"type": "metadata"})), success_threshold=dict(raw_plan.get("success_threshold", {"minimum_improvement_percent": 10})), regression_constraints=list(raw_plan.get("regression_constraints", ["Deterministic tests pass"])), affected_components=list(raw_plan.get("affected_components", [candidate.target])), task_plan=project_plan_payload, experiment_requirements=dict(raw_plan.get("experiment_requirements", {})), context_snapshot=context)
|
|
candidate.status = "PLANNING"
|
|
candidate.save(update_fields=["status", "updated_at"])
|
|
return plan
|
|
|
|
def approve_plan(self, plan: EvolutionPlan) -> EvolutionPlan:
|
|
plan.status = "APPROVED"
|
|
plan.approved_at = timezone.now()
|
|
plan.save(update_fields=["status", "approved_at", "updated_at"])
|
|
plan.candidate.status = "READY"
|
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
|
return plan
|
|
|
|
def materialize_work(self, plan: EvolutionPlan) -> ProjectPlan:
|
|
if plan.project_plan_id:
|
|
return plan.project_plan
|
|
contract = parse_project_plan_response(json.dumps(plan.task_plan))
|
|
project_plan = ExtensionService(bus=self.bus)._materialize_contract(plan.project, contract, prefix=f"EVO-{str(plan.id)[:8]}")
|
|
plan.project_plan = project_plan
|
|
plan.status = "MATERIALIZED"
|
|
plan.save(update_fields=["project_plan", "status", "updated_at"])
|
|
plan.candidate.status = "BUILDING"
|
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
|
return project_plan
|
|
|
|
def execute(self, plan: EvolutionPlan, router: ModelRouter, *, test_command: list[str] | None = None) -> list[GraphRun]:
|
|
from graph.bootstrap import champion_task_execution_graph_v1
|
|
|
|
project_plan = self.materialize_work(plan)
|
|
runs: list[GraphRun] = []
|
|
for task in Task.objects.filter(milestone__plan=project_plan).exclude(status=TaskStatus.COMPLETE).order_by("priority", "created_at"):
|
|
graph_version = champion_task_execution_graph_v1()
|
|
graph_run = GraphRun.objects.create(execution_graph_version=graph_version, project=task.project, milestone=task.milestone, feature=task.feature, task=task, current_node=graph_version.graph_spec["entry"], metadata={"evolution_plan_id": str(plan.id), "evolution_candidate_id": str(plan.candidate_id)})
|
|
LangGraphRuntime(task_execution_registry(TaskExecutionServices(router, bus=self.bus, test_command=test_command or ["python", "-m", "pytest"])), bus=self.bus).run_until_terminal_or_paused(graph_run)
|
|
for commit in task.commits.all():
|
|
commit.evolution_candidate = plan.candidate
|
|
commit.save(update_fields=["evolution_candidate", "updated_at"])
|
|
runs.append(graph_run)
|
|
return runs
|
|
|
|
def measure_candidate(self, plan: EvolutionPlan) -> dict[str, object]:
|
|
method = plan.measurement_method or {}
|
|
if "candidate_measurement" in plan.metadata:
|
|
measurement = dict(plan.metadata["candidate_measurement"])
|
|
elif method.get("type") == "command" and plan.project.repository_path:
|
|
completed = subprocess.run([str(part) for part in method.get("command", [])], cwd=plan.project.repository_path, capture_output=True, text=True, check=False, timeout=120)
|
|
measurement = {"returncode": completed.returncode, "stdout": completed.stdout[-4000:], "stderr": completed.stderr[-2000:]}
|
|
else:
|
|
measurement = dict(plan.candidate.target_measurement or plan.baseline)
|
|
plan.candidate_measurement = measurement
|
|
plan.save(update_fields=["candidate_measurement", "updated_at"])
|
|
return measurement
|
|
|
|
def compare_baseline(self, plan: EvolutionPlan) -> dict[str, object]:
|
|
metric = str(plan.success_threshold.get("metric", plan.baseline.get("metric", "value")))
|
|
baseline_value = float(plan.baseline.get(metric, plan.baseline.get("value", 0)) or 0)
|
|
candidate_value = float(plan.candidate_measurement.get(metric, plan.candidate_measurement.get("value", baseline_value)) or 0)
|
|
direction = plan.candidate.desired_direction
|
|
if baseline_value == 0:
|
|
improvement_percent = 0.0
|
|
elif direction == "DECREASE":
|
|
improvement_percent = ((baseline_value - candidate_value) / baseline_value) * 100
|
|
else:
|
|
improvement_percent = ((candidate_value - baseline_value) / baseline_value) * 100
|
|
threshold = float(plan.success_threshold.get("minimum_improvement_percent", 0))
|
|
delta = {"metric": metric, "baseline": baseline_value, "candidate": candidate_value, "improvement_percent": improvement_percent, "threshold": threshold}
|
|
plan.delta = delta
|
|
plan.save(update_fields=["delta", "updated_at"])
|
|
return delta
|
|
|
|
def judge_evolution(self, plan: EvolutionPlan) -> Verification:
|
|
if not plan.candidate_measurement:
|
|
self.measure_candidate(plan)
|
|
delta = self.compare_baseline(plan)
|
|
project_plan = plan.project_plan
|
|
tasks = Task.objects.filter(milestone__plan=project_plan) if project_plan else Task.objects.none()
|
|
tests_pass = not TestRun.objects.filter(task__in=tasks).exclude(status="PASS").exists()
|
|
implementation_complete = not tasks.exclude(status=TaskStatus.COMPLETE).exists() if tasks.exists() else True
|
|
improved = float(delta["improvement_percent"]) >= float(delta["threshold"])
|
|
verdict = "PASS" if implementation_complete and tests_pass and improved else "NOT_IMPROVED"
|
|
verification = Verification.objects.create(project=plan.project, milestone=tasks.first().milestone if tasks.exists() else None, level=VerificationLevel.MILESTONE, result=VerificationResult.PASS if verdict == "PASS" else VerificationResult.FAIL, contract={"evolution_plan_id": str(plan.id), "success_threshold": plan.success_threshold, "regression_constraints": plan.regression_constraints}, evidence=[{"implementation_complete": implementation_complete, "tests_pass": tests_pass, "delta": delta, "verdict": verdict}], summary="Evolution objective improved" if verdict == "PASS" else "Evolution implementation did not improve objective")
|
|
plan.verification = verification
|
|
plan.verdict = verdict
|
|
plan.status = "COMPLETE" if verdict == "PASS" else "NOT_IMPROVED"
|
|
plan.completed_at = timezone.now()
|
|
plan.save(update_fields=["verification", "verdict", "status", "completed_at", "updated_at"])
|
|
plan.candidate.status = "COMPLETE" if verdict == "PASS" else "PROPOSED"
|
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
|
return verification
|
|
|
|
def _fallback_evolution_plan(self, candidate: EvolutionCandidate) -> dict[str, object]:
|
|
acceptance = ["Deterministic tests pass", f"Objective improves: {candidate.objective}"]
|
|
task_goal = candidate.metadata.get("task_goal") or f"Improve {candidate.target}: {candidate.objective}"
|
|
return {"evolution_plan": {"baseline": candidate.baseline_measurement, "hypothesis": candidate.objective, "intervention": str(task_goal), "measurement_method": {"type": "metadata"}, "success_threshold": {"metric": candidate.baseline_measurement.get("metric", "value"), "minimum_improvement_percent": 10}, "regression_constraints": ["Deterministic tests pass"], "affected_components": [candidate.target], "project_plan": {"goal": candidate.project.goal, "scope": candidate.objective, "acceptance_criteria": acceptance, "milestones": [{"key": "EVOLVE", "title": candidate.target, "goal": candidate.objective, "verification_contract": {"evolution_candidate_id": str(candidate.id)}, "features": [{"key": "F1", "title": candidate.target, "description": candidate.objective, "acceptance_criteria": acceptance, "tasks": [{"id": "T1", "goal": str(task_goal), "type": "implementation", "acceptance_criteria": acceptance, "priority": 50, "dependencies": []}]}]}]}}}
|
|
|
|
def _candidate_payload(self, candidate: EvolutionCandidate) -> dict[str, object]:
|
|
return {"id": str(candidate.id), "target": candidate.target, "objective": candidate.objective, "baseline_measurement": candidate.baseline_measurement, "desired_direction": candidate.desired_direction, "target_measurement": candidate.target_measurement, "evidence": candidate.evidence}
|
|
|
|
|
|
class ExplorerService(ProjectContextMixin):
|
|
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
|
|
self.router = router
|
|
self.bus = bus or EventBus()
|
|
|
|
def start_exploration(self, project: Project, *, prompt: str = "") -> Exploration:
|
|
exploration = Exploration.objects.create(project=project, prompt=prompt, context_snapshot=self.project_context(project))
|
|
return exploration
|
|
|
|
def generate_opportunities(self, exploration: Exploration) -> list[ExplorationOpportunity]:
|
|
fallback = {"opportunities": self._fallback_opportunities(exploration)}
|
|
payload = self._json_from_project_brain(self.router, "Explore an existing project for valuable changes. Return JSON opportunities only; do not create build work.\n" + json.dumps(exploration.context_snapshot, default=str), fallback=fallback, project=exploration.project, category="exploration")
|
|
raw_items = payload.get("opportunities", [])
|
|
if not isinstance(raw_items, list):
|
|
raise LifecyclePlanningError("Explorer response opportunities must be a list")
|
|
opportunities: list[ExplorationOpportunity] = []
|
|
for raw in raw_items:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
opportunity = self.upsert_opportunity(exploration, raw)
|
|
opportunities.append(opportunity)
|
|
exploration.status = "COMPLETE"
|
|
exploration.completed_at = timezone.now()
|
|
exploration.save(update_fields=["status", "completed_at", "updated_at"])
|
|
return sorted(opportunities, key=lambda item: item.composite_score, reverse=True)
|
|
|
|
def upsert_opportunity(self, exploration: Exploration, raw: dict[str, object]) -> ExplorationOpportunity:
|
|
title = str(raw.get("title", "Untitled opportunity"))
|
|
grouping_key = self._grouping_key(exploration.project, title, str(raw.get("opportunity_type", "FEATURE")))
|
|
existing = self._known_duplicate(exploration.project, grouping_key, title)
|
|
scores = self.score(raw)
|
|
raw_evidence = raw.get("evidence", {})
|
|
evidence = raw_evidence if isinstance(raw_evidence, dict) else {"raw": raw_evidence}
|
|
if existing is not None:
|
|
existing.metadata = {**existing.metadata, "rediscovered_by": str(exploration.id)}
|
|
existing.save(update_fields=["metadata", "updated_at"])
|
|
return existing
|
|
return ExplorationOpportunity.objects.create(exploration=exploration, project=exploration.project, title=title, description=str(raw.get("description", "")), opportunity_type=str(raw.get("opportunity_type", "FEATURE")), evidence=evidence, rationale=str(raw.get("rationale", "")), expected_value=str(raw.get("expected_value", "")), effort_estimate=str(raw.get("effort_estimate", "MEDIUM")), risk=str(raw.get("risk", "MEDIUM")), confidence=scores["confidence"], technical_fit=scores["technical_fit"], strategic_fit=scores["strategic_fit"], value_score=scores["value"], effort_score=scores["effort"], risk_score=scores["risk"], composite_score=scores["composite"], recommended_action=str(raw.get("recommended_action", "DEFER")), grouping_key=grouping_key)
|
|
|
|
def score(self, raw: dict[str, object]) -> dict[str, float]:
|
|
value = self._score_value(raw.get("value", raw.get("value_score", 0.5)))
|
|
effort = self._score_value(raw.get("effort", raw.get("effort_score", 0.5)))
|
|
risk = self._score_value(raw.get("risk_score", 0.5))
|
|
confidence = self._score_value(raw.get("confidence", 0.5))
|
|
technical_fit = self._score_value(raw.get("technical_fit", 0.5))
|
|
strategic_fit = self._score_value(raw.get("strategic_fit", 0.5))
|
|
composite = (value * 0.35) + ((1 - effort) * 0.15) + ((1 - risk) * 0.15) + (confidence * 0.15) + (technical_fit * 0.1) + (strategic_fit * 0.1)
|
|
return {"value": value, "effort": effort, "risk": risk, "confidence": confidence, "technical_fit": technical_fit, "strategic_fit": strategic_fit, "composite": composite}
|
|
|
|
def _score_value(self, value: object) -> float:
|
|
try:
|
|
score = float(value)
|
|
except (TypeError, ValueError):
|
|
return 0.5
|
|
return max(0.0, min(1.0, score))
|
|
|
|
def convert_to_extension(self, opportunity: ExplorationOpportunity) -> ExtensionCandidate:
|
|
candidate = ExtensionService(bus=self.bus).create_candidate(opportunity.project, title=opportunity.title, description=opportunity.description, rationale=opportunity.rationale, source="Explorer", expected_value=opportunity.expected_value, affected_areas=[opportunity.opportunity_type], estimated_complexity=opportunity.effort_estimate, risk=opportunity.risk, confidence=opportunity.confidence, evidence=opportunity.evidence, source_opportunity=opportunity)
|
|
opportunity.converted_extension = candidate
|
|
opportunity.status = "CONVERTED"
|
|
opportunity.save(update_fields=["converted_extension", "status", "updated_at"])
|
|
return candidate
|
|
|
|
def convert_to_evolution(self, opportunity: ExplorationOpportunity, *, baseline_measurement: dict[str, object], desired_direction: str = "DECREASE") -> EvolutionCandidate:
|
|
candidate = EvolutionService(bus=self.bus).create_candidate(opportunity.project, target=opportunity.opportunity_type, objective=opportunity.description or opportunity.title, baseline_measurement=baseline_measurement, desired_direction=desired_direction, rationale=opportunity.rationale, source="Explorer", evidence=opportunity.evidence, risk=opportunity.risk, confidence=opportunity.confidence, source_opportunity=opportunity)
|
|
opportunity.converted_evolution = candidate
|
|
opportunity.status = "CONVERTED"
|
|
opportunity.save(update_fields=["converted_evolution", "status", "updated_at"])
|
|
return candidate
|
|
|
|
def defer(self, opportunity: ExplorationOpportunity) -> ExplorationOpportunity:
|
|
opportunity.status = "DEFERRED"
|
|
opportunity.save(update_fields=["status", "updated_at"])
|
|
return opportunity
|
|
|
|
def reject(self, opportunity: ExplorationOpportunity) -> ExplorationOpportunity:
|
|
opportunity.status = "REJECTED"
|
|
opportunity.save(update_fields=["status", "updated_at"])
|
|
return opportunity
|
|
|
|
def _fallback_opportunities(self, exploration: Exploration) -> list[dict[str, object]]:
|
|
context = exploration.context_snapshot
|
|
features = str(context.get("features", "")).lower()
|
|
opportunities: list[dict[str, object]] = []
|
|
if "dashboard" not in features:
|
|
opportunities.append({"title": "Add project health dashboard", "description": "Expose recent runs, findings, and lifecycle status in a project dashboard.", "opportunity_type": "FEATURE", "evidence": {"missing_feature": "dashboard"}, "rationale": "Operators need a fast project health view.", "expected_value": "Improves observability", "effort_estimate": "MEDIUM", "value": 0.8, "effort": 0.45, "risk_score": 0.3, "confidence": 0.7, "technical_fit": 0.8, "strategic_fit": 0.8, "recommended_action": "EXTEND"})
|
|
opportunities.append({"title": "Measure repository analysis latency", "description": "Establish and improve latency for repeated project inspection.", "opportunity_type": "PERFORMANCE", "evidence": {"source": "Explorer fallback"}, "rationale": "Faster analysis improves iteration speed.", "expected_value": "Reduces operational latency", "effort_estimate": "LOW", "value": 0.6, "effort": 0.25, "risk_score": 0.2, "confidence": 0.65, "technical_fit": 0.75, "strategic_fit": 0.65, "recommended_action": "EVOLVE"})
|
|
return opportunities
|
|
|
|
def _known_duplicate(self, project: Project, grouping_key: str, title: str) -> ExplorationOpportunity | None:
|
|
existing = ExplorationOpportunity.objects.filter(project=project, grouping_key=grouping_key).first()
|
|
if existing:
|
|
return existing
|
|
lowered = title.lower()
|
|
if ExtensionCandidate.objects.filter(project=project, title__iexact=title).exists() or RoadmapItem.objects.filter(project=project, title__iexact=title).exists():
|
|
return ExplorationOpportunity.objects.filter(project=project, title__iexact=title).first()
|
|
if any(item in lowered for item in ["authentication", "auth"]) and Decision.objects.filter(project=project, decision__icontains="authentication").exists():
|
|
return ExplorationOpportunity.objects.filter(project=project, title__icontains="auth").first()
|
|
return None
|
|
|
|
def _grouping_key(self, project: Project, title: str, opportunity_type: str) -> str:
|
|
fingerprint = hashlib.sha256(f"{title.lower()}:{opportunity_type.lower()}".encode("utf-8")).hexdigest()[:16]
|
|
return f"{project.id}:{opportunity_type}:{fingerprint}"[:240]
|
|
|
|
|
|
class LifecycleInspectionService:
|
|
def project_lifecycle_view(self, project: Project) -> dict[str, object]:
|
|
return {
|
|
"project_id": str(project.id),
|
|
"repairs": list(project.steward_findings.filter(recommended_action="REPAIR").values("id", "title", "status", "severity")),
|
|
"extensions": [self._extension(candidate) for candidate in project.extension_candidates.order_by("-created_at")],
|
|
"evolutions": [self._evolution(candidate) for candidate in project.evolution_candidates.order_by("-created_at")],
|
|
"explorations": [self._exploration(exploration) for exploration in project.explorations.order_by("-created_at")],
|
|
}
|
|
|
|
def _extension(self, candidate: ExtensionCandidate) -> dict[str, object]:
|
|
plan = candidate.plans.order_by("-created_at").first()
|
|
tasks = Task.objects.filter(milestone__plan=plan.project_plan) if plan and plan.project_plan_id else Task.objects.none()
|
|
return {"candidate": {"id": str(candidate.id), "title": candidate.title, "status": candidate.status}, "plan": str(plan.id) if plan else None, "tasks": list(tasks.values("id", "status", "goal")), "verification": str(plan.verification_id) if plan and plan.verification_id else None, "commits": list(candidate.commits.values("id", "sha", "task_id"))}
|
|
|
|
def _evolution(self, candidate: EvolutionCandidate) -> dict[str, object]:
|
|
plan = candidate.plans.order_by("-created_at").first()
|
|
return {"candidate": {"id": str(candidate.id), "target": candidate.target, "objective": candidate.objective, "status": candidate.status}, "baseline": candidate.baseline_measurement, "target": candidate.target_measurement, "measurement": plan.candidate_measurement if plan else {}, "delta": plan.delta if plan else {}, "verdict": plan.verdict if plan else ""}
|
|
|
|
def _exploration(self, exploration: Exploration) -> dict[str, object]:
|
|
return {"id": str(exploration.id), "status": exploration.status, "opportunities": list(exploration.opportunities.order_by("-composite_score").values("id", "title", "opportunity_type", "status", "recommended_action", "value_score", "effort_score", "risk_score", "confidence", "technical_fit", "strategic_fit", "composite_score"))}
|
|
|
|
|
|
def create_project_planning_signal(project: Project, summary: str, evidence: dict[str, object]) -> ProgenySignal:
|
|
return ProgenySignal.objects.create(project=project, source="project_lifecycle", severity="MEDIUM", failure_category="PROJECT_PLANNING", summary=summary, evidence=evidence, grouping_key=f"project_lifecycle:{project.id}:planning"[:120])
|