Artifex/agents/roadmap.py
2026-08-16 15:35:55 +07:00

168 lines
14 KiB
Python

from __future__ import annotations
import hashlib
import json
from typing import Any
from control_plane.agents.models import ProgenySignal
from control_plane.events.bus import EventBus
from control_plane.projects.models import (
Decision,
EvolutionCandidate,
ExtensionCandidate,
ExplorationOpportunity,
Project,
RoadmapHorizon,
RoadmapItem,
RoadmapStatus,
RoadmapTargetAction,
StewardFinding,
)
from agents.lifecycle import EvolutionService, ExtensionService, ProjectContextMixin
from agents.progeny import ProgenyService
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
class RoadmapService(ProjectContextMixin):
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
self.router = router
self.bus = bus or EventBus()
def upsert_item(self, project: Project, *, title: str, description: str = "", source: str = "USER", source_ref: dict[str, object] | None = None, rationale: str = "", evidence: dict[str, object] | None = None, horizon: str = RoadmapHorizon.EXPLORING, category: str = "", target_action: str = RoadmapTargetAction.NONE, scores: dict[str, object] | None = None, status: str = RoadmapStatus.PROPOSED) -> RoadmapItem:
grouping_key = self._grouping_key(project, title, category or target_action)
existing = self._find_existing(project, grouping_key, title)
values = self.score(scores or {})
if existing:
metadata = dict(existing.metadata)
metadata["occurrences"] = int(metadata.get("occurrences", 1)) + 1
metadata.setdefault("reinforced_by", []).append({"source": source, "source_ref": source_ref or {}})
existing.evidence = self._merge_evidence(existing.evidence, evidence or {})
existing.source_ref = self._merge_evidence(existing.source_ref, source_ref or {})
existing.confidence = min(1.0, max(existing.confidence, values["confidence"]) + 0.05)
existing.composite_score = self._composite(existing)
existing.metadata = metadata
existing.save(update_fields=["evidence", "source_ref", "confidence", "composite_score", "metadata", "updated_at"])
self.bus.publish("ROADMAP_ITEM_UPDATED", project=project, payload={"roadmap_item_id": str(existing.id), "reason": "deduplicated_reinforcement"})
return existing
item = RoadmapItem.objects.create(project=project, title=title, description=description, source=source, source_ref=source_ref or {}, rationale=rationale, evidence=evidence or {}, horizon=horizon, category=category, status=status, target_action=target_action, grouping_key=grouping_key, value_score=values["value"], effort_score=values["effort"], risk_score=values["risk"], confidence=values["confidence"], strategic_fit=values["strategic_fit"], technical_fit=values["technical_fit"], urgency=values["urgency"])
item.composite_score = self._composite(item)
item.priority = max(1, min(100, int(item.composite_score * 100)))
item.save(update_fields=["composite_score", "priority", "updated_at"])
self.bus.publish("ROADMAP_ITEM_CREATED", project=project, payload={"roadmap_item_id": str(item.id), "source": source})
return item
def gather_candidate_items(self, project: Project) -> list[RoadmapItem]:
items: list[RoadmapItem] = []
for opportunity in project.exploration_opportunities.exclude(status="REJECTED"):
items.append(self.upsert_item(project, title=opportunity.title, description=opportunity.description, source="EXPLORE", source_ref={"exploration_opportunity_id": str(opportunity.id)}, rationale=opportunity.rationale, evidence=opportunity.evidence, horizon=RoadmapHorizon.EXPLORING, category=opportunity.opportunity_type, target_action=opportunity.recommended_action if opportunity.recommended_action in RoadmapTargetAction.values else RoadmapTargetAction.NONE, scores={"value": opportunity.value_score, "effort": opportunity.effort_score, "risk": opportunity.risk_score, "confidence": opportunity.confidence, "strategic_fit": opportunity.strategic_fit, "technical_fit": opportunity.technical_fit}))
for finding in project.steward_findings.exclude(status__in=["RESOLVED", "DISMISSED"]):
action = finding.recommended_action if finding.recommended_action in RoadmapTargetAction.values else RoadmapTargetAction.INVESTIGATE
items.append(self.upsert_item(project, title=finding.title, description=finding.summary, source="STEWARD", source_ref={"steward_finding_id": str(finding.id)}, rationale="Steward surfaced this future project intent.", evidence=finding.evidence, horizon=RoadmapHorizon.EXPLORING, category=finding.finding_type, target_action=action, scores={"confidence": finding.confidence, "risk": 0.7 if finding.severity in ["HIGH", "CRITICAL"] else 0.4, "urgency": 0.8 if finding.severity in ["HIGH", "CRITICAL"] else 0.4}))
return items
def review_with_project_brain(self, project: Project) -> dict[str, object]:
fallback = {"recommendations": []}
if self.router is None:
return fallback
payload = {"context": self.project_context(project), "roadmap_items": list(project.roadmap_items.values("id", "title", "description", "horizon", "status", "target_action", "value_score", "effort_score", "risk_score", "confidence", "strategic_fit", "technical_fit", "urgency", "composite_score"))}
try:
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, project=project, prompt="Review this existing project roadmap. Return JSON with recommendations: item_id, recommendation, rationale, optional horizon/status. Do not execute work.\n" + json.dumps(payload, default=str)))
parsed = json.loads(response.content)
return parsed if isinstance(parsed, dict) else fallback
except Exception as exc:
ProgenySignal.objects.create(project=project, source="roadmap", severity="MEDIUM", failure_category="ROADMAP_PRIORITIZATION", summary="Roadmap Project Brain review failed; deterministic scoring retained.", evidence={"error": str(exc)}, grouping_key=f"roadmap:{project.id}:prioritization")
return fallback
def apply_recommendations(self, project: Project, recommendations: dict[str, object]) -> list[RoadmapItem]:
updated: list[RoadmapItem] = []
for rec in recommendations.get("recommendations", []):
if not isinstance(rec, dict):
continue
item_id = rec.get("item_id")
try:
item = project.roadmap_items.get(id=item_id)
except Exception:
continue
item.metadata = {**item.metadata, "project_brain_recommendations": [*item.metadata.get("project_brain_recommendations", []), rec]}
if rec.get("horizon") in RoadmapHorizon.values:
item.horizon = str(rec["horizon"])
if rec.get("status") in RoadmapStatus.values:
item.status = str(rec["status"])
item.save(update_fields=["horizon", "status", "metadata", "updated_at"])
updated.append(item)
self.bus.publish("ROADMAP_ITEM_UPDATED", project=project, payload={"roadmap_item_id": str(item.id), "recommendation": rec})
return updated
def review_project_roadmap(self, project: Project) -> dict[str, object]:
self.gather_candidate_items(project)
recommendations = self.review_with_project_brain(project)
self.apply_recommendations(project, recommendations)
self.bus.publish("ROADMAP_REVIEW_COMPLETED", project=project, payload={"item_count": project.roadmap_items.count(), "recommendations": recommendations})
return self.project_roadmap_view(project)
def convert_to_extension(self, item: RoadmapItem) -> ExtensionCandidate:
candidate = ExtensionService(bus=self.bus).create_candidate(item.project, title=item.title, description=item.description, rationale=item.rationale, source="RoadmapItem", expected_value=str(item.evidence.get("expected_value", "")), affected_areas=[item.category] if item.category else [], risk=str(item.risk_score), confidence=item.confidence, evidence={"roadmap_item_id": str(item.id), **item.evidence}, source_roadmap_item=item)
item.converted_extension = candidate
item.status = RoadmapStatus.PLANNING
item.save(update_fields=["converted_extension", "status", "updated_at"])
self.bus.publish("ROADMAP_ITEM_CONVERTED", project=item.project, payload={"roadmap_item_id": str(item.id), "extension_candidate_id": str(candidate.id)})
return candidate
def convert_to_evolution(self, item: RoadmapItem, *, baseline_measurement: dict[str, object], desired_direction: str = "DECREASE") -> EvolutionCandidate:
candidate = EvolutionService(bus=self.bus).create_candidate(item.project, target=item.category or item.title, objective=item.description or item.title, baseline_measurement=baseline_measurement, desired_direction=desired_direction, rationale=item.rationale, source="RoadmapItem", evidence={"roadmap_item_id": str(item.id), **item.evidence}, risk=str(item.risk_score), confidence=item.confidence, source_roadmap_item=item)
item.converted_evolution = candidate
item.status = RoadmapStatus.PLANNING
item.save(update_fields=["converted_evolution", "status", "updated_at"])
self.bus.publish("ROADMAP_ITEM_CONVERTED", project=item.project, payload={"roadmap_item_id": str(item.id), "evolution_candidate_id": str(candidate.id)})
return candidate
def convert_to_investigation(self, item: RoadmapItem):
signal = ProgenySignal.objects.create(project=item.project, source="roadmap", severity="MEDIUM", failure_category=item.category or "ROADMAP_INVESTIGATION", summary=item.description or item.title, evidence={"roadmap_item_id": str(item.id), **item.evidence}, grouping_key=f"roadmap:{item.grouping_key}"[:120])
investigation = ProgenyService(self.bus).create_smart_investigation(signal.grouping_key)
item.converted_investigation = investigation
item.status = RoadmapStatus.PLANNING
item.save(update_fields=["converted_investigation", "status", "updated_at"])
self.bus.publish("ROADMAP_ITEM_CONVERTED", project=item.project, payload={"roadmap_item_id": str(item.id), "investigation_id": str(investigation.id)})
return investigation
def project_roadmap_view(self, project: Project) -> dict[str, object]:
return {horizon: [self._item(item) for item in project.roadmap_items.filter(horizon=horizon).order_by("-composite_score", "-priority", "created_at")] for horizon in RoadmapHorizon.values}
def score(self, raw: dict[str, object]) -> dict[str, float]:
return {key: self._score_value(raw.get(key, 0.5)) for key in ["value", "effort", "risk", "confidence", "strategic_fit", "technical_fit", "urgency"]}
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 _composite(self, item: RoadmapItem) -> float:
return (item.value_score * 0.25) + ((1 - item.effort_score) * 0.12) + ((1 - item.risk_score) * 0.12) + (item.confidence * 0.14) + (item.strategic_fit * 0.14) + (item.technical_fit * 0.11) + (item.urgency * 0.12)
def _grouping_key(self, project: Project, title: str, category: str) -> str:
fingerprint = hashlib.sha256(f"{title.lower()}:{category.lower()}".encode("utf-8")).hexdigest()[:16]
return f"{project.id}:roadmap:{fingerprint}"[:240]
def _find_existing(self, project: Project, grouping_key: str, title: str) -> RoadmapItem | None:
existing = project.roadmap_items.filter(grouping_key=grouping_key).first() or project.roadmap_items.filter(title__iexact=title).first()
if existing:
return existing
if project.exploration_opportunities.filter(title__iexact=title).exists() or ExtensionCandidate.objects.filter(project=project, title__iexact=title).exists() or EvolutionCandidate.objects.filter(project=project, objective__icontains=title[:80]).exists() or StewardFinding.objects.filter(project=project, title__iexact=title).exists():
return project.roadmap_items.filter(title__iexact=title).first()
if Decision.objects.filter(project=project, decision__icontains=title[:80], decision_type__in=["REJECTED", "DEFERRED"]).exists():
return project.roadmap_items.filter(title__iexact=title).first()
return None
def _merge_evidence(self, current: dict[str, object], incoming: dict[str, object]) -> dict[str, object]:
merged = dict(current or {})
for key, value in incoming.items():
if key in merged and merged[key] != value:
merged[key] = [merged[key], value]
else:
merged[key] = value
return merged
def _item(self, item: RoadmapItem) -> dict[str, object]:
return {"id": str(item.id), "title": item.title, "source": item.source, "rationale": item.rationale, "evidence": item.evidence, "scores": {"value": item.value_score, "effort": item.effort_score, "risk": item.risk_score, "confidence": item.confidence, "strategic_fit": item.strategic_fit, "technical_fit": item.technical_fit, "urgency": item.urgency, "composite": item.composite_score}, "status": item.status, "target_action": item.target_action, "dependencies": [str(dep.id) for dep in item.dependencies.all()], "related_items": [str(rel.id) for rel in item.related_items.all()], "conversion_lineage": {"extension_candidate_id": str(item.converted_extension_id) if item.converted_extension_id else None, "evolution_candidate_id": str(item.converted_evolution_id) if item.converted_evolution_id else None, "investigation_id": str(item.converted_investigation_id) if item.converted_investigation_id else None}, "metadata": item.metadata}