Kairo-Jinkendo/backend/steering/read_models/epic_rollup.py
Lars ef5ee0d19b
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
feat(steering): Epic Roll-up Read Model und Attention (ADP P4)
Schliesst den Backlog/Epic-Track mit kernel-basiertem Fortschritt pro Epic, Steuerungs-Attention und UI-Fortschrittsanzeige im Plan-Eingang ab.

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

171 lines
5.9 KiB
Python

"""Epic roll-up read model — ADP P4 / Backlog-Hierarchie.
Fortschritt eines Epics = Aggregation committeter/erledigter Stories im Subbaum.
Kein Gate-Status; Leading bleibt Action.
"""
from __future__ import annotations
from typing import Any, Literal
EpicRollupStatus = Literal["empty", "intake_only", "in_progress", "done"]
_DONE_ACTION = frozenset({"done"})
_ACTIVE_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"})
_CHILD_EXCLUDE = frozenset({"rejected"})
def _action_status(actions_by_id: dict[str, dict[str, Any]], action_id: Any) -> str | None:
if not action_id:
return None
row = actions_by_id.get(str(action_id))
return row.get("status") if row else None
def compute_epic_rollup(
*,
backlog_items: list[dict[str, Any]],
actions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Roll-up pro Epic-BacklogItem (item_kind=epic, nicht rejected)."""
actions_by_id = {str(a["id"]): a for a in actions if a.get("id")}
epics = [
b
for b in backlog_items
if b.get("item_kind") == "epic" and b.get("status") not in _CHILD_EXCLUDE
]
rollups: list[dict[str, Any]] = []
for epic in epics:
epic_id = str(epic["id"])
children = [
b
for b in backlog_items
if b.get("parent_backlog_id")
and str(b["parent_backlog_id"]) == epic_id
and b.get("item_kind") != "epic"
and b.get("status") not in _CHILD_EXCLUDE
]
child_total = len(children)
committed: list[dict[str, Any]] = []
uncommitted: list[dict[str, Any]] = []
for child in children:
if child.get("status") == "converted" or child.get("converted_action_id"):
committed.append(child)
else:
uncommitted.append(child)
actions_done = actions_in_progress = actions_open = 0
for child in committed:
status = _action_status(actions_by_id, child.get("converted_action_id"))
if status in _DONE_ACTION:
actions_done += 1
elif status in _ACTIVE_ACTION:
actions_in_progress += 1
actions_open += 1
elif status:
actions_open += 1
if child_total == 0:
rollup_status: EpicRollupStatus = "empty"
elif len(committed) == 0:
rollup_status = "intake_only"
elif actions_done == child_total:
rollup_status = "done"
else:
rollup_status = "in_progress"
completion_ratio = actions_done / child_total if child_total else None
commit_ratio = len(committed) / child_total if child_total else None
rollups.append(
{
"epic_backlog_id": epic_id,
"epic_title": epic.get("title") or "Epic",
"child_total": child_total,
"child_uncommitted": len(uncommitted),
"child_committed": len(committed),
"actions_done": actions_done,
"actions_in_progress": actions_in_progress,
"actions_open": actions_open,
"completion_ratio": completion_ratio,
"commit_ratio": commit_ratio,
"status": rollup_status,
"data_source": "epic_rollup_v0.1",
}
)
rollups.sort(key=lambda r: (r["status"] != "in_progress", r["epic_title"].lower()))
return rollups
def epic_rollup_attention_items(
*,
initiative_id: str,
rollups: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Attention aus Epic-Roll-up — Steuerung, nicht nur Anzeige."""
items: list[dict[str, Any]] = []
for rollup in rollups:
epic_id = rollup["epic_backlog_id"]
title = rollup["epic_title"]
child_total = rollup["child_total"]
if child_total == 0:
continue
if rollup["child_committed"] == 0:
items.append(
{
"kind": "epic_awaiting_commit",
"severity": "info",
"title": title,
"summary": (
f"Epic hat {rollup['child_uncommitted']} Item(s) im Eingang — "
"noch keine committete Arbeit"
),
"scope_type": "backlog",
"scope_id": epic_id,
"initiative_id": initiative_id,
"reason_code": "epic_awaiting_commit",
"data_source": "epic_rollup",
}
)
continue
if rollup["child_uncommitted"] > 0 and rollup["actions_in_progress"] > 0:
items.append(
{
"kind": "epic_uncommitted_during_execution",
"severity": "warning",
"title": title,
"summary": (
f"{rollup['child_uncommitted']} Item(s) noch im Eingang, "
f"während {rollup['actions_in_progress']} Arbeitspaket(e) laufen"
),
"scope_type": "backlog",
"scope_id": epic_id,
"initiative_id": initiative_id,
"reason_code": "epic_uncommitted_during_execution",
"data_source": "epic_rollup",
}
)
if rollup["status"] == "done":
items.append(
{
"kind": "epic_complete",
"severity": "info",
"title": title,
"summary": "Alle Stories im Epic erledigt",
"scope_type": "backlog",
"scope_id": epic_id,
"initiative_id": initiative_id,
"reason_code": "epic_complete",
"data_source": "epic_rollup",
}
)
return items