Kairo-Jinkendo/backend/steering/signals/default_rules.py
Lars 1fa61b2dac
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2m18s
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 2s
Test Suite / compose-smoke (push) Has been skipped
AP1.4e: Methodenprofile am Gate-Graph.
generic_operating ohne harte Blockade, Reifegrad-Methoden mit Erfuellungsgrad; Attention gate_graph_blocked fuer gate-orientierte Methoden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 08:06:42 +02:00

775 lines
27 KiB
Python

"""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()