refactor(steering): Sprint-Vorschlaege mit austauschbarem Ranker und Abhaengigkeiten
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
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
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>
This commit is contained in:
parent
20636f88d2
commit
1bbc5dbfce
|
|
@ -52,7 +52,7 @@ def _register_builtin_proposals() -> None:
|
||||||
key="sprint_commit",
|
key="sprint_commit",
|
||||||
requires_elements=frozenset({"work_cycle_scope"}),
|
requires_elements=frozenset({"work_cycle_scope"}),
|
||||||
requires_data_slices=frozenset({"backlog", "work_cycles"}),
|
requires_data_slices=frozenset({"backlog", "work_cycles"}),
|
||||||
compute=lambda ctx, _read_models: propose_sprint_commit(ctx),
|
compute=lambda ctx, read_models: propose_sprint_commit(ctx, read_models=read_models),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Sprint commit proposals — ADP P6 / agile_iteration."""
|
"""Sprint commit proposals — ADP P6 / pluggable ranking."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -6,126 +6,65 @@ from typing import Any
|
||||||
|
|
||||||
from steering.eval_context import SteeringEvalContext
|
from steering.eval_context import SteeringEvalContext
|
||||||
|
|
||||||
COMMITTABLE_STATUSES = frozenset({"new", "triaged", "accepted"})
|
# Ranker registration side effects
|
||||||
PLANABLE_CYCLE_STATUSES = frozenset({"planned", "active"})
|
import steering.proposals.sprint_commit_agent_v1 # noqa: F401
|
||||||
OPEN_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"})
|
import steering.proposals.sprint_commit_heuristic_v0 # noqa: F401
|
||||||
|
from steering.proposals.sprint_commit_context import (
|
||||||
_PRIORITY_RANK = {"critical": 0, "high": 1, "normal": 2, "low": 3}
|
build_sprint_commit_candidates,
|
||||||
_KIND_RANK = {"bug": 0, "issue": 1, "incident": 1, "story": 2, "idea": 3}
|
resolve_sprint_commit_ranker,
|
||||||
|
resolve_target_work_cycle,
|
||||||
_REASON_LABELS = {
|
)
|
||||||
"bug_critical": "Kritischer Bug — Sprint-Priorität",
|
|
||||||
"bug_high_priority": "Bug mit hoher Priorität",
|
|
||||||
"bug_default": "Bug — vor Features priorisieren",
|
|
||||||
"issue_default": "Issue/Störung im Eingang",
|
|
||||||
"story_high_priority": "Story mit hoher Priorität",
|
|
||||||
"story_ready": "Story bereit zum Commit",
|
|
||||||
"idea_default": "Idee im Eingang",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_target_work_cycle(
|
def propose_sprint_commit(
|
||||||
ctx: SteeringEvalContext,
|
ctx: SteeringEvalContext,
|
||||||
) -> dict[str, Any] | None:
|
*,
|
||||||
cycles = [
|
read_models: dict[str, Any] | None = None,
|
||||||
c for c in ctx.work_cycles if c.get("status") in PLANABLE_CYCLE_STATUSES
|
) -> list[dict[str, Any]]:
|
||||||
]
|
"""Ranked backlog items for sprint commit — ranker is swappable, no auto-mutation."""
|
||||||
if not cycles:
|
read_models = read_models or {}
|
||||||
return None
|
target = resolve_target_work_cycle(ctx)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _reason_code(item: dict[str, Any]) -> str:
|
|
||||||
kind = item.get("item_kind") or "story"
|
|
||||||
priority = item.get("priority") or "normal"
|
|
||||||
if kind == "bug":
|
|
||||||
if priority == "critical":
|
|
||||||
return "bug_critical"
|
|
||||||
if priority == "high":
|
|
||||||
return "bug_high_priority"
|
|
||||||
return "bug_default"
|
|
||||||
if kind in ("issue", "incident"):
|
|
||||||
return "issue_default"
|
|
||||||
if kind == "idea":
|
|
||||||
return "idea_default"
|
|
||||||
if priority in ("critical", "high"):
|
|
||||||
return "story_high_priority"
|
|
||||||
return "story_ready"
|
|
||||||
|
|
||||||
|
|
||||||
def _sort_key(item: dict[str, Any]) -> tuple:
|
|
||||||
kind = item.get("item_kind") or "story"
|
|
||||||
priority = item.get("priority") or "normal"
|
|
||||||
return (
|
|
||||||
_KIND_RANK.get(kind, 9),
|
|
||||||
_PRIORITY_RANK.get(priority, 9),
|
|
||||||
item.get("sort_order") if item.get("sort_order") is not None else 9999,
|
|
||||||
(item.get("title") or "").lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def propose_sprint_commit(ctx: SteeringEvalContext) -> list[dict[str, Any]]:
|
|
||||||
"""Ranked backlog items suggested for sprint commit — no auto-mutation."""
|
|
||||||
target = _resolve_target_work_cycle(ctx)
|
|
||||||
if not target:
|
if not target:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
vocabulary = ctx.backlog_vocabulary
|
|
||||||
convertible = frozenset(vocabulary.get("convertible_kinds") or ["story", "bug", "issue"])
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
item
|
|
||||||
for item in ctx.backlog_items
|
|
||||||
if item.get("status") in COMMITTABLE_STATUSES
|
|
||||||
and item.get("item_kind") != "epic"
|
|
||||||
and (item.get("item_kind") or "story") in convertible
|
|
||||||
]
|
|
||||||
if not candidates:
|
|
||||||
return []
|
|
||||||
|
|
||||||
ranked = sorted(candidates, key=_sort_key)
|
|
||||||
cycle_id = str(target["id"])
|
cycle_id = str(target["id"])
|
||||||
cycle_title = target.get("title") or "Sprint"
|
cycle_title = target.get("title") or "Sprint"
|
||||||
|
|
||||||
|
candidates = build_sprint_commit_candidates(ctx, target_work_cycle_id=cycle_id)
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ranker = resolve_sprint_commit_ranker(ctx)
|
||||||
|
ranked = ranker.rank(
|
||||||
|
ctx,
|
||||||
|
candidates=candidates,
|
||||||
|
read_models=read_models,
|
||||||
|
target_work_cycle_id=cycle_id,
|
||||||
|
)
|
||||||
|
|
||||||
proposals: list[dict[str, Any]] = []
|
proposals: list[dict[str, Any]] = []
|
||||||
for rank, item in enumerate(ranked[:20], start=1):
|
for row in ranked:
|
||||||
reason = _reason_code(item)
|
item = row.candidate.item
|
||||||
kind = item.get("item_kind") or "story"
|
|
||||||
proposals.append(
|
proposals.append(
|
||||||
{
|
{
|
||||||
"proposal_key": "sprint_commit",
|
"proposal_key": "sprint_commit",
|
||||||
"scope_type": "backlog_item",
|
"scope_type": "backlog_item",
|
||||||
"scope_id": str(item["id"]),
|
"scope_id": str(item["id"]),
|
||||||
"rank": rank,
|
"rank": row.rank,
|
||||||
"reason_code": reason,
|
"score": row.score,
|
||||||
"summary": _REASON_LABELS.get(reason, "Zum Sprint committen"),
|
"reason_code": row.reason_code,
|
||||||
|
"summary": row.summary,
|
||||||
"title": item.get("title") or "Backlog-Item",
|
"title": item.get("title") or "Backlog-Item",
|
||||||
"item_kind": kind,
|
"item_kind": item.get("item_kind") or "story",
|
||||||
"priority": item.get("priority") or "normal",
|
"priority": item.get("priority") or "normal",
|
||||||
"work_cycle_id": cycle_id,
|
"work_cycle_id": cycle_id,
|
||||||
"work_cycle_title": cycle_title,
|
"work_cycle_title": cycle_title,
|
||||||
"initiative_id": ctx.initiative_id,
|
"initiative_id": ctx.initiative_id,
|
||||||
"confidence": "heuristic",
|
"ranker_key": row.ranker_key,
|
||||||
|
"confidence": "agent" if row.ranker_key.startswith("agent") else "heuristic",
|
||||||
|
"factors": row.factors,
|
||||||
|
"dependency_refs": row.candidate.dependency_refs,
|
||||||
|
"dependency_blocked": row.candidate.dependency_blocked,
|
||||||
"data_source": "steering_kernel",
|
"data_source": "steering_kernel",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -140,6 +79,8 @@ def sprint_commit_attention_items(
|
||||||
actions: list[dict[str, Any]],
|
actions: list[dict[str, Any]],
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Attention when sprint has room for proposed commits."""
|
"""Attention when sprint has room for proposed commits."""
|
||||||
|
from steering.proposals.sprint_commit_context import OPEN_ACTION
|
||||||
|
|
||||||
if not proposals:
|
if not proposals:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -154,18 +95,20 @@ def sprint_commit_attention_items(
|
||||||
]
|
]
|
||||||
open_in_sprint = sum(1 for a in sprint_actions if a.get("status") in OPEN_ACTION)
|
open_in_sprint = sum(1 for a in sprint_actions if a.get("status") in OPEN_ACTION)
|
||||||
|
|
||||||
|
ready = [p for p in proposals if not p.get("dependency_blocked")]
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
cycle_title = proposals[0].get("work_cycle_title") or "Sprint"
|
cycle_title = proposals[0].get("work_cycle_title") or "Sprint"
|
||||||
|
|
||||||
if open_in_sprint == 0:
|
if open_in_sprint == 0 and ready:
|
||||||
|
ranker = proposals[0].get("ranker_key", "heuristic_v0")
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"kind": "sprint_commit_suggested",
|
"kind": "sprint_commit_suggested",
|
||||||
"severity": "info",
|
"severity": "info",
|
||||||
"title": cycle_title,
|
"title": cycle_title,
|
||||||
"summary": (
|
"summary": (
|
||||||
f"{len(proposals)} Item(s) im Eingang — "
|
f"{len(ready)} commit-bereite Item(s) — "
|
||||||
"Kernel schlägt Sprint-Commit vor"
|
f"Vorschlag via Ranker {ranker}"
|
||||||
),
|
),
|
||||||
"scope_type": "work_cycle",
|
"scope_type": "work_cycle",
|
||||||
"scope_id": str(target_id),
|
"scope_id": str(target_id),
|
||||||
|
|
|
||||||
64
backend/steering/proposals/sprint_commit_agent_v1.py
Normal file
64
backend/steering/proposals/sprint_commit_agent_v1.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""Agent sprint ranker slot — placeholder for Principle Gate / Actor execution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from steering.eval_context import SteeringEvalContext
|
||||||
|
from steering.proposals.sprint_commit_context import (
|
||||||
|
RankedSprintCommitCandidate,
|
||||||
|
SprintCommitCandidate,
|
||||||
|
register_sprint_commit_ranker,
|
||||||
|
)
|
||||||
|
from steering.proposals.sprint_commit_heuristic_v0 import HeuristicV0SprintCommitRanker
|
||||||
|
|
||||||
|
_FALLBACK = HeuristicV0SprintCommitRanker()
|
||||||
|
|
||||||
|
|
||||||
|
class AgentV1SprintCommitRanker:
|
||||||
|
"""
|
||||||
|
Agent-gestütztes Ranking — noch nicht aktiv (Principle Gate).
|
||||||
|
|
||||||
|
Kontext kommt aus Snapshot/Operating Context; Scoring über auditierten Actor,
|
||||||
|
nicht aus hardcodiertem Prompt. Bis Freigabe: Fallback auf heuristic_v0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = "agent_v1"
|
||||||
|
|
||||||
|
def rank(
|
||||||
|
self,
|
||||||
|
ctx: SteeringEvalContext,
|
||||||
|
*,
|
||||||
|
candidates: list[SprintCommitCandidate],
|
||||||
|
read_models: dict[str, Any],
|
||||||
|
target_work_cycle_id: str,
|
||||||
|
) -> list[RankedSprintCommitCandidate]:
|
||||||
|
# TODO(P6+): Actor-Slot + Governance-Pack; KI wählt Subset + Begründung
|
||||||
|
ranked = _FALLBACK.rank(
|
||||||
|
ctx,
|
||||||
|
candidates=candidates,
|
||||||
|
read_models=read_models,
|
||||||
|
target_work_cycle_id=target_work_cycle_id,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
RankedSprintCommitCandidate(
|
||||||
|
candidate=row.candidate,
|
||||||
|
rank=row.rank,
|
||||||
|
score=row.score,
|
||||||
|
reason_code=row.reason_code,
|
||||||
|
summary=f"{row.summary} (Agent-Ranker: Fallback heuristic_v0)",
|
||||||
|
factors=row.factors
|
||||||
|
+ [
|
||||||
|
{
|
||||||
|
"code": "ranker_fallback",
|
||||||
|
"weight": 0,
|
||||||
|
"label": "agent_v1 noch nicht freigegeben",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ranker_key="agent_v1_fallback",
|
||||||
|
)
|
||||||
|
for row in ranked
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
register_sprint_commit_ranker(AgentV1SprintCommitRanker())
|
||||||
173
backend/steering/proposals/sprint_commit_context.py
Normal file
173
backend/steering/proposals/sprint_commit_context.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""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
|
||||||
157
backend/steering/proposals/sprint_commit_heuristic_v0.py
Normal file
157
backend/steering/proposals/sprint_commit_heuristic_v0.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""Default sprint ranker — explicit fallback, not product policy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from steering.eval_context import SteeringEvalContext
|
||||||
|
from steering.proposals.sprint_commit_context import (
|
||||||
|
RankedSprintCommitCandidate,
|
||||||
|
SprintCommitCandidate,
|
||||||
|
SprintCommitRanker,
|
||||||
|
register_sprint_commit_ranker,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PRIORITY_SCORE = {"critical": 100, "high": 75, "normal": 50, "low": 25}
|
||||||
|
_KIND_BIAS = {"bug": 5, "issue": 3, "incident": 3, "story": 0, "idea": -5}
|
||||||
|
|
||||||
|
|
||||||
|
class HeuristicV0SprintCommitRanker:
|
||||||
|
"""
|
||||||
|
Fallback-Ranker bis Agent/Regelwerk aktiv ist.
|
||||||
|
|
||||||
|
Sortiert primär nach Priorität + Eingangsreihenfolge; item_kind ist nur leichte
|
||||||
|
Tendenz in `factors` — keine „Bugs immer zuerst“-Policy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = "heuristic_v0"
|
||||||
|
|
||||||
|
def rank(
|
||||||
|
self,
|
||||||
|
ctx: SteeringEvalContext,
|
||||||
|
*,
|
||||||
|
candidates: list[SprintCommitCandidate],
|
||||||
|
read_models: dict[str, Any],
|
||||||
|
target_work_cycle_id: str,
|
||||||
|
) -> list[RankedSprintCommitCandidate]:
|
||||||
|
scored: list[tuple[float, SprintCommitCandidate, list[dict[str, Any]]]] = []
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
item = candidate.item
|
||||||
|
priority = item.get("priority") or "normal"
|
||||||
|
kind = item.get("item_kind") or "story"
|
||||||
|
priority_score = _PRIORITY_SCORE.get(priority, 50)
|
||||||
|
kind_bias = _KIND_BIAS.get(kind, 0)
|
||||||
|
|
||||||
|
factors: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"code": "priority",
|
||||||
|
"weight": priority_score,
|
||||||
|
"label": f"Priorität {priority}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "intake_order",
|
||||||
|
"weight": -(item.get("sort_order") or 0),
|
||||||
|
"label": "Eingangsreihenfolge",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if kind_bias:
|
||||||
|
factors.append(
|
||||||
|
{
|
||||||
|
"code": "kind_bias",
|
||||||
|
"weight": kind_bias,
|
||||||
|
"label": f"Typ {kind} (leichte Tendenz, keine Policy)",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
score = priority_score + kind_bias - (item.get("sort_order") or 0) * 0.01
|
||||||
|
|
||||||
|
if candidate.dependency_blocked:
|
||||||
|
score -= 1000
|
||||||
|
factors.append(
|
||||||
|
{
|
||||||
|
"code": "dependency_blocked",
|
||||||
|
"weight": -1000,
|
||||||
|
"label": candidate.dependency_reason or "Abhängigkeit offen",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
epic_rollups = read_models.get("epic_rollup") or []
|
||||||
|
parent_epic = item.get("parent_backlog_id")
|
||||||
|
if parent_epic:
|
||||||
|
rollup = next(
|
||||||
|
(
|
||||||
|
r
|
||||||
|
for r in epic_rollups
|
||||||
|
if str(r.get("epic_backlog_id")) == str(parent_epic)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if rollup and rollup.get("status") == "in_progress":
|
||||||
|
score += 2
|
||||||
|
factors.append(
|
||||||
|
{
|
||||||
|
"code": "epic_in_progress",
|
||||||
|
"weight": 2,
|
||||||
|
"label": "Epic mit laufender Arbeit",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
scored.append((score, candidate, factors))
|
||||||
|
|
||||||
|
scored.sort(
|
||||||
|
key=lambda row: (
|
||||||
|
-row[0],
|
||||||
|
(row[1].item.get("title") or "").lower(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked: list[RankedSprintCommitCandidate] = []
|
||||||
|
for index, (score, candidate, factors) in enumerate(scored[:20], start=1):
|
||||||
|
item = candidate.item
|
||||||
|
reason_code = _reason_code(item, candidate)
|
||||||
|
ranked.append(
|
||||||
|
RankedSprintCommitCandidate(
|
||||||
|
candidate=candidate,
|
||||||
|
rank=index,
|
||||||
|
score=score,
|
||||||
|
reason_code=reason_code,
|
||||||
|
summary=_summary_for(reason_code, candidate),
|
||||||
|
factors=factors,
|
||||||
|
ranker_key=self.key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ranked
|
||||||
|
|
||||||
|
|
||||||
|
def _reason_code(item: dict[str, Any], candidate: SprintCommitCandidate) -> str:
|
||||||
|
if candidate.dependency_blocked:
|
||||||
|
return candidate.dependency_reason or "dependency_blocked"
|
||||||
|
priority = item.get("priority") or "normal"
|
||||||
|
if priority == "critical":
|
||||||
|
return "priority_critical"
|
||||||
|
if priority == "high":
|
||||||
|
return "priority_high"
|
||||||
|
kind = item.get("item_kind") or "story"
|
||||||
|
if kind in ("bug", "issue", "incident"):
|
||||||
|
return "candidate_bug_or_issue"
|
||||||
|
return "candidate_ready"
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_for(reason_code: str, candidate: SprintCommitCandidate) -> str:
|
||||||
|
if candidate.dependency_blocked:
|
||||||
|
if reason_code == "waiting_on_feature_action":
|
||||||
|
return "Wartet auf Feature-Arbeitspaket im Sprint oder erledigt"
|
||||||
|
if reason_code == "missing_parent_action":
|
||||||
|
return "Feature-Referenz fehlt — Abhängigkeit klären"
|
||||||
|
return "Abhängigkeit noch nicht erfüllt"
|
||||||
|
labels = {
|
||||||
|
"priority_critical": "Hohe Dringlichkeit (Priorität critical)",
|
||||||
|
"priority_high": "Erhöhte Priorität",
|
||||||
|
"candidate_bug_or_issue": "Kandidat (Heuristik — kein Bug-Zwang)",
|
||||||
|
"candidate_ready": "Bereit zum Commit (Heuristik)",
|
||||||
|
}
|
||||||
|
return labels.get(reason_code, "Sprint-Commit-Kandidat (Heuristik)")
|
||||||
|
|
||||||
|
|
||||||
|
register_sprint_commit_ranker(HeuristicV0SprintCommitRanker())
|
||||||
|
|
@ -44,9 +44,9 @@ def test_steering_snapshot_includes_sprint_commit_proposals(client):
|
||||||
assert body["steering_kernel"]["data_source"] == "steering_kernel_v0.3"
|
assert body["steering_kernel"]["data_source"] == "steering_kernel_v0.3"
|
||||||
proposals = body.get("sprint_commit_proposals") or []
|
proposals = body.get("sprint_commit_proposals") or []
|
||||||
assert len(proposals) >= 2
|
assert len(proposals) >= 2
|
||||||
assert proposals[0]["item_kind"] == "bug"
|
|
||||||
assert proposals[0]["scope_type"] == "backlog_item"
|
|
||||||
assert proposals[0]["proposal_key"] == "sprint_commit"
|
assert proposals[0]["proposal_key"] == "sprint_commit"
|
||||||
|
assert proposals[0].get("ranker_key") == "heuristic_v0"
|
||||||
|
assert "factors" in proposals[0]
|
||||||
|
|
||||||
attention_codes = [
|
attention_codes = [
|
||||||
item.get("code") or item.get("reason_code")
|
item.get("code") or item.get("reason_code")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Unit tests — sprint commit proposal (P6)."""
|
"""Unit tests — sprint commit proposal rankers (P6)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -12,6 +12,7 @@ def _ctx(
|
||||||
*,
|
*,
|
||||||
backlog_items: list,
|
backlog_items: list,
|
||||||
work_cycles: list,
|
work_cycles: list,
|
||||||
|
actions: list | None = None,
|
||||||
vocabulary: dict | None = None,
|
vocabulary: dict | None = None,
|
||||||
) -> SteeringEvalContext:
|
) -> SteeringEvalContext:
|
||||||
binding = SteeringBinding(
|
binding = SteeringBinding(
|
||||||
|
|
@ -55,23 +56,50 @@ def _ctx(
|
||||||
)
|
)
|
||||||
eval_ctx._backlog_items = backlog_items
|
eval_ctx._backlog_items = backlog_items
|
||||||
eval_ctx._work_cycles = work_cycles
|
eval_ctx._work_cycles = work_cycles
|
||||||
|
eval_ctx._actions = actions or []
|
||||||
return eval_ctx
|
return eval_ctx
|
||||||
|
|
||||||
|
|
||||||
def test_sprint_commit_ranks_bugs_before_stories():
|
def test_critical_story_can_rank_before_normal_bug():
|
||||||
proposals = propose_sprint_commit(
|
proposals = propose_sprint_commit(
|
||||||
_ctx(
|
_ctx(
|
||||||
work_cycles=[{"id": "wc-1", "title": "Sprint 1", "status": "planned", "sort_order": 0}],
|
work_cycles=[{"id": "wc-1", "title": "Sprint 1", "status": "planned", "sort_order": 0}],
|
||||||
backlog_items=[
|
backlog_items=[
|
||||||
{"id": "s1", "title": "Story", "item_kind": "story", "status": "accepted", "priority": "normal", "sort_order": 0},
|
{"id": "s1", "title": "Critical story", "item_kind": "story", "status": "accepted", "priority": "critical", "sort_order": 1},
|
||||||
{"id": "b1", "title": "Bug", "item_kind": "bug", "status": "accepted", "priority": "normal", "sort_order": 1},
|
{"id": "b1", "title": "Minor bug", "item_kind": "bug", "status": "accepted", "priority": "normal", "sort_order": 0},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert len(proposals) == 2
|
assert proposals[0]["scope_id"] == "s1"
|
||||||
assert proposals[0]["scope_id"] == "b1"
|
assert proposals[0]["ranker_key"] == "heuristic_v0"
|
||||||
assert proposals[0]["reason_code"] == "bug_default"
|
assert any(f["code"] == "priority" for f in proposals[0]["factors"])
|
||||||
assert proposals[0]["work_cycle_id"] == "wc-1"
|
|
||||||
|
|
||||||
|
def test_dependency_blocked_bug_ranks_after_ready_story():
|
||||||
|
proposals = propose_sprint_commit(
|
||||||
|
_ctx(
|
||||||
|
work_cycles=[{"id": "wc-1", "title": "Sprint 1", "status": "planned", "sort_order": 0}],
|
||||||
|
backlog_items=[
|
||||||
|
{"id": "s1", "title": "Ready story", "item_kind": "story", "status": "accepted", "priority": "normal", "sort_order": 1},
|
||||||
|
{
|
||||||
|
"id": "b1",
|
||||||
|
"title": "Blocked bug",
|
||||||
|
"item_kind": "bug",
|
||||||
|
"status": "accepted",
|
||||||
|
"priority": "critical",
|
||||||
|
"sort_order": 0,
|
||||||
|
"parent_action_id": "a-feature",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions=[
|
||||||
|
{"id": "a-feature", "title": "Feature", "status": "in_progress", "work_cycle_id": None},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert proposals[0]["scope_id"] == "s1"
|
||||||
|
blocked = next(p for p in proposals if p["scope_id"] == "b1")
|
||||||
|
assert blocked["dependency_blocked"] is True
|
||||||
|
assert blocked["reason_code"] == "waiting_on_feature_action"
|
||||||
|
|
||||||
|
|
||||||
def test_sprint_commit_empty_without_planable_cycle():
|
def test_sprint_commit_empty_without_planable_cycle():
|
||||||
|
|
|
||||||
|
|
@ -49,15 +49,22 @@ Kein Phase-Sprung. **KI/Prompt/MCP** für P8 erst nach Principle Gate und stabil
|
||||||
|
|
||||||
## 4. P6 — Sprint-Vorschlag (Skizze)
|
## 4. P6 — Sprint-Vorschlag (Skizze)
|
||||||
|
|
||||||
**Read Model** `sprint_planning_proposal` (kein Pflicht-Commit):
|
**Read Model** `sprint_commit` (kein Pflicht-Commit):
|
||||||
|
|
||||||
- Input: offene Backlog-Items (Epic-Subbaum optional), aktiver/geplanter Sprint, Action-Ist, Blocker
|
- Input: committable Backlog-Items, Sprint-Horizont, Action-Ist, **Abhängigkeiten** (`parent_action_id`, Epic-Roll-up)
|
||||||
- Output: ranked Liste `{ backlog_item_id, reason_code, score_hint }`
|
- Output: ranked Liste mit `ranker_key`, `factors[]`, `dependency_refs`, `dependency_blocked`
|
||||||
- Attention wenn: Sprint startet in N Tagen und Commit leer; Bugs ohne Owner; Epic intake_only trotz aktivem Sprint
|
- **Keine feste Bug-Policy** — Default-Ranker `heuristic_v0` ist expliziter Fallback; Produktentscheidung später via `agent_v1` (Actor/KI, Principle Gate)
|
||||||
|
|
||||||
**UI:** Plan → Sprint — „Vorgeschlagene Items“ (Accept/Adjust), nicht Auto-Commit.
|
**Ranker-Auswahl** (Operating Context / Governance, nicht Page-If):
|
||||||
|
|
||||||
**Steuerung:** `evaluate_next_work` / eigene Strategy-Hook — **keine** Page-Ifs.
|
| Ranker | Status | Verhalten |
|
||||||
|
|--------|--------|-----------|
|
||||||
|
| `heuristic_v0` | ✓ Fallback | Priorität + Eingang + leichte kind_bias + Abhängigkeiten |
|
||||||
|
| `agent_v1` | Stub | Agent-Slot — wählt Subset/Begründung; bis Freigabe Fallback |
|
||||||
|
|
||||||
|
**UI:** Plan → Sprint — Vorschläge Accept/Adjust; blockierte Abhängigkeiten nicht committierbar.
|
||||||
|
|
||||||
|
**Steuerung:** `ProposalProvider` + austauschbarer Ranker — **nicht** `evaluate_next_work`, **keine** hardcodierte Sortierung in UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,14 +138,20 @@ class ProposalProvider:
|
||||||
"scope_type": "backlog_item" | "action" | "roadmap_item" | "review",
|
"scope_type": "backlog_item" | "action" | "roadmap_item" | "review",
|
||||||
"scope_id": "...",
|
"scope_id": "...",
|
||||||
"rank": 1,
|
"rank": 1,
|
||||||
"reason_code": "bug_in_active_sprint",
|
"score": 127.5,
|
||||||
"summary": "Bug X — hohe Priorität, Sprint aktiv",
|
"reason_code": "priority_critical",
|
||||||
"confidence": "heuristic" | "rule" | "agent", # später
|
"summary": "...",
|
||||||
|
"ranker_key": "heuristic_v0" | "agent_v1",
|
||||||
|
"confidence": "heuristic" | "agent",
|
||||||
|
"factors": [{"code": "priority", "weight": 100, "label": "..."}],
|
||||||
|
"dependency_refs": [{"ref_type": "parent_action", "ref_id": "...", "status": "..."}],
|
||||||
|
"dependency_blocked": false,
|
||||||
"data_source": "steering_kernel",
|
"data_source": "steering_kernel",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- UI: Accept / Adjust / Ignore — Mensch oder Agent bestätigt
|
- UI: Accept / Adjust / Ignore — Mensch oder Agent bestätigt
|
||||||
|
- **Kein** festes „Bugs zuerst“ — Ranker austauschbar via `proposal_rankers.sprint_commit` im Operating Context / Governance
|
||||||
- **Agile P6:** `sprint_commit` ProposalProvider
|
- **Agile P6:** `sprint_commit` ProposalProvider
|
||||||
- **Gate-Methoden:** `gate_next_actions` (planning_debt → konkrete AP-Vorschläge)
|
- **Gate-Methoden:** `gate_next_actions` (planning_debt → konkrete AP-Vorschläge)
|
||||||
- **Continuous Product:** `intake_triage` (Eingang priorisieren)
|
- **Continuous Product:** `intake_triage` (Eingang priorisieren)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ export function SprintCommitProposalPanel({
|
||||||
<div>
|
<div>
|
||||||
<h2>Sprint-Vorschläge</h2>
|
<h2>Sprint-Vorschläge</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Vom Steuerungskern priorisiert — Accept committet ins Sprint-Backlog (kein Auto-Commit).
|
Vom Steuerungskern priorisiert (Ranker:{' '}
|
||||||
|
{filtered[0]?.ranker_key || 'heuristic_v0'}) — Accept committet ins Sprint-Backlog.
|
||||||
|
Keine feste Bug-Policy; später Agent/KI wählbar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -35,6 +37,11 @@ export function SprintCommitProposalPanel({
|
||||||
{proposal.rank}. {proposal.title}
|
{proposal.rank}. {proposal.title}
|
||||||
</strong>
|
</strong>
|
||||||
<p className="list-item-sub muted">{proposalReasonLabel(proposal)}</p>
|
<p className="list-item-sub muted">{proposalReasonLabel(proposal)}</p>
|
||||||
|
{proposal.dependency_blocked && (
|
||||||
|
<p className="list-item-sub muted sprint-commit-proposal__blocked">
|
||||||
|
Abhängigkeit offen — wartet auf Feature oder Epic-Kontext
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta action-controls">
|
<div className="list-item-meta action-controls">
|
||||||
{proposal.item_kind && (
|
{proposal.item_kind && (
|
||||||
|
|
@ -47,7 +54,7 @@ export function SprintCommitProposalPanel({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
disabled={busy}
|
disabled={busy || proposal.dependency_blocked}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onAcceptProposal?.(proposal.scope_id, {
|
onAcceptProposal?.(proposal.scope_id, {
|
||||||
work_cycle_id: proposal.work_cycle_id,
|
work_cycle_id: proposal.work_cycle_id,
|
||||||
|
|
|
||||||
|
|
@ -1716,6 +1716,10 @@
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sprint-commit-proposal__blocked {
|
||||||
|
color: var(--jk-warning-text, #7a4b08);
|
||||||
|
}
|
||||||
|
|
||||||
.archetype-badge {
|
.archetype-badge {
|
||||||
background: var(--jk-surface-muted, #eef2f6);
|
background: var(--jk-surface-muted, #eef2f6);
|
||||||
color: var(--jk-text-secondary, #445);
|
color: var(--jk-text-secondary, #445);
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,13 @@ export function sprintProposalsForCycle(proposals = [], workCycleId = '') {
|
||||||
|
|
||||||
export function proposalReasonLabel(proposal) {
|
export function proposalReasonLabel(proposal) {
|
||||||
const labels = {
|
const labels = {
|
||||||
bug_critical: 'Kritischer Bug',
|
priority_critical: 'Dringlichkeit critical',
|
||||||
bug_high_priority: 'Bug (hoch)',
|
priority_high: 'Hohe Priorität',
|
||||||
bug_default: 'Bug',
|
candidate_bug_or_issue: 'Kandidat (Heuristik)',
|
||||||
issue_default: 'Issue',
|
candidate_ready: 'Commit-bereit (Heuristik)',
|
||||||
story_high_priority: 'Story (hoch)',
|
waiting_on_feature_action: 'Wartet auf Feature-AP',
|
||||||
story_ready: 'Story bereit',
|
missing_parent_action: 'Feature-Referenz fehlt',
|
||||||
idea_default: 'Idee',
|
dependency_blocked: 'Abhängigkeit offen',
|
||||||
}
|
}
|
||||||
return labels[proposal?.reason_code] || proposal?.summary || 'Vorschlag'
|
return labels[proposal?.reason_code] || proposal?.summary || 'Vorschlag'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ describe('sprintCommitProposal', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('labels reason codes', () => {
|
it('labels reason codes', () => {
|
||||||
expect(proposalReasonLabel({ reason_code: 'bug_critical' })).toBe('Kritischer Bug')
|
expect(proposalReasonLabel({ reason_code: 'priority_critical' })).toBe('Dringlichkeit critical')
|
||||||
|
expect(proposalReasonLabel({ reason_code: 'waiting_on_feature_action' })).toContain('Feature')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user