Kairo-Jinkendo/backend/steering/proposals/sprint_commit_context.py
Lars 1bbc5dbfce
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 4m8s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s
refactor(steering): Sprint-Vorschlaege mit austauschbarem Ranker und Abhaengigkeiten
Keine feste Bug-zuerst-Policy: heuristic_v0 als Fallback, agent_v1-Slot vorbereitet, parent_action-Abhaengigkeiten und factors[] fuer spaetere KI-Steuerung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:38:22 +02:00

174 lines
5.2 KiB
Python

"""Sprint commit ranking — pluggable rankers (heuristic now, agent later)."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol
from steering.eval_context import SteeringEvalContext
COMMITTABLE_STATUSES = frozenset({"new", "triaged", "accepted"})
PLANABLE_CYCLE_STATUSES = frozenset({"planned", "active"})
OPEN_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"})
_DONE_ACTION = frozenset({"done", "discarded"})
@dataclass
class SprintCommitCandidate:
item: dict[str, Any]
dependency_refs: list[dict[str, Any]] = field(default_factory=list)
dependency_blocked: bool = False
dependency_reason: str | None = None
@dataclass
class RankedSprintCommitCandidate:
candidate: SprintCommitCandidate
rank: int
score: float
reason_code: str
summary: str
factors: list[dict[str, Any]]
ranker_key: str
class SprintCommitRanker(Protocol):
key: str
def rank(
self,
ctx: SteeringEvalContext,
*,
candidates: list[SprintCommitCandidate],
read_models: dict[str, Any],
target_work_cycle_id: str,
) -> list[RankedSprintCommitCandidate]:
...
_RANKERS: dict[str, SprintCommitRanker] = {}
def register_sprint_commit_ranker(ranker: SprintCommitRanker) -> None:
_RANKERS[ranker.key] = ranker
def resolve_sprint_commit_ranker(ctx: SteeringEvalContext) -> SprintCommitRanker:
"""
Ranker-Auswahl — nicht hardcodiert.
Zukünftig: Governance/Method-Metadata (`proposal_rankers.sprint_commit=agent_v1`)
oder Actor-Slot mit auditiertem Agent-Scoring. Default: heuristic_v0 (Fallback).
"""
configured = (
(ctx.operating_context.get("proposal_rankers") or {}).get("sprint_commit")
or (ctx.operating_context.get("method_capabilities") or {}).get(
"sprint_commit_ranker"
)
)
if configured and configured in _RANKERS:
return _RANKERS[configured]
return _RANKERS["heuristic_v0"]
def _action_by_id(actions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {str(a["id"]): a for a in actions if a.get("id")}
def build_sprint_commit_candidates(
ctx: SteeringEvalContext,
*,
target_work_cycle_id: str,
) -> list[SprintCommitCandidate]:
"""Kandidaten + Abhängigkeitskontext — keine feste Bug/Story-Reihenfolge."""
vocabulary = ctx.backlog_vocabulary
convertible = frozenset(
vocabulary.get("convertible_kinds") or ["story", "bug", "issue"]
)
actions_by_id = _action_by_id(ctx.actions)
candidates: list[SprintCommitCandidate] = []
for item in ctx.backlog_items:
if item.get("status") not in COMMITTABLE_STATUSES:
continue
if item.get("item_kind") == "epic":
continue
if (item.get("item_kind") or "story") not in convertible:
continue
refs: list[dict[str, Any]] = []
blocked = False
block_reason: str | None = None
parent_action_id = item.get("parent_action_id")
if parent_action_id:
parent = actions_by_id.get(str(parent_action_id))
refs.append(
{
"ref_type": "parent_action",
"ref_id": str(parent_action_id),
"status": parent.get("status") if parent else "missing",
"title": parent.get("title") if parent else None,
}
)
if not parent:
blocked = True
block_reason = "missing_parent_action"
elif parent.get("status") not in _DONE_ACTION:
in_target_sprint = (
parent.get("work_cycle_id")
and str(parent["work_cycle_id"]) == str(target_work_cycle_id)
)
if not in_target_sprint:
blocked = True
block_reason = "waiting_on_feature_action"
parent_backlog_id = item.get("parent_backlog_id")
if parent_backlog_id:
refs.append(
{
"ref_type": "parent_epic",
"ref_id": str(parent_backlog_id),
}
)
candidates.append(
SprintCommitCandidate(
item=item,
dependency_refs=refs,
dependency_blocked=blocked,
dependency_reason=block_reason,
)
)
return candidates
def resolve_target_work_cycle(ctx: SteeringEvalContext) -> dict[str, Any] | None:
cycles = [
c for c in ctx.work_cycles if c.get("status") in PLANABLE_CYCLE_STATUSES
]
if not cycles:
return None
if ctx.horizon.work_cycle_id:
match = next(
(c for c in cycles if str(c["id"]) == str(ctx.horizon.work_cycle_id)),
None,
)
if match:
return match
planned = sorted(
[c for c in cycles if c.get("status") == "planned"],
key=lambda c: (c.get("sort_order", 0), str(c["id"])),
)
if planned:
return planned[0]
active = sorted(
[c for c in cycles if c.get("status") == "active"],
key=lambda c: (c.get("sort_order", 0), str(c["id"])),
)
return active[0] if active else None