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",
|
||||
requires_elements=frozenset({"work_cycle_scope"}),
|
||||
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
|
||||
|
||||
|
|
@ -6,126 +6,65 @@ from typing import Any
|
|||
|
||||
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"})
|
||||
|
||||
_PRIORITY_RANK = {"critical": 0, "high": 1, "normal": 2, "low": 3}
|
||||
_KIND_RANK = {"bug": 0, "issue": 1, "incident": 1, "story": 2, "idea": 3}
|
||||
|
||||
_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",
|
||||
}
|
||||
# Ranker registration side effects
|
||||
import steering.proposals.sprint_commit_agent_v1 # noqa: F401
|
||||
import steering.proposals.sprint_commit_heuristic_v0 # noqa: F401
|
||||
from steering.proposals.sprint_commit_context import (
|
||||
build_sprint_commit_candidates,
|
||||
resolve_sprint_commit_ranker,
|
||||
resolve_target_work_cycle,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_target_work_cycle(
|
||||
def propose_sprint_commit(
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
*,
|
||||
read_models: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Ranked backlog items for sprint commit — ranker is swappable, no auto-mutation."""
|
||||
read_models = read_models or {}
|
||||
target = resolve_target_work_cycle(ctx)
|
||||
if not target:
|
||||
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_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]] = []
|
||||
for rank, item in enumerate(ranked[:20], start=1):
|
||||
reason = _reason_code(item)
|
||||
kind = item.get("item_kind") or "story"
|
||||
for row in ranked:
|
||||
item = row.candidate.item
|
||||
proposals.append(
|
||||
{
|
||||
"proposal_key": "sprint_commit",
|
||||
"scope_type": "backlog_item",
|
||||
"scope_id": str(item["id"]),
|
||||
"rank": rank,
|
||||
"reason_code": reason,
|
||||
"summary": _REASON_LABELS.get(reason, "Zum Sprint committen"),
|
||||
"rank": row.rank,
|
||||
"score": row.score,
|
||||
"reason_code": row.reason_code,
|
||||
"summary": row.summary,
|
||||
"title": item.get("title") or "Backlog-Item",
|
||||
"item_kind": kind,
|
||||
"item_kind": item.get("item_kind") or "story",
|
||||
"priority": item.get("priority") or "normal",
|
||||
"work_cycle_id": cycle_id,
|
||||
"work_cycle_title": cycle_title,
|
||||
"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",
|
||||
}
|
||||
)
|
||||
|
|
@ -140,6 +79,8 @@ def sprint_commit_attention_items(
|
|||
actions: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Attention when sprint has room for proposed commits."""
|
||||
from steering.proposals.sprint_commit_context import OPEN_ACTION
|
||||
|
||||
if not proposals:
|
||||
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)
|
||||
|
||||
ready = [p for p in proposals if not p.get("dependency_blocked")]
|
||||
items: list[dict[str, Any]] = []
|
||||
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(
|
||||
{
|
||||
"kind": "sprint_commit_suggested",
|
||||
"severity": "info",
|
||||
"title": cycle_title,
|
||||
"summary": (
|
||||
f"{len(proposals)} Item(s) im Eingang — "
|
||||
"Kernel schlägt Sprint-Commit vor"
|
||||
f"{len(ready)} commit-bereite Item(s) — "
|
||||
f"Vorschlag via Ranker {ranker}"
|
||||
),
|
||||
"scope_type": "work_cycle",
|
||||
"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"
|
||||
proposals = body.get("sprint_commit_proposals") or []
|
||||
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].get("ranker_key") == "heuristic_v0"
|
||||
assert "factors" in proposals[0]
|
||||
|
||||
attention_codes = [
|
||||
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
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ def _ctx(
|
|||
*,
|
||||
backlog_items: list,
|
||||
work_cycles: list,
|
||||
actions: list | None = None,
|
||||
vocabulary: dict | None = None,
|
||||
) -> SteeringEvalContext:
|
||||
binding = SteeringBinding(
|
||||
|
|
@ -55,23 +56,50 @@ def _ctx(
|
|||
)
|
||||
eval_ctx._backlog_items = backlog_items
|
||||
eval_ctx._work_cycles = work_cycles
|
||||
eval_ctx._actions = actions or []
|
||||
return eval_ctx
|
||||
|
||||
|
||||
def test_sprint_commit_ranks_bugs_before_stories():
|
||||
def test_critical_story_can_rank_before_normal_bug():
|
||||
proposals = propose_sprint_commit(
|
||||
_ctx(
|
||||
work_cycles=[{"id": "wc-1", "title": "Sprint 1", "status": "planned", "sort_order": 0}],
|
||||
backlog_items=[
|
||||
{"id": "s1", "title": "Story", "item_kind": "story", "status": "accepted", "priority": "normal", "sort_order": 0},
|
||||
{"id": "b1", "title": "Bug", "item_kind": "bug", "status": "accepted", "priority": "normal", "sort_order": 1},
|
||||
{"id": "s1", "title": "Critical story", "item_kind": "story", "status": "accepted", "priority": "critical", "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"] == "b1"
|
||||
assert proposals[0]["reason_code"] == "bug_default"
|
||||
assert proposals[0]["work_cycle_id"] == "wc-1"
|
||||
assert proposals[0]["scope_id"] == "s1"
|
||||
assert proposals[0]["ranker_key"] == "heuristic_v0"
|
||||
assert any(f["code"] == "priority" for f in proposals[0]["factors"])
|
||||
|
||||
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -49,15 +49,22 @@ Kein Phase-Sprung. **KI/Prompt/MCP** für P8 erst nach Principle Gate und stabil
|
|||
|
||||
## 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
|
||||
- Output: ranked Liste `{ backlog_item_id, reason_code, score_hint }`
|
||||
- Attention wenn: Sprint startet in N Tagen und Commit leer; Bugs ohne Owner; Epic intake_only trotz aktivem Sprint
|
||||
- Input: committable Backlog-Items, Sprint-Horizont, Action-Ist, **Abhängigkeiten** (`parent_action_id`, Epic-Roll-up)
|
||||
- Output: ranked Liste mit `ranker_key`, `factors[]`, `dependency_refs`, `dependency_blocked`
|
||||
- **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_id": "...",
|
||||
"rank": 1,
|
||||
"reason_code": "bug_in_active_sprint",
|
||||
"summary": "Bug X — hohe Priorität, Sprint aktiv",
|
||||
"confidence": "heuristic" | "rule" | "agent", # später
|
||||
"score": 127.5,
|
||||
"reason_code": "priority_critical",
|
||||
"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",
|
||||
}
|
||||
```
|
||||
|
||||
- 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
|
||||
- **Gate-Methoden:** `gate_next_actions` (planning_debt → konkrete AP-Vorschläge)
|
||||
- **Continuous Product:** `intake_triage` (Eingang priorisieren)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ export function SprintCommitProposalPanel({
|
|||
<div>
|
||||
<h2>Sprint-Vorschläge</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -35,6 +37,11 @@ export function SprintCommitProposalPanel({
|
|||
{proposal.rank}. {proposal.title}
|
||||
</strong>
|
||||
<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 className="list-item-meta action-controls">
|
||||
{proposal.item_kind && (
|
||||
|
|
@ -47,7 +54,7 @@ export function SprintCommitProposalPanel({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy}
|
||||
disabled={busy || proposal.dependency_blocked}
|
||||
onClick={() =>
|
||||
onAcceptProposal?.(proposal.scope_id, {
|
||||
work_cycle_id: proposal.work_cycle_id,
|
||||
|
|
|
|||
|
|
@ -1716,6 +1716,10 @@
|
|||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sprint-commit-proposal__blocked {
|
||||
color: var(--jk-warning-text, #7a4b08);
|
||||
}
|
||||
|
||||
.archetype-badge {
|
||||
background: var(--jk-surface-muted, #eef2f6);
|
||||
color: var(--jk-text-secondary, #445);
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ export function sprintProposalsForCycle(proposals = [], workCycleId = '') {
|
|||
|
||||
export function proposalReasonLabel(proposal) {
|
||||
const labels = {
|
||||
bug_critical: 'Kritischer Bug',
|
||||
bug_high_priority: 'Bug (hoch)',
|
||||
bug_default: 'Bug',
|
||||
issue_default: 'Issue',
|
||||
story_high_priority: 'Story (hoch)',
|
||||
story_ready: 'Story bereit',
|
||||
idea_default: 'Idee',
|
||||
priority_critical: 'Dringlichkeit critical',
|
||||
priority_high: 'Hohe Priorität',
|
||||
candidate_bug_or_issue: 'Kandidat (Heuristik)',
|
||||
candidate_ready: 'Commit-bereit (Heuristik)',
|
||||
waiting_on_feature_action: 'Wartet auf Feature-AP',
|
||||
missing_parent_action: 'Feature-Referenz fehlt',
|
||||
dependency_blocked: 'Abhängigkeit offen',
|
||||
}
|
||||
return labels[proposal?.reason_code] || proposal?.summary || 'Vorschlag'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ describe('sprintCommitProposal', () => {
|
|||
})
|
||||
|
||||
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