All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m11s
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 18s
Test Suite / playwright-smoke (push) Successful in 15s
Erweitert evaluate_steering um read_models und proposals, migriert epic_rollup auf Registry, liefert sprint_commit-Vorschlaege und UI auf Plan-Sprint. Co-authored-by: Cursor <cursoragent@cursor.com>
412 lines
15 KiB
Python
412 lines
15 KiB
Python
"""Initiative steering snapshot — verknüpftes Read-Model (AP0.10a).
|
|
|
|
Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steering Core.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
from psycopg2.extras import RealDictCursor
|
|
|
|
from db import get_connection
|
|
from entity_archetypes.registry import archetype_label
|
|
from method_profiles.registry import get_method_profile
|
|
from steering.context import get_steering_context_dto
|
|
from steering.kernel import evaluate_steering
|
|
from steering.signals.snapshot_signals import derive_initiative_signals
|
|
from services.initiatives import get_initiative
|
|
from tenant_context import TenantContext
|
|
|
|
OPEN_BLOCKER = ("open", "in_progress")
|
|
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
|
|
ACTIVE_INITIATIVE = ("active", "paused")
|
|
|
|
_REASON_LABELS = {
|
|
"open_action_priority": "Offenes Arbeitspaket",
|
|
"milestone_at_risk_or_active": "Plan-Element aktiv oder gefährdet",
|
|
"blocked_action": "Blockiertes Arbeitspaket",
|
|
"backlog_triage": "Eingang triagieren",
|
|
}
|
|
|
|
|
|
def _steering_guidance(
|
|
*,
|
|
next_actions: list[dict[str, Any]],
|
|
signals: list[str],
|
|
method_key: str,
|
|
) -> str:
|
|
if next_actions:
|
|
item = next_actions[0]
|
|
title = item.get("title") or "Nächster Schritt"
|
|
hint = (
|
|
item.get("recommended_action")
|
|
or item.get("summary")
|
|
or _REASON_LABELS.get(item.get("reason_code", ""), "")
|
|
)
|
|
if hint:
|
|
return f"Als Nächstes: {title} — {hint}"
|
|
return f"Als Nächstes: {title}"
|
|
if signals:
|
|
return "Attention: Struktur oder Ist-Arbeit ergänzen, um Next Actions zu erzeugen."
|
|
return (
|
|
f"Methode {method_key}: Plan oder Eingang befüllen, dann committete Arbeit starten."
|
|
)
|
|
|
|
|
|
def _attention_items(signals: list[str]) -> list[dict[str, str]]:
|
|
return [{"code": code, "label": code.replace("_", " ")} for code in signals]
|
|
|
|
|
|
def _sid(value: Any) -> Optional[str]:
|
|
return str(value) if value else None
|
|
|
|
|
|
def _iso(value: Any) -> Optional[str]:
|
|
return value.isoformat() if value else None
|
|
|
|
|
|
def get_initiative_steering_snapshot(
|
|
ctx: TenantContext, *, initiative_id: str
|
|
) -> Optional[dict[str, Any]]:
|
|
"""Tenant-scoped Graph-Snapshot eines Vorhabens."""
|
|
initiative = get_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
|
if not initiative:
|
|
return None
|
|
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, description, status, priority, due_at,
|
|
created_at, updated_at
|
|
FROM actions
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
actions_raw = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, action_id, title, status, created_at, updated_at
|
|
FROM blockers
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
blockers = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, action_id, milestone_id, title, status, created_at, updated_at
|
|
FROM evidence
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
evidence = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, action_id, milestone_id, title, status, due_at,
|
|
created_at, updated_at
|
|
FROM reviews
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
reviews = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, priority, converted_action_id, item_kind,
|
|
parent_backlog_id, created_at, updated_at
|
|
FROM backlog_items
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
backlog = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT ri.id, ri.title, ri.status, ri.target_date, ri.item_type,
|
|
ri.sequencing_mode, ri.sort_order, ri.created_at, ri.updated_at
|
|
FROM roadmap_items ri
|
|
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
|
WHERE ri.tenant_id = %s AND r.initiative_id = %s
|
|
ORDER BY ri.sort_order ASC, ri.updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
roadmap_items_raw = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, outcome, created_at, updated_at
|
|
FROM decisions
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
decisions = [dict(r) for r in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, title, status, next_due_at, created_at, updated_at
|
|
FROM recurring_elements
|
|
WHERE tenant_id = %s AND initiative_id = %s
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(ctx.tenant_id, initiative_id),
|
|
)
|
|
recurring = [dict(r) for r in cur.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
blockers_by_action: dict[str, list[dict]] = {}
|
|
unlinked_blockers: list[dict] = []
|
|
for b in blockers:
|
|
item = {
|
|
"id": _sid(b["id"]),
|
|
"title": b["title"],
|
|
"status": b["status"],
|
|
"action_id": _sid(b.get("action_id")),
|
|
}
|
|
aid = item["action_id"]
|
|
if aid:
|
|
blockers_by_action.setdefault(aid, []).append(item)
|
|
else:
|
|
unlinked_blockers.append(item)
|
|
|
|
evidence_by_action: dict[str, list[dict]] = {}
|
|
unlinked_evidence: list[dict] = []
|
|
for e in evidence:
|
|
item = {
|
|
"id": _sid(e["id"]),
|
|
"title": e["title"],
|
|
"status": e["status"],
|
|
"action_id": _sid(e.get("action_id")),
|
|
"milestone_id": _sid(e.get("milestone_id")),
|
|
}
|
|
aid = item["action_id"]
|
|
if aid:
|
|
evidence_by_action.setdefault(aid, []).append(item)
|
|
else:
|
|
unlinked_evidence.append(item)
|
|
|
|
reviews_by_action: dict[str, list[dict]] = {}
|
|
reviews_by_milestone: dict[str, list[dict]] = {}
|
|
unlinked_reviews: list[dict] = []
|
|
planned_reviews_due = 0
|
|
for r in reviews:
|
|
item = {
|
|
"id": _sid(r["id"]),
|
|
"title": r["title"],
|
|
"status": r["status"],
|
|
"due_at": _iso(r.get("due_at")),
|
|
"action_id": _sid(r.get("action_id")),
|
|
"milestone_id": _sid(r.get("milestone_id")),
|
|
}
|
|
if r["status"] == "planned" and r.get("due_at"):
|
|
planned_reviews_due += 1
|
|
aid, mid = item["action_id"], item["milestone_id"]
|
|
if aid:
|
|
reviews_by_action.setdefault(aid, []).append(item)
|
|
elif mid:
|
|
reviews_by_milestone.setdefault(mid, []).append(item)
|
|
else:
|
|
unlinked_reviews.append(item)
|
|
|
|
actions: list[dict[str, Any]] = []
|
|
open_actions = blocked_actions = review_required_actions = 0
|
|
for a in actions_raw:
|
|
aid = _sid(a["id"])
|
|
status = a["status"]
|
|
if status in OPEN_ACTION:
|
|
open_actions += 1
|
|
if status == "blocked":
|
|
blocked_actions += 1
|
|
if status == "review_required":
|
|
review_required_actions += 1
|
|
action_blockers = blockers_by_action.get(aid, [])
|
|
open_blocker_count = sum(1 for b in action_blockers if b["status"] in OPEN_BLOCKER)
|
|
actions.append(
|
|
{
|
|
"id": aid,
|
|
"title": a["title"],
|
|
"status": status,
|
|
"priority": a["priority"],
|
|
"due_at": _iso(a.get("due_at")),
|
|
"blockers": action_blockers,
|
|
"evidence": evidence_by_action.get(aid, []),
|
|
"reviews": reviews_by_action.get(aid, []),
|
|
"open_blocker_count": open_blocker_count,
|
|
"has_open_blocker": open_blocker_count > 0,
|
|
}
|
|
)
|
|
|
|
roadmap_items: list[dict[str, Any]] = []
|
|
for m in roadmap_items_raw:
|
|
mid = _sid(m["id"])
|
|
roadmap_items.append(
|
|
{
|
|
"id": mid,
|
|
"title": m["title"],
|
|
"status": m["status"],
|
|
"item_type": m["item_type"],
|
|
"sequencing_mode": m["sequencing_mode"],
|
|
"sort_order": m.get("sort_order", 0),
|
|
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
|
|
"reviews": reviews_by_milestone.get(mid, []),
|
|
}
|
|
)
|
|
|
|
open_blockers = sum(
|
|
1 for b in blockers if b["status"] in OPEN_BLOCKER
|
|
)
|
|
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
|
|
|
|
signals = derive_initiative_signals(
|
|
initiative_status=initiative["status"],
|
|
open_actions=open_actions,
|
|
blocked_actions=blocked_actions,
|
|
review_required_actions=review_required_actions,
|
|
open_blockers=open_blockers,
|
|
backlog_new=backlog_new,
|
|
planned_reviews_due=planned_reviews_due,
|
|
)
|
|
|
|
upcoming_milestones = sorted(
|
|
[
|
|
m
|
|
for m in roadmap_items
|
|
if m["status"] in ("planned", "active", "at_risk")
|
|
],
|
|
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
|
|
)[:5]
|
|
|
|
evaluation = evaluate_steering(ctx, initiative_id=initiative_id, next_work_limit=5)
|
|
next_actions = evaluation.next_work
|
|
kernel_attention = evaluation.attention
|
|
epic_rollup = evaluation.read_models.get("epic_rollup") or []
|
|
sprint_commit_proposals = evaluation.proposals.get("sprint_commit") or []
|
|
|
|
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
|
metadata = steering.get("lifecycle_metadata") or {}
|
|
if isinstance(metadata, str):
|
|
metadata = {}
|
|
method_profile_key = metadata.get("method_profile_key")
|
|
method_profile_label = None
|
|
if method_profile_key:
|
|
profile = get_method_profile(method_profile_key)
|
|
if profile:
|
|
method_profile_label = profile.get("label")
|
|
initiative_archetype = initiative.get("archetype_key") or "initiative.generic"
|
|
|
|
from services.work_cycle import get_active_work_cycle
|
|
|
|
active_work_cycle = get_active_work_cycle(
|
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
|
)
|
|
|
|
return {
|
|
"initiative_id": initiative_id,
|
|
"initiative_title": initiative["title"],
|
|
"initiative_status": initiative["status"],
|
|
"archetype_key": initiative_archetype,
|
|
"archetype_label": archetype_label(
|
|
entity_type="initiative", archetype_key=initiative_archetype
|
|
),
|
|
"method_profile_key": method_profile_key,
|
|
"method_profile_label": method_profile_label,
|
|
"lifecycle_state": steering["lifecycle_state"],
|
|
"lifecycle_label": steering["lifecycle_label"],
|
|
"method_key": steering["method_key"],
|
|
"method_label": steering.get("method_label"),
|
|
"method_version": steering["method_version"],
|
|
"steering_guidance": _steering_guidance(
|
|
next_actions=next_actions,
|
|
signals=signals,
|
|
method_key=steering["method_key"],
|
|
),
|
|
"attention_items": _attention_items(signals)
|
|
+ [
|
|
{
|
|
"code": item.get("reason_code") or item.get("kind", "attention"),
|
|
"label": item.get("summary") or item.get("title") or "Attention",
|
|
}
|
|
for item in kernel_attention[:5]
|
|
],
|
|
"signals": signals,
|
|
"steering_kernel": {
|
|
"horizon": evaluation.horizon.to_dict(),
|
|
"lifecycle": evaluation.lifecycle.to_dict(),
|
|
"primary_method_key": evaluation.binding.primary_method_key,
|
|
"composition_modifier": evaluation.binding.composition_modifier,
|
|
"data_source": evaluation.data_source,
|
|
"read_models": evaluation.read_models,
|
|
"proposals": evaluation.proposals,
|
|
},
|
|
"upcoming_milestones": upcoming_milestones,
|
|
"upcoming_roadmap_items": upcoming_milestones,
|
|
"next_actions": next_actions,
|
|
"active_work_cycle": active_work_cycle,
|
|
"epic_rollup": epic_rollup,
|
|
"sprint_commit_proposals": sprint_commit_proposals,
|
|
"counts": {
|
|
"actions_open": open_actions,
|
|
"actions_blocked": blocked_actions,
|
|
"actions_review_required": review_required_actions,
|
|
"blockers_open": open_blockers,
|
|
"backlog_open": backlog_new,
|
|
"decisions": len(decisions),
|
|
"reviews_planned_due": planned_reviews_due,
|
|
"recurring_active": sum(1 for r in recurring if r["status"] == "active"),
|
|
"unlinked_blockers": len(unlinked_blockers),
|
|
"unlinked_evidence": len(unlinked_evidence),
|
|
"unlinked_reviews": len(unlinked_reviews),
|
|
},
|
|
"actions": actions,
|
|
"roadmap_items": roadmap_items,
|
|
"milestones": roadmap_items,
|
|
"initiative_level": {
|
|
"blockers": unlinked_blockers,
|
|
"evidence": unlinked_evidence,
|
|
"reviews": unlinked_reviews,
|
|
"decisions": [
|
|
{"id": _sid(d["id"]), "title": d["title"], "status": d["status"]}
|
|
for d in decisions
|
|
],
|
|
"backlog": [
|
|
{
|
|
"id": _sid(b["id"]),
|
|
"title": b["title"],
|
|
"status": b["status"],
|
|
"item_kind": b.get("item_kind"),
|
|
"parent_backlog_id": _sid(b.get("parent_backlog_id")),
|
|
"converted_action_id": _sid(b.get("converted_action_id")),
|
|
}
|
|
for b in backlog
|
|
],
|
|
"recurring": [
|
|
{
|
|
"id": _sid(r["id"]),
|
|
"title": r["title"],
|
|
"status": r["status"],
|
|
"next_due_at": _iso(r.get("next_due_at")),
|
|
}
|
|
for r in recurring
|
|
],
|
|
},
|
|
"data_source": "initiative_steering_snapshot",
|
|
}
|