"""Execution-ready ranking for NextAction strategies — AP2.0d.""" from __future__ import annotations from typing import Any, Iterable from steering.graph.execution_engine import load_initiative_execution_graph_state from tenant_context import TenantContext _OPEN_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"}) def rank_ready_action_ids( graph_state: dict[str, Any], *, prefer_critical_path: bool = True, ) -> list[str]: """Order ready_actions: critical path first, then sort_order.""" ready = [str(action_id) for action_id in graph_state.get("ready_actions", [])] if not ready: return [] items = graph_state.get("items") or {} ready_set = set(ready) ordered: list[str] = [] if prefer_critical_path: for action_id in graph_state.get("critical_path") or []: aid = str(action_id) if aid in ready_set and aid not in ordered: ordered.append(aid) remaining = [aid for aid in ready if aid not in ordered] remaining.sort( key=lambda action_id: ( items.get(action_id, {}).get("sort_order", 0), action_id, ) ) ordered.extend(remaining) return ordered def action_to_next_candidate( action: dict[str, Any], *, reason_code: str = "execution_ready", summary: str = "Ausführungsbereit — keine blockierenden Vorgänger", recommended_action: str = "Als Nächstes ausführen", on_critical_path: bool = False, ) -> dict[str, Any]: """Convert action row to NextAction candidate.""" if on_critical_path and reason_code == "execution_ready": reason_code = "execution_critical_path" summary = "Kritischer Pfad — als Nächstes ausführen" return { "kind": "action", "title": action.get("title") or "Arbeitspaket", "summary": summary, "initiative_id": str(action["initiative_id"]), "action_id": str(action["id"]), "backlog_item_id": None, "reason_code": reason_code, "recommended_action": recommended_action, } def build_execution_ready_candidates( *, actions: list[dict[str, Any]], graph_state: dict[str, Any], limit: int, prefer_critical_path: bool = True, reason_code: str = "execution_ready", ) -> list[dict[str, Any]]: """Build ranked NextAction candidates from a preloaded graph state.""" if limit < 1: return [] action_by_id = {str(action["id"]): action for action in actions} critical_path = {str(action_id) for action_id in graph_state.get("critical_path") or []} blocked_ids = {str(action_id) for action_id in graph_state.get("blocked_actions") or []} candidates: list[dict[str, Any]] = [] for action_id in rank_ready_action_ids( graph_state, prefer_critical_path=prefer_critical_path ): action = action_by_id.get(action_id) if not action: continue if action.get("status") not in _OPEN_ACTION: continue if action_id in blocked_ids: continue candidates.append( action_to_next_candidate( action, reason_code=reason_code, on_critical_path=action_id in critical_path, ) ) if len(candidates) >= limit: break return candidates def list_execution_ready_candidates( ctx: TenantContext, initiative_id: str, *, limit: int, scope_roadmap_item_id: str | None = None, prefer_critical_path: bool = True, reason_code: str = "execution_ready", ) -> list[dict[str, Any]]: """Load execution graph and return ranked ready-action candidates.""" from services import actions as action_service actions = action_service.list_actions_for_initiative( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) graph_state = load_initiative_execution_graph_state( tenant_id=ctx.tenant_id, initiative_id=initiative_id, scope_roadmap_item_id=scope_roadmap_item_id, ) return build_execution_ready_candidates( actions=actions, graph_state=graph_state, limit=limit, prefer_critical_path=prefer_critical_path, reason_code=reason_code, ) def merge_candidates( *sources: Iterable[dict[str, Any]], limit: int, exclude_action_ids: set[str] | None = None, ) -> list[dict[str, Any]]: """Merge candidate lists without duplicate action_ids.""" excluded = exclude_action_ids or set() merged: list[dict[str, Any]] = [] seen_actions: set[str] = set() for source in sources: for item in source: action_id = item.get("action_id") if action_id: if action_id in excluded or action_id in seen_actions: continue seen_actions.add(action_id) merged.append(item) if len(merged) >= limit: return merged return merged def blocked_execution_action_ids( ctx: TenantContext, initiative_id: str, *, scope_roadmap_item_id: str | None = None ) -> set[str]: """Action IDs blocked by execution graph (not necessarily status=blocked).""" graph_state = load_initiative_execution_graph_state( tenant_id=ctx.tenant_id, initiative_id=initiative_id, scope_roadmap_item_id=scope_roadmap_item_id, ) return {str(action_id) for action_id in graph_state.get("blocked_actions") or []} def first_active_gate_id(ctx: TenantContext, initiative_id: str) -> str | None: """First active plan gate (milestone/review/maturity) — not work_cycle.""" from db import get_connection from psycopg2.extras import RealDictCursor conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT ri.id 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 AND ri.status = 'active' AND ri.item_type != 'work_cycle' ORDER BY ri.sort_order ASC NULLS LAST, ri.target_date ASC NULLS LAST, ri.updated_at DESC LIMIT 1 """, (ctx.tenant_id, initiative_id), ) row = cur.fetchone() return str(row["id"]) if row else None finally: conn.close() def resolve_initiative_method_key(ctx: TenantContext, initiative_id: str) -> str: from services import steering_context as sc_service row = sc_service.get_steering_context( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) if row: return row["method_key"] return "generic_operating" def gate_horizon_scope_for_method(method_key: str, ctx: TenantContext, initiative_id: str) -> str | None: """Plan-Horizont (aktives Gate) für gate-geführte Primary-Methoden.""" if method_key in ( "sequential_dependency", "program_delivery", "maturity_progression", ): return first_active_gate_id(ctx, initiative_id) return None def filter_actions_for_work_cycle( actions: list[dict[str, Any]], *, work_cycle_id: str ) -> list[dict[str, Any]]: cycle = str(work_cycle_id) return [a for a in actions if str(a.get("work_cycle_id") or "") == cycle]