"""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, Literal, Optional from psycopg2.extras import RealDictCursor from db import get_connection from data_layer.attention import get_next_action_candidates_for_initiative from services.initiatives import get_initiative from tenant_context import TenantContext OperatingPhase = Literal[ "capture", "triage", "structure", "commit", "execute", "verify", "review", "adapt", "closure", ] 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 _derive_operating_phase( *, initiative_status: str, open_actions: int, blocked_actions: int, review_required_actions: int, open_blockers: int, backlog_new: int, planned_reviews_due: int, ) -> tuple[OperatingPhase, list[str]]: """Heuristische, erklärbare Phase — kein Lifecycle-State-Machine-Ersatz.""" signals: list[str] = [] if initiative_status in ("completed", "archived"): return "closure", ["initiative_terminal"] if open_actions == 0 and backlog_new > 0: signals.append("backlog_awaiting_commit") return "triage", signals if open_actions == 0 and backlog_new == 0: signals.append("no_open_work") return "structure", signals if blocked_actions > 0 or open_blockers > 0: if blocked_actions: signals.append("actions_blocked") if open_blockers: signals.append("open_blockers") return "execute", signals if review_required_actions > 0 or planned_reviews_due > 0: if review_required_actions: signals.append("actions_need_review") if planned_reviews_due: signals.append("reviews_due") return "review", signals if open_actions > 0: signals.append("work_in_progress") return "execute", signals return "adapt", signals 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 id, title, status, target_date, created_at, updated_at FROM milestones WHERE tenant_id = %s AND initiative_id = %s ORDER BY updated_at DESC """, (ctx.tenant_id, initiative_id), ) milestones_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, } ) milestones = [] for m in milestones_raw: mid = _sid(m["id"]) milestones.append( { "id": mid, "title": m["title"], "status": m["status"], "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")) phase, phase_signals = _derive_operating_phase( 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 milestones 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 ) return { "initiative_id": initiative_id, "initiative_title": initiative["title"], "initiative_status": initiative["status"], "operating_phase": phase, "phase_signals": phase_signals, "upcoming_milestones": 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, "milestones": milestones, "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", }