"""Default Signal Rule Provider — regelbasiert, tenant-scoped (AP0.8a, AP1.0).""" from __future__ import annotations from typing import Any, Literal, Optional from psycopg2.extras import RealDictCursor from db import get_connection from tenant_context import TenantContext AttentionKind = Literal[ "blocked_action", "open_blocker", "high_priority_action", "unassigned_action", "initiative_without_next_action", "stale_initiative", "milestone_at_risk", "gate_graph_blocked", "overdue_action", "review_due", "recurring_due", "action_review_required", ] NextActionKind = Literal[ "assign_action", "resolve_blocker", "create_action", "convert_backlog", "review_milestone", ] Severity = Literal["info", "warning", "critical"] _SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2} OPEN_BLOCKER_STATUSES = ("open", "in_progress") OPEN_ACTION_STATUSES = ("open", "ready", "in_progress", "blocked", "review_required") ACTIVE_INITIATIVE_STATUSES = ("active", "paused") def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]: item = dict(row) for key in ( "scope_id", "initiative_id", "action_id", "blocker_id", "milestone_id", "review_id", "recurring_element_id", ): if item.get(key): item[key] = str(item[key]) return item def _blocked_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'blocked_action' AS kind, 'critical' AS severity, a.title AS title, 'Maßnahme ist blockiert' AS summary, 'action' AS scope_type, a.id AS scope_id, a.initiative_id, a.id AS action_id, NULL::uuid AS blocker_id, NULL::uuid AS milestone_id, 'action_blocked' AS reason_code, 'actions' AS data_source FROM actions a WHERE a.tenant_id = %s AND a.status = 'blocked' ORDER BY a.updated_at DESC LIMIT 50 """, (ctx.tenant_id,), ) return [_serialize_attention(dict(r)) for r in cur.fetchall()] def _open_blockers(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'open_blocker' AS kind, 'warning' AS severity, b.title AS title, 'Offener Blocker im Vorhaben' AS summary, 'blocker' AS scope_type, b.id AS scope_id, b.initiative_id, b.action_id, b.id AS blocker_id, NULL::uuid AS milestone_id, 'blocker_open' AS reason_code, 'blockers' AS data_source FROM blockers b WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress') ORDER BY b.updated_at DESC LIMIT 50 """, (ctx.tenant_id,), ) items = [] for row in cur.fetchall(): item = _serialize_attention(dict(row)) if item.get("action_id"): item["action_id"] = str(item["action_id"]) items.append(item) return items def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: actor_filter = "" params: list[Any] = [ctx.tenant_id] if ctx.actor_id: actor_filter = """ AND EXISTS ( SELECT 1 FROM action_assignments aa WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id AND aa.actor_id = %s ) """ params.append(ctx.actor_id) else: return [] cur.execute( f""" SELECT 'high_priority_action' AS kind, 'warning' AS severity, a.title AS title, 'High-Priority Maßnahme offen' AS summary, 'action' AS scope_type, a.id AS scope_id, a.initiative_id, a.id AS action_id, NULL::uuid AS blocker_id, NULL::uuid AS milestone_id, 'action_high_priority_open' AS reason_code, 'actions' AS data_source FROM actions a WHERE a.tenant_id = %s AND a.priority = 'high' AND a.status IN ('open', 'ready', 'in_progress') {actor_filter} ORDER BY a.updated_at DESC LIMIT 30 """, tuple(params), ) return [_serialize_attention(dict(r)) for r in cur.fetchall()] def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'unassigned_action' AS kind, 'warning' AS severity, a.title AS title, 'Maßnahme ohne Zuweisung' AS summary, 'action' AS scope_type, a.id AS scope_id, a.initiative_id, a.id AS action_id, NULL::uuid AS blocker_id, NULL::uuid AS milestone_id, 'action_unassigned' AS reason_code, 'actions' AS data_source FROM actions a WHERE a.tenant_id = %s AND a.status IN ('open', 'ready', 'in_progress') AND NOT EXISTS ( SELECT 1 FROM action_assignments aa WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id ) ORDER BY a.updated_at DESC LIMIT 30 """, (ctx.tenant_id,), ) return [_serialize_attention(dict(r)) for r in cur.fetchall()] def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'initiative_without_next_action' AS kind, 'info' AS severity, i.title AS title, 'Keine offene nächste Maßnahme' AS summary, 'initiative' AS scope_type, i.id AS scope_id, i.id AS initiative_id, NULL::uuid AS action_id, NULL::uuid AS blocker_id, NULL::uuid AS milestone_id, 'initiative_no_open_action' AS reason_code, 'initiatives' AS data_source FROM initiatives i WHERE i.tenant_id = %s AND i.status IN ('active', 'paused') AND NOT EXISTS ( SELECT 1 FROM actions a WHERE a.initiative_id = i.id AND a.tenant_id = i.tenant_id AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required') ) ORDER BY i.updated_at DESC LIMIT 30 """, (ctx.tenant_id,), ) return [_serialize_attention(dict(r)) for r in cur.fetchall()] def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'stale_initiative' AS kind, 'info' AS severity, i.title AS title, 'Vorhaben seit über 14 Tagen unverändert' AS summary, 'initiative' AS scope_type, i.id AS scope_id, i.id AS initiative_id, NULL::uuid AS action_id, NULL::uuid AS blocker_id, NULL::uuid AS milestone_id, 'initiative_stale' AS reason_code, 'initiatives' AS data_source FROM initiatives i WHERE i.tenant_id = %s AND i.status = 'active' AND i.updated_at < NOW() - INTERVAL '14 days' ORDER BY i.updated_at ASC LIMIT 20 """, (ctx.tenant_id,), ) return [_serialize_attention(dict(r)) for r in cur.fetchall()] def _graph_blocked_gates(cur, ctx: TenantContext) -> list[dict[str, Any]]: from steering.graph.profiles import enforce_gate_blocking_for_method from steering.graph.roadmap_engine import load_initiative_graph_state cur.execute( """ SELECT sc.initiative_id, sc.method_key FROM steering_contexts sc JOIN initiatives i ON i.id = sc.initiative_id AND i.tenant_id = sc.tenant_id WHERE sc.tenant_id = %s AND i.status IN ('active', 'paused') ORDER BY i.updated_at DESC LIMIT 30 """, (ctx.tenant_id,), ) items: list[dict[str, Any]] = [] for row in cur.fetchall(): method_key = row.get("method_key") or "generic_operating" if not enforce_gate_blocking_for_method(method_key): continue initiative_id = str(row["initiative_id"]) state = load_initiative_graph_state( tenant_id=ctx.tenant_id, initiative_id=initiative_id ) for gate_id in state.get("blocked_items", []): cur.execute( """ SELECT title FROM roadmap_items WHERE id = %s AND tenant_id = %s """, (gate_id, ctx.tenant_id), ) gate_row = cur.fetchone() if not gate_row: continue blocked_by = state["items"].get(gate_id, {}).get("blocked_by") or [] summary = ( "Graph-Voraussetzung noch nicht erreicht" if len(blocked_by) != 1 else "Graph-Voraussetzung noch nicht erreicht" ) items.append( { "kind": "gate_graph_blocked", "severity": "warning", "title": gate_row["title"], "summary": summary, "scope_type": "milestone", "scope_id": gate_id, "initiative_id": initiative_id, "action_id": None, "blocker_id": None, "milestone_id": gate_id, "reason_code": "gate_graph_blocked", "data_source": "roadmap_graph", } ) if len(items) >= 20: return items return items def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'milestone_at_risk' AS kind, 'warning' AS severity, ri.title AS title, 'Plan-Element als gefährdet markiert' AS summary, 'milestone' AS scope_type, ri.id AS scope_id, r.initiative_id, NULL::uuid AS action_id, NULL::uuid AS blocker_id, ri.id AS milestone_id, 'milestone_at_risk' AS reason_code, 'roadmap_items' AS data_source 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 ri.status = 'at_risk' ORDER BY ri.updated_at DESC LIMIT 20 """, (ctx.tenant_id,), ) return [_serialize_attention(dict(r)) for r in cur.fetchall()] def _actions_review_required(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'action_review_required' AS kind, 'warning' AS severity, a.title AS title, 'Maßnahme wartet auf Review — kein geplantes Review verknüpft' AS summary, 'action' AS scope_type, a.id AS scope_id, a.initiative_id, a.id AS action_id, NULL::uuid AS blocker_id, NULL::uuid AS milestone_id, NULL::uuid AS review_id, NULL::uuid AS recurring_element_id, 'action_review_required_no_review' AS reason_code, 'actions' AS data_source FROM actions a WHERE a.tenant_id = %s AND a.status = 'review_required' AND NOT EXISTS ( SELECT 1 FROM reviews r WHERE r.tenant_id = a.tenant_id AND r.action_id = a.id AND r.status = 'planned' ) ORDER BY a.updated_at DESC LIMIT 20 """, (ctx.tenant_id,), ) return [_serialize_attention(dict(r)) for r in cur.fetchall()] def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'overdue_action' AS kind, 'warning' AS severity, a.title AS title, 'Maßnahme überfällig' AS summary, 'action' AS scope_type, a.id AS scope_id, a.initiative_id, a.id AS action_id, NULL::uuid AS blocker_id, NULL::uuid AS milestone_id, NULL::uuid AS review_id, NULL::uuid AS recurring_element_id, 'action_overdue' AS reason_code, 'actions' AS data_source FROM actions a WHERE a.tenant_id = %s AND a.due_at IS NOT NULL AND a.due_at < NOW() AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required') ORDER BY a.due_at ASC LIMIT 30 """, (ctx.tenant_id,), ) return [_serialize_attention(dict(r)) for r in cur.fetchall()] def _reviews_due(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'review_due' AS kind, 'warning' AS severity, r.title AS title, 'Review fällig' AS summary, 'review' AS scope_type, r.id AS scope_id, r.initiative_id, r.action_id, NULL::uuid AS blocker_id, r.milestone_id, r.id AS review_id, NULL::uuid AS recurring_element_id, 'review_due' AS reason_code, 'reviews' AS data_source FROM reviews r WHERE r.tenant_id = %s AND r.status = 'planned' AND r.due_at IS NOT NULL AND r.due_at <= NOW() ORDER BY r.due_at ASC LIMIT 20 """, (ctx.tenant_id,), ) items = [] for row in cur.fetchall(): item = _serialize_attention(dict(row)) for fk in ("action_id", "milestone_id"): if item.get(fk): item[fk] = str(item[fk]) items.append(item) return items def _recurring_due(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ SELECT 'recurring_due' AS kind, 'info' AS severity, re.title AS title, 'Wiederkehrendes Element fällig' AS summary, 'recurring_element' AS scope_type, re.id AS scope_id, re.initiative_id, NULL::uuid AS action_id, NULL::uuid AS blocker_id, NULL::uuid AS milestone_id, NULL::uuid AS review_id, re.id AS recurring_element_id, 'recurring_due' AS reason_code, 'recurring_elements' AS data_source FROM recurring_elements re WHERE re.tenant_id = %s AND re.status = 'active' AND re.next_due_at IS NOT NULL AND re.next_due_at <= NOW() ORDER BY re.next_due_at ASC LIMIT 20 """, (ctx.tenant_id,), ) return [_serialize_attention(dict(r)) for r in cur.fetchall()] def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]: """Regelbasierte Attention Items — tenant-scoped, erklärbar.""" conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: items: list[dict[str, Any]] = [] items.extend(_blocked_actions(cur, ctx)) items.extend(_open_blockers(cur, ctx)) items.extend(_high_priority_actions(cur, ctx)) items.extend(_unassigned_actions(cur, ctx)) items.extend(_initiatives_without_next_action(cur, ctx)) items.extend(_stale_initiatives(cur, ctx)) items.extend(_graph_blocked_gates(cur, ctx)) items.extend(_milestones_at_risk(cur, ctx)) items.extend(_overdue_actions(cur, ctx)) items.extend(_actions_review_required(cur, ctx)) items.extend(_reviews_due(cur, ctx)) items.extend(_recurring_due(cur, ctx)) items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99)) return items finally: conn.close() def get_next_action_candidates( ctx: TenantContext, *, limit: int = 10 ) -> list[dict[str, Any]]: """Regelbasierte NextActionCandidates — limitiert, tenant-scoped.""" if limit < 1: limit = 1 if limit > 50: limit = 50 conn = get_connection() candidates: list[dict[str, Any]] = [] try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT 'resolve_blocker' AS kind, b.title AS title, 'Blocker klären oder Status aktualisieren' AS summary, b.initiative_id, b.action_id, NULL::uuid AS backlog_item_id, 'blocker_open' AS reason_code, 'Blocker bearbeiten' AS recommended_action FROM blockers b WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress') ORDER BY b.updated_at DESC LIMIT %s """, (ctx.tenant_id, limit), ) for row in cur.fetchall(): item = dict(row) item["initiative_id"] = str(item["initiative_id"]) if item.get("action_id"): item["action_id"] = str(item["action_id"]) candidates.append(item) remaining = limit - len(candidates) if remaining > 0: cur.execute( """ SELECT 'assign_action' AS kind, a.title AS title, 'Actor zuweisen' AS summary, a.initiative_id, a.id AS action_id, NULL::uuid AS backlog_item_id, 'action_unassigned' AS reason_code, 'Maßnahme zuweisen' AS recommended_action FROM actions a WHERE a.tenant_id = %s AND a.status IN ('open', 'ready', 'in_progress') AND NOT EXISTS ( SELECT 1 FROM action_assignments aa WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id ) ORDER BY a.updated_at DESC LIMIT %s """, (ctx.tenant_id, remaining), ) for row in cur.fetchall(): item = dict(row) item["initiative_id"] = str(item["initiative_id"]) item["action_id"] = str(item["action_id"]) candidates.append(item) remaining = limit - len(candidates) if remaining > 0: cur.execute( """ SELECT 'convert_backlog' AS kind, bi.title AS title, 'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary, bi.initiative_id, NULL::uuid AS action_id, bi.id AS backlog_item_id, 'backlog_accepted_not_converted' AS reason_code, 'In Maßnahme umwandeln' AS recommended_action FROM backlog_items bi WHERE bi.tenant_id = %s AND bi.status = 'accepted' AND bi.converted_action_id IS NULL ORDER BY bi.updated_at DESC LIMIT %s """, (ctx.tenant_id, remaining), ) for row in cur.fetchall(): item = dict(row) item["initiative_id"] = str(item["initiative_id"]) item["backlog_item_id"] = str(item["backlog_item_id"]) candidates.append(item) remaining = limit - len(candidates) if remaining > 0: cur.execute( """ SELECT 'create_action' AS kind, i.title AS title, 'Nächste Maßnahme für Vorhaben anlegen' AS summary, i.id AS initiative_id, NULL::uuid AS action_id, NULL::uuid AS backlog_item_id, 'initiative_no_open_action' AS reason_code, 'Maßnahme anlegen' AS recommended_action FROM initiatives i WHERE i.tenant_id = %s AND i.status IN ('active', 'paused') AND NOT EXISTS ( SELECT 1 FROM actions a WHERE a.initiative_id = i.id AND a.tenant_id = i.tenant_id AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required') ) ORDER BY i.updated_at DESC LIMIT %s """, (ctx.tenant_id, remaining), ) for row in cur.fetchall(): item = dict(row) item["initiative_id"] = str(item["initiative_id"]) candidates.append(item) return candidates[:limit] finally: conn.close() def get_next_action_candidates_for_initiative( ctx: TenantContext, *, initiative_id: str, limit: int = 5 ) -> list[dict[str, Any]]: """NextActionCandidates für ein Vorhaben — tenant-scoped.""" if limit < 1: limit = 1 if limit > 20: limit = 20 conn = get_connection() candidates: list[dict[str, Any]] = [] try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT 'resolve_blocker' AS kind, b.title AS title, 'Blocker klären oder Status aktualisieren' AS summary, b.initiative_id, b.action_id, NULL::uuid AS backlog_item_id, 'blocker_open' AS reason_code, 'Blocker bearbeiten' AS recommended_action FROM blockers b WHERE b.tenant_id = %s AND b.initiative_id = %s AND b.status IN ('open', 'in_progress') ORDER BY b.updated_at DESC LIMIT %s """, (ctx.tenant_id, initiative_id, limit), ) for row in cur.fetchall(): item = dict(row) item["initiative_id"] = str(item["initiative_id"]) if item.get("action_id"): item["action_id"] = str(item["action_id"]) candidates.append(item) remaining = limit - len(candidates) if remaining > 0: cur.execute( """ SELECT 'assign_action' AS kind, a.title AS title, 'Actor zuweisen' AS summary, a.initiative_id, a.id AS action_id, NULL::uuid AS backlog_item_id, 'action_unassigned' AS reason_code, 'Maßnahme zuweisen' AS recommended_action FROM actions a WHERE a.tenant_id = %s AND a.initiative_id = %s AND a.status IN ('open', 'ready', 'in_progress') AND NOT EXISTS ( SELECT 1 FROM action_assignments aa WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id ) ORDER BY a.updated_at DESC LIMIT %s """, (ctx.tenant_id, initiative_id, remaining), ) for row in cur.fetchall(): item = dict(row) item["initiative_id"] = str(item["initiative_id"]) item["action_id"] = str(item["action_id"]) candidates.append(item) remaining = limit - len(candidates) if remaining > 0: cur.execute( """ SELECT 'convert_backlog' AS kind, bi.title AS title, 'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary, bi.initiative_id, NULL::uuid AS action_id, bi.id AS backlog_item_id, 'backlog_accepted_not_converted' AS reason_code, 'In Maßnahme umwandeln' AS recommended_action FROM backlog_items bi WHERE bi.tenant_id = %s AND bi.initiative_id = %s AND bi.status = 'accepted' AND bi.converted_action_id IS NULL ORDER BY bi.updated_at DESC LIMIT %s """, (ctx.tenant_id, initiative_id, remaining), ) for row in cur.fetchall(): item = dict(row) item["initiative_id"] = str(item["initiative_id"]) item["backlog_item_id"] = str(item["backlog_item_id"]) candidates.append(item) remaining = limit - len(candidates) if remaining > 0: cur.execute( """ SELECT 1 FROM actions a WHERE a.initiative_id = %s AND a.tenant_id = %s AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required') LIMIT 1 """, (initiative_id, ctx.tenant_id), ) if not cur.fetchone(): cur.execute( """ SELECT title FROM initiatives WHERE id = %s AND tenant_id = %s """, (initiative_id, ctx.tenant_id), ) init_row = cur.fetchone() if init_row: candidates.append( { "kind": "create_action", "title": init_row["title"], "summary": "Nächste Maßnahme für Vorhaben anlegen", "initiative_id": initiative_id, "action_id": None, "backlog_item_id": None, "reason_code": "initiative_no_open_action", "recommended_action": "Maßnahme anlegen", } ) return candidates[:limit] finally: conn.close()