Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Failing after 3m23s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Decision-Lock, Spec-D-Methoden und C1-Massstab-Vollspecs als Zielbild; Steering-Registry/Compat und Horizon-Tests an die Normierung anbinden. Implementierungsausreichendheit bleibt bewusst offen. Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
7.3 KiB
Python
227 lines
7.3 KiB
Python
"""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 roadmap item for initiative scope (Gate-Horizont)."""
|
|
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'
|
|
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]
|