"""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 data_layer.attention import get_next_action_candidates_for_initiative from steering.context import get_steering_context_dto 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") 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, 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] next_actions = get_next_action_candidates_for_initiative( ctx, initiative_id=initiative_id, limit=5 ) steering = get_steering_context_dto(ctx, initiative_id=initiative_id) return { "initiative_id": initiative_id, "initiative_title": initiative["title"], "initiative_status": initiative["status"], "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"], "signals": signals, "upcoming_milestones": upcoming_milestones, "upcoming_roadmap_items": upcoming_milestones, "next_actions": next_actions, "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"], "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", }