"""Continuous product NextAction strategy — AP2.0a + AP2.0d.""" from __future__ import annotations from typing import Any from db import get_connection from psycopg2.extras import RealDictCursor from steering.signals import default_rules from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy from steering.strategies.next_action.execution_ready import ( blocked_execution_action_ids, list_execution_ready_candidates, merge_candidates, ) from steering.strategies.next_action.registry import ( get_next_action_strategy, register_next_action_strategy, ) from tenant_context import TenantContext _default = DefaultNextActionStrategy() _OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required") class ContinuousProductStrategy: key = "continuous_product" def evaluate( self, ctx: TenantContext, *, initiative_id: str | None = None, limit: int = 10, ) -> list[dict[str, Any]]: if limit < 1: limit = 1 if not initiative_id: return _default.evaluate(ctx, limit=limit) blocked_ids = blocked_execution_action_ids(ctx, initiative_id) ready = list_execution_ready_candidates( ctx, initiative_id, limit=limit, prefer_critical_path=True ) remaining = limit - len(ready) operational: list[dict[str, Any]] = [] if remaining > 0: blocked_list = list(blocked_ids) blocked_clause = "" blocked_params: list[Any] = [] if blocked_list: blocked_clause = "AND NOT (a.id::text = ANY(%s))" blocked_params = [blocked_list] conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT 'action' AS kind, a.title AS title, 'Offenes Arbeitspaket priorisieren' AS summary, a.initiative_id, a.id AS action_id, NULL::uuid AS backlog_item_id, 'open_action_priority' AS reason_code, 'Als Nächstes ausführen' AS recommended_action FROM actions a WHERE a.tenant_id = %s AND a.initiative_id = %s AND a.status = ANY(%s) {blocked_clause} ORDER BY CASE a.status WHEN 'review_required' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'ready' THEN 2 ELSE 3 END, CASE a.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 ELSE 2 END, a.due_at ASC NULLS LAST, a.updated_at DESC LIMIT %s """, ( ctx.tenant_id, initiative_id, list(_OPEN_ACTION), *blocked_params, remaining, ), ) for row in cur.fetchall(): item = dict(row) item["initiative_id"] = str(item["initiative_id"]) item["action_id"] = str(item["action_id"]) if item["action_id"] in blocked_ids: continue operational.append(item) finally: conn.close() merged = merge_candidates( ready, operational, limit=limit, exclude_action_ids=blocked_ids ) remaining = limit - len(merged) if remaining > 0: from steering.strategies.next_action.product_milestone_driven import ( ProductMilestoneDrivenStrategy, ) gate_items = ProductMilestoneDrivenStrategy().evaluate( ctx, initiative_id=initiative_id, limit=remaining ) gate_items = [ item for item in gate_items if item.get("kind") == "review_milestone" ] merged = merge_candidates( merged, gate_items, limit=limit, exclude_action_ids=blocked_ids ) if len(merged) < limit: rest = default_rules.get_next_action_candidates_for_initiative( ctx, initiative_id=initiative_id, limit=limit - len(merged) ) merged = merge_candidates( merged, rest, limit=limit, exclude_action_ids=blocked_ids ) return merged[:limit] continuous_product_strategy = ContinuousProductStrategy() def register_continuous_product_strategy() -> None: if not get_next_action_strategy(continuous_product_strategy.key): register_next_action_strategy(continuous_product_strategy)