diff --git a/CLAUDE.md b/CLAUDE.md index bb63459..056cd9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,4 +165,4 @@ Bei notwendiger Abweichung erstelle ein Architecture Decision Proposal. Siehe `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md` und Abschlussberichte AP0.7 / AP0.R1. -Foundation AP0.1–AP0.7 ist abgeschlossen. Fachlicher Ausbau nur entlang des Canonical Operating Models. +Foundation AP0.1–AP0.7 ist abgeschlossen. **Strategiewechsel (2026-07-05):** AP1.0 Steering Foundation als nächstes Code-Paket — siehe `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`. AP0.10c eingefroren. Keine parallele Steuerungslogik außerhalb `backend/steering/`. diff --git a/README.md b/README.md index 3ea21bb..f055168 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,12 @@ Operating Model Integration (AP0.10, Schema weiter `008`): | Endpoint | Methode | Capability | Beschreibung | |----------|---------|------------|--------------| -| `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Operating Phase | +| `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Lifecycle + Operating Phase (deprecated) | +| `/api/initiatives/{id}/steering-context` | GET | `kairo.initiative.read` | SteeringContext (Lifecycle, Methode) | + +**Steering Core (AP1.0, Schema `009`):** `backend/steering/` — Standard Lifecycle, Hook/Method Registry, Signal Engine. Scope Lock: siehe `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`. + +Version nach AP1.0: **`0.11.0-ap1.0`** **Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required` → `done` @@ -254,6 +259,8 @@ Operating Model Integration UI (AP0.10b, Schema weiter `008`): Version nach AP0.10b: **`0.10.1-ap0.10b`** +Operating Model Integration UI (AP0.10b, Schema weiter `008`): + Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` diff --git a/backend/data_layer/attention.py b/backend/data_layer/attention.py index 11f9b3e..9092514 100644 --- a/backend/data_layer/attention.py +++ b/backend/data_layer/attention.py @@ -1,706 +1,25 @@ -"""Attention and NextAction read-models — regelbasiert, tenant-scoped (AP0.8a).""" +"""Attention and NextAction — thin Data Layer wrapper (AP1.0).""" from __future__ import annotations -from typing import Any, Literal, Optional - -from psycopg2.extras import RealDictCursor - -from db import get_connection +from steering.signals.engine import evaluate 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", - "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 get_attention_items(ctx: TenantContext): + return evaluate(ctx, kind="attention") -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 _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]: - cur.execute( - """ - SELECT - 'milestone_at_risk' AS kind, - 'warning' AS severity, - m.title AS title, - 'Meilenstein als gefährdet markiert' AS summary, - 'milestone' AS scope_type, - m.id AS scope_id, - m.initiative_id, - NULL::uuid AS action_id, - NULL::uuid AS blocker_id, - m.id AS milestone_id, - 'milestone_at_risk' AS reason_code, - 'milestones' AS data_source - FROM milestones m - WHERE m.tenant_id = %s AND m.status = 'at_risk' - ORDER BY m.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(_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(ctx: TenantContext, *, limit: int = 10): + return evaluate(ctx, kind="next_action", limit=limit) 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() +): + return evaluate( + ctx, + kind="next_action", + limit=limit, + initiative_id=initiative_id, + ) diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index 5075539..9ddd1ac 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -11,6 +11,7 @@ from psycopg2.extras import RealDictCursor from db import get_connection from data_layer.attention import get_next_action_candidates_for_initiative +from steering.context import get_steering_context_dto from services.initiatives import get_initiative from tenant_context import TenantContext @@ -310,11 +311,18 @@ def get_initiative_steering_snapshot( ctx, initiative_id=initiative_id, limit=5 ) + steering = get_steering_context_dto(ctx, initiative_id=initiative_id) + return { "initiative_id": initiative_id, "initiative_title": initiative["title"], "initiative_status": initiative["status"], + "lifecycle_state": steering["lifecycle_state"], + "lifecycle_label": steering["lifecycle_label"], + "method_key": steering["method_key"], + "method_version": steering["method_version"], "operating_phase": phase, + "operating_phase_deprecated": True, "phase_signals": phase_signals, "upcoming_milestones": upcoming_milestones, "next_actions": next_actions, diff --git a/backend/main.py b/backend/main.py index 88b1113..cdf4bc0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,8 @@ from fastapi.middleware.cors import CORSMiddleware from db import check_db from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION +import steering # noqa: F401 — bootstrap hooks & methods + if os.getenv("KAIRO_DB_READY") != "1": if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"): import run_migrations diff --git a/backend/migrations/009_steering_contexts.sql b/backend/migrations/009_steering_contexts.sql new file mode 100644 index 0000000..4ca8b31 --- /dev/null +++ b/backend/migrations/009_steering_contexts.sql @@ -0,0 +1,41 @@ +-- AP1.0: Steering Foundation — steering_contexts + +CREATE TABLE steering_contexts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + method_key VARCHAR(64) NOT NULL DEFAULT 'generic_operating', + method_version VARCHAR(32) NOT NULL DEFAULT '0.1.0', + lifecycle_state VARCHAR(64) NOT NULL DEFAULT 'intake', + lifecycle_metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, initiative_id) +); + +CREATE INDEX idx_steering_contexts_tenant ON steering_contexts(tenant_id); +CREATE INDEX idx_steering_contexts_initiative ON steering_contexts(tenant_id, initiative_id); + +INSERT INTO steering_contexts (tenant_id, initiative_id, lifecycle_state) +SELECT + i.tenant_id, + i.id, + CASE + WHEN i.status IN ('completed', 'archived') THEN 'closure' + WHEN 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' + ) + ) THEN 'action_selection' + WHEN EXISTS ( + SELECT 1 FROM backlog_items bi + WHERE bi.initiative_id = i.id + AND bi.tenant_id = i.tenant_id + AND bi.status IN ('new', 'triaged', 'accepted') + ) THEN 'structure_setup' + ELSE 'planning' + END +FROM initiatives i; diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 1ecb834..cfd939d 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -9,6 +9,7 @@ from capabilities import require_capability from data_layer import initiative_snapshot as dl_initiative_snapshot from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from steering.context import get_steering_context_dto from services import actions as action_service from services import backlog as backlog_service from services import blockers as blocker_service @@ -168,6 +169,18 @@ def get_initiative_steering_snapshot( return snapshot +@router.get("/{initiative_id}/steering-context") +def get_initiative_steering_context( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), +): + if not initiative_service.get_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ): + raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") + return get_steering_context_dto(ctx, initiative_id=initiative_id) + + @router.patch("/{initiative_id}") def update_initiative( initiative_id: str, diff --git a/backend/services/blockers.py b/backend/services/blockers.py index 83ab22b..b42d972 100644 --- a/backend/services/blockers.py +++ b/backend/services/blockers.py @@ -270,6 +270,7 @@ def update_blocker( tenant_id=tenant_id, blocker_id=blocker_id, action_id=result.get("action_id"), + initiative_id=result.get("initiative_id"), old_status=old_status, new_status=status, user_id=user_id, diff --git a/backend/services/initiatives.py b/backend/services/initiatives.py index f50a2e3..00b4bfe 100644 --- a/backend/services/initiatives.py +++ b/backend/services/initiatives.py @@ -94,6 +94,13 @@ def create_initiative( tenant_id=tenant_id, details={"initiative_id": row["id"], "title": title, "status": status}, ) + from services.steering_context import create_context_for_new_initiative + + create_context_for_new_initiative( + tenant_id=tenant_id, + initiative_id=row["id"], + user_id=user_id, + ) return row diff --git a/backend/services/operating_transitions.py b/backend/services/operating_transitions.py index b7e429a..d8d4004 100644 --- a/backend/services/operating_transitions.py +++ b/backend/services/operating_transitions.py @@ -1,7 +1,4 @@ -"""Minimale Operating-Transitions — Vorstufe zum Steering Orchestrator (AP0.10a). - -Side Effects bei Statusänderungen. Später: Hook Registry + Method Strategies. -""" +"""Minimale Operating-Transitions — AP1.0 mit Hook Dispatch.""" from __future__ import annotations @@ -10,6 +7,7 @@ from typing import Any, Optional from psycopg2.extras import RealDictCursor from db import get_connection +from steering.hooks.dispatch import dispatch_hook RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"}) @@ -19,6 +17,7 @@ def after_blocker_status_change( tenant_id: str, blocker_id: str, action_id: Optional[str], + initiative_id: Optional[str] = None, old_status: str, new_status: str, user_id: Optional[str] = None, @@ -86,6 +85,31 @@ def after_blocker_status_change( finally: conn.close() + if effects: + dispatch_hook( + "on_blocker_resolved", + None, + { + "blocker_id": blocker_id, + "action_id": action_id, + "initiative_id": initiative_id, + "effects": effects, + }, + tenant_id=tenant_id, + user_id=user_id, + ) + if initiative_id and user_id: + from steering.lifecycle.orchestrator import transition + + transition( + None, + initiative_id, + "action_selection", + reason="blocker_resolved", + tenant_id=tenant_id, + user_id=user_id, + ) + if effects and user_id: from services.audit import log_audit @@ -103,6 +127,7 @@ def after_review_status_change( tenant_id: str, review_id: str, action_id: Optional[str], + initiative_id: Optional[str] = None, old_status: str, new_status: str, user_id: Optional[str] = None, @@ -137,6 +162,20 @@ def after_review_status_change( finally: conn.close() + if effects: + dispatch_hook( + "on_review_completed", + None, + { + "review_id": review_id, + "action_id": action_id, + "initiative_id": initiative_id, + "effects": effects, + }, + tenant_id=tenant_id, + user_id=user_id, + ) + if effects and user_id: from services.audit import log_audit diff --git a/backend/services/reviews.py b/backend/services/reviews.py index d16f954..6f699ce 100644 --- a/backend/services/reviews.py +++ b/backend/services/reviews.py @@ -315,6 +315,7 @@ def update_review( tenant_id=tenant_id, review_id=review_id, action_id=result.get("action_id"), + initiative_id=result.get("initiative_id"), old_status=old_status, new_status=status, user_id=user_id, diff --git a/backend/services/steering_context.py b/backend/services/steering_context.py new file mode 100644 index 0000000..7b0fd91 --- /dev/null +++ b/backend/services/steering_context.py @@ -0,0 +1,208 @@ +"""SteeringContext write service — AP1.0.""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +from psycopg2.extras import RealDictCursor, Json + +from db import get_connection +from services.audit import log_audit +from services.initiatives import get_initiative +from steering.lifecycle.states import validate_lifecycle_state + +DEFAULT_METHOD_KEY = "generic_operating" +DEFAULT_METHOD_VERSION = "0.1.0" + + +def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for key in ("id", "tenant_id", "initiative_id"): + if result.get(key): + result[key] = str(result[key]) + if result.get("created_at"): + result["created_at"] = result["created_at"].isoformat() + if result.get("updated_at"): + result["updated_at"] = result["updated_at"].isoformat() + if isinstance(result.get("lifecycle_metadata"), dict): + pass + elif result.get("lifecycle_metadata") is not None: + result["lifecycle_metadata"] = dict(result["lifecycle_metadata"]) + return result + + +def _infer_initial_lifecycle_state(*, tenant_id: str, initiative_id: str) -> str: + initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id) + if not initiative: + return "intake" + if initiative["status"] in ("completed", "archived"): + return "closure" + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT 1 FROM actions + WHERE tenant_id = %s AND initiative_id = %s + AND status IN ( + 'open', 'ready', 'in_progress', 'blocked', 'review_required' + ) + LIMIT 1 + """, + (tenant_id, initiative_id), + ) + if cur.fetchone(): + return "action_selection" + + cur.execute( + """ + SELECT 1 FROM backlog_items + WHERE tenant_id = %s AND initiative_id = %s + AND status IN ('new', 'triaged', 'accepted') + LIMIT 1 + """, + (tenant_id, initiative_id), + ) + if cur.fetchone(): + return "structure_setup" + finally: + conn.close() + + return "planning" + + +def get_steering_context( + *, tenant_id: str, initiative_id: str +) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, method_key, method_version, + lifecycle_state, lifecycle_metadata, created_at, updated_at + FROM steering_contexts + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + row = cur.fetchone() + return _serialize_row(dict(row)) if row else None + finally: + conn.close() + + +def ensure_steering_context( + *, + tenant_id: str, + initiative_id: str, + initial_state: Optional[str] = None, + user_id: Optional[str] = None, +) -> dict[str, Any]: + existing = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id) + if existing: + return existing + + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Vorhaben nicht gefunden") + + state = initial_state or _infer_initial_lifecycle_state( + tenant_id=tenant_id, initiative_id=initiative_id + ) + validate_lifecycle_state(state) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO steering_contexts ( + tenant_id, initiative_id, method_key, method_version, + lifecycle_state, lifecycle_metadata + ) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, tenant_id, initiative_id, method_key, method_version, + lifecycle_state, lifecycle_metadata, created_at, updated_at + """, + ( + tenant_id, + initiative_id, + DEFAULT_METHOD_KEY, + DEFAULT_METHOD_VERSION, + state, + Json({}), + ), + ) + row = _serialize_row(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + + log_audit( + "steering_context.created", + user_id=user_id, + tenant_id=tenant_id, + details={"initiative_id": initiative_id, "lifecycle_state": state}, + ) + return row + + +def update_lifecycle_state( + *, + tenant_id: str, + initiative_id: str, + lifecycle_state: str, + user_id: Optional[str] = None, + reason: str = "", + from_state: Optional[str] = None, +) -> dict[str, Any]: + validate_lifecycle_state(lifecycle_state) + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + UPDATE steering_contexts + SET lifecycle_state = %s, updated_at = NOW() + WHERE tenant_id = %s AND initiative_id = %s + RETURNING id, tenant_id, initiative_id, method_key, method_version, + lifecycle_state, lifecycle_metadata, created_at, updated_at + """, + (lifecycle_state, tenant_id, initiative_id), + ) + row = cur.fetchone() + if not row: + raise ValueError("SteeringContext nicht gefunden") + result = _serialize_row(dict(row)) + conn.commit() + finally: + conn.close() + + log_audit( + "steering_context.lifecycle_transition", + user_id=user_id, + tenant_id=tenant_id, + details={ + "initiative_id": initiative_id, + "from_state": from_state, + "to_state": lifecycle_state, + "reason": reason, + }, + ) + return result + + +def create_context_for_new_initiative( + *, + tenant_id: str, + initiative_id: str, + user_id: Optional[str] = None, +) -> dict[str, Any]: + return ensure_steering_context( + tenant_id=tenant_id, + initiative_id=initiative_id, + initial_state="intake", + user_id=user_id, + ) diff --git a/backend/steering/__init__.py b/backend/steering/__init__.py new file mode 100644 index 0000000..9368807 --- /dev/null +++ b/backend/steering/__init__.py @@ -0,0 +1,15 @@ +"""Adaptive Steering Core — AP1.0 Foundation.""" + +from __future__ import annotations + +from steering.hooks.registry import register_builtin_hooks +from steering.methods.registrations import generic_operating + + +def bootstrap_steering() -> None: + """Register built-in hooks and methods (idempotent).""" + register_builtin_hooks() + generic_operating.register() + + +bootstrap_steering() diff --git a/backend/steering/context.py b/backend/steering/context.py new file mode 100644 index 0000000..23600eb --- /dev/null +++ b/backend/steering/context.py @@ -0,0 +1,35 @@ +"""SteeringContext read helpers — AP1.0.""" + +from __future__ import annotations + +from typing import Any, Optional + +from services import steering_context as sc_service +from steering.lifecycle.states import lifecycle_label +from tenant_context import TenantContext + + +def get_steering_context_dto( + ctx: TenantContext, *, initiative_id: str +) -> Optional[dict[str, Any]]: + row = sc_service.get_steering_context( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + if not row: + row = sc_service.ensure_steering_context( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + ) + return { + **row, + "lifecycle_label": lifecycle_label(row["lifecycle_state"]), + } + + +def get_or_create_for_initiative( + ctx: TenantContext, *, initiative_id: str +) -> dict[str, Any]: + dto = get_steering_context_dto(ctx, initiative_id=initiative_id) + if not dto: + raise ValueError("SteeringContext nicht verfügbar") + return dto diff --git a/backend/steering/hooks/__init__.py b/backend/steering/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/steering/hooks/dispatch.py b/backend/steering/hooks/dispatch.py new file mode 100644 index 0000000..70e2ffb --- /dev/null +++ b/backend/steering/hooks/dispatch.py @@ -0,0 +1,35 @@ +"""Hook dispatch — AP1.0 stub with audit trail.""" + +from __future__ import annotations + +from typing import Any, Optional + +from steering.hooks.registry import get_hook +from tenant_context import TenantContext + + +def dispatch_hook( + slug: str, + ctx: TenantContext | None, + payload: dict[str, Any], + *, + tenant_id: Optional[str] = None, + user_id: Optional[str] = None, +) -> list[dict[str, Any]]: + """Dispatch hook — AP1.0: audit only, no handlers yet.""" + hook = get_hook(slug) + if not hook: + return [{"kind": "hook_unknown", "slug": slug}] + + tid = tenant_id or (ctx.tenant_id if ctx else None) + if tid and user_id: + from services.audit import log_audit + + log_audit( + f"steering.hook.{slug}", + user_id=user_id, + tenant_id=tid, + details={"slug": slug, "payload": payload}, + ) + + return [{"kind": "hook_dispatched", "slug": slug, "lifecycle_step": hook.lifecycle_step}] diff --git a/backend/steering/hooks/registry.py b/backend/steering/hooks/registry.py new file mode 100644 index 0000000..2c3a019 --- /dev/null +++ b/backend/steering/hooks/registry.py @@ -0,0 +1,63 @@ +"""Hook Registry — stabile Einhängepunkte (AP1.0 definitions only).""" + +from __future__ import annotations + +from dataclasses import dataclass + +_HOOKS: dict[str, "HookDefinition"] = {} + + +@dataclass(frozen=True) +class HookDefinition: + slug: str + lifecycle_step: str + description: str + since_version: str = "0.11.0" + + +def register_hook(defn: HookDefinition) -> None: + if defn.slug in _HOOKS: + raise ValueError(f"Hook already registered: {defn.slug}") + _HOOKS[defn.slug] = defn + + +def get_hook(slug: str) -> HookDefinition | None: + return _HOOKS.get(slug) + + +def list_hooks() -> tuple[HookDefinition, ...]: + return tuple(_HOOKS.values()) + + +def clear_hooks_for_tests() -> None: + _HOOKS.clear() + + +def register_builtin_hooks() -> None: + """Slugs aus Target Architecture §7.3 — Handler folgen in AP1.3.""" + builtins = ( + ("on_goal_captured", "intake", "Ziel erfasst"), + ("on_method_selected", "method_selection", "Methode gewählt"), + ("on_structure_required", "structure_setup", "Struktur erforderlich"), + ("on_backlog_required", "structure_setup", "Backlog erforderlich"), + ("on_next_action_requested", "action_selection", "Nächste Aktion angefragt"), + ("on_assignment_required", "assignment", "Zuweisung erforderlich"), + ("on_blocker_open", "waiting", "Blocker gemeldet"), + ("on_blocker_resolved", "action_selection", "Blocker geschlossen"), + ("on_wait_started", "waiting", "Warten begonnen"), + ("on_result_received", "result_intake", "Ergebnis eingegangen"), + ("on_evidence_required", "validation", "Nachweis erforderlich"), + ("on_review_due", "review", "Review fällig"), + ("on_review_completed", "review", "Review abgeschlossen"), + ("on_replan_required", "adaptation", "Anpassung nötig"), + ("on_closure_requested", "closure", "Abschluss angefragt"), + ) + for slug, step, desc in builtins: + if slug not in _HOOKS: + register_hook( + HookDefinition( + slug=slug, + lifecycle_step=step, + description=desc, + ) + ) diff --git a/backend/steering/lifecycle/__init__.py b/backend/steering/lifecycle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/steering/lifecycle/orchestrator.py b/backend/steering/lifecycle/orchestrator.py new file mode 100644 index 0000000..15ea8b3 --- /dev/null +++ b/backend/steering/lifecycle/orchestrator.py @@ -0,0 +1,78 @@ +"""Lifecycle orchestrator — persistierter Standard Lifecycle (AP1.0).""" + +from __future__ import annotations + +from typing import Any, Optional + +from services import steering_context as sc_service +from steering.lifecycle.states import validate_lifecycle_state +from tenant_context import TenantContext + + +def get_lifecycle_state(ctx: TenantContext, initiative_id: str) -> str: + row = sc_service.get_steering_context( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + if row: + return row["lifecycle_state"] + created = sc_service.ensure_steering_context( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + initial_state="intake", + ) + return created["lifecycle_state"] + + +def transition( + ctx: TenantContext | None, + initiative_id: str, + to_state: str, + *, + reason: str = "", + tenant_id: Optional[str] = None, + user_id: Optional[str] = None, +) -> dict[str, Any]: + """Lifecycle-Transition — AP1.0: freier Übergang (Guards in AP1.1).""" + validate_lifecycle_state(to_state) + tid = tenant_id or (ctx.tenant_id if ctx else None) + if not tid: + raise ValueError("tenant_id erforderlich") + + existing = sc_service.get_steering_context(tenant_id=tid, initiative_id=initiative_id) + if not existing: + existing = sc_service.ensure_steering_context( + tenant_id=tid, + initiative_id=initiative_id, + initial_state=to_state, + ) + return { + "initiative_id": initiative_id, + "from_state": None, + "to_state": to_state, + "reason": reason, + } + + from_state = existing["lifecycle_state"] + if from_state == to_state: + return { + "initiative_id": initiative_id, + "from_state": from_state, + "to_state": to_state, + "reason": reason, + "unchanged": True, + } + + updated = sc_service.update_lifecycle_state( + tenant_id=tid, + initiative_id=initiative_id, + lifecycle_state=to_state, + user_id=user_id, + reason=reason, + from_state=from_state, + ) + return { + "initiative_id": initiative_id, + "from_state": from_state, + "to_state": updated["lifecycle_state"], + "reason": reason, + } diff --git a/backend/steering/lifecycle/states.py b/backend/steering/lifecycle/states.py new file mode 100644 index 0000000..ae41ef3 --- /dev/null +++ b/backend/steering/lifecycle/states.py @@ -0,0 +1,42 @@ +"""Standard Lifecycle — gemeinsame Basis (AP1.0).""" + +from __future__ import annotations + +STANDARD_LIFECYCLE_STEPS: tuple[str, ...] = ( + "intake", + "method_selection", + "structure_setup", + "planning", + "action_selection", + "assignment", + "waiting", + "result_intake", + "validation", + "review", + "adaptation", + "closure", +) + +LIFECYCLE_LABELS: dict[str, str] = { + "intake": "Aufnahme", + "method_selection": "Methode wählen", + "structure_setup": "Struktur aufbauen", + "planning": "Planung", + "action_selection": "Maßnahmen auswählen", + "assignment": "Zuweisen", + "waiting": "Warten", + "result_intake": "Ergebnis aufnehmen", + "validation": "Validierung", + "review": "Review", + "adaptation": "Anpassung", + "closure": "Abschluss", +} + + +def lifecycle_label(state: str) -> str: + return LIFECYCLE_LABELS.get(state, state) + + +def validate_lifecycle_state(state: str) -> None: + if state not in STANDARD_LIFECYCLE_STEPS: + raise ValueError(f"Ungültiger Lifecycle-State: {state}") diff --git a/backend/steering/methods/__init__.py b/backend/steering/methods/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/steering/methods/registrations/__init__.py b/backend/steering/methods/registrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/steering/methods/registrations/generic_operating.py b/backend/steering/methods/registrations/generic_operating.py new file mode 100644 index 0000000..ef10683 --- /dev/null +++ b/backend/steering/methods/registrations/generic_operating.py @@ -0,0 +1,19 @@ +"""Built-in method: generic_operating — AP1.0.""" + +from __future__ import annotations + +from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS +from steering.methods.registry import MethodDefinition, get_method, register_method + + +def register() -> None: + if get_method("generic_operating"): + return + register_method( + MethodDefinition( + key="generic_operating", + version="0.1.0", + description="Standard-Lifecycle ohne methodenspezifische Spezialisierung", + default_lifecycle_steps=STANDARD_LIFECYCLE_STEPS, + ) + ) diff --git a/backend/steering/methods/registry.py b/backend/steering/methods/registry.py new file mode 100644 index 0000000..eb13691 --- /dev/null +++ b/backend/steering/methods/registry.py @@ -0,0 +1,33 @@ +"""Method Registry — built-in steering methods (AP1.0).""" + +from __future__ import annotations + +from dataclasses import dataclass + +_METHODS: dict[str, "MethodDefinition"] = {} + + +@dataclass(frozen=True) +class MethodDefinition: + key: str + version: str + description: str + default_lifecycle_steps: tuple[str, ...] + + +def register_method(defn: MethodDefinition) -> None: + if defn.key in _METHODS: + raise ValueError(f"Method already registered: {defn.key}") + _METHODS[defn.key] = defn + + +def get_method(key: str) -> MethodDefinition | None: + return _METHODS.get(key) + + +def list_methods() -> tuple[MethodDefinition, ...]: + return tuple(_METHODS.values()) + + +def clear_methods_for_tests() -> None: + _METHODS.clear() diff --git a/backend/steering/signals/__init__.py b/backend/steering/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/steering/signals/default_rules.py b/backend/steering/signals/default_rules.py new file mode 100644 index 0000000..00e4393 --- /dev/null +++ b/backend/steering/signals/default_rules.py @@ -0,0 +1,706 @@ +"""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", + "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 _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]: + cur.execute( + """ + SELECT + 'milestone_at_risk' AS kind, + 'warning' AS severity, + m.title AS title, + 'Meilenstein als gef├ñhrdet markiert' AS summary, + 'milestone' AS scope_type, + m.id AS scope_id, + m.initiative_id, + NULL::uuid AS action_id, + NULL::uuid AS blocker_id, + m.id AS milestone_id, + 'milestone_at_risk' AS reason_code, + 'milestones' AS data_source + FROM milestones m + WHERE m.tenant_id = %s AND m.status = 'at_risk' + ORDER BY m.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(_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() diff --git a/backend/steering/signals/engine.py b/backend/steering/signals/engine.py new file mode 100644 index 0000000..8046e96 --- /dev/null +++ b/backend/steering/signals/engine.py @@ -0,0 +1,26 @@ +"""Signal Engine — delegates to default rule provider (AP1.0).""" + +from __future__ import annotations + +from typing import Any, Literal + +from steering.signals import default_rules +from tenant_context import TenantContext + +SignalKind = Literal["attention", "next_action"] + + +def evaluate( + ctx: TenantContext, + kind: SignalKind = "attention", + *, + limit: int = 10, + initiative_id: str | None = None, +) -> list[dict[str, Any]]: + if kind == "attention": + return default_rules.get_attention_items(ctx) + if initiative_id: + return default_rules.get_next_action_candidates_for_initiative( + ctx, initiative_id=initiative_id, limit=limit + ) + return default_rules.get_next_action_candidates(ctx, limit=limit) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a6be23c..71b3da4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -69,6 +69,7 @@ def _seed_hygiene(): @pytest.fixture(scope="session", autouse=True) def _sync_rights_registry(): import rights_registrations # noqa: F401 + import steering # noqa: F401 — bootstrap hooks & methods from rights_registry import sync_rights_registry_to_db assert sync_rights_registry_to_db() == 0 diff --git a/backend/tests/test_ap1_steering_foundation.py b/backend/tests/test_ap1_steering_foundation.py new file mode 100644 index 0000000..30c54e2 --- /dev/null +++ b/backend/tests/test_ap1_steering_foundation.py @@ -0,0 +1,91 @@ +"""AP1.0 — Steering Foundation tests.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import ( + _auth, + _create_action, + _create_initiative, + _login, +) + + +def test_steering_context_on_initiative_create(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + res = client.get( + f"/api/initiatives/{initiative_id}/steering-context", + headers=_auth(token), + ) + assert res.status_code == 200 + data = res.json() + assert data["initiative_id"] == initiative_id + assert data["lifecycle_state"] == "intake" + assert data["method_key"] == "generic_operating" + assert data["lifecycle_label"] + + +def test_steering_snapshot_has_lifecycle(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + _create_action(client, token, initiative_id) + + snap = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap.status_code == 200 + data = snap.json() + assert "lifecycle_state" in data + assert data.get("operating_phase_deprecated") is True + assert data["lifecycle_state"] in ( + "intake", + "planning", + "action_selection", + "structure_setup", + ) + + +def test_steering_context_tenant_isolation(client): + user_a = provision_user_in_tenant(tenant_role="member") + token_a = _login(client, user_a) + initiative_id = _create_initiative(client, token_a).json()["id"] + + user_b = provision_user_in_tenant(tenant_role="member") + token_b = _login(client, user_b) + + res = client.get( + f"/api/initiatives/{initiative_id}/steering-context", + headers=_auth(token_b), + ) + assert res.status_code == 404 + + +def test_lifecycle_transition_on_blocker_resolve(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + action = _create_action( + client, token, initiative_id, assigned_actor_ids=[user["actor_id"]] + ).json() + blocker = client.post( + f"/api/initiatives/{initiative_id}/blockers", + json={"title": "B", "action_id": action["id"], "set_action_blocked": True}, + headers=_auth(token), + ).json() + + client.patch( + f"/api/blockers/{blocker['id']}", + json={"status": "resolved"}, + headers=_auth(token), + ) + + ctx = client.get( + f"/api/initiatives/{initiative_id}/steering-context", + headers=_auth(token), + ).json() + assert ctx["lifecycle_state"] == "action_selection" diff --git a/backend/version.py b/backend/version.py index 6e031ce..4dabacb 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.10.1-ap0.10b" -DB_SCHEMA_VERSION = "008" +APP_VERSION = "0.11.0-ap1.0" +DB_SCHEMA_VERSION = "009" APP_NAME = "jinkendo-kairo" diff --git a/docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md b/docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md new file mode 100644 index 0000000..cb1534c --- /dev/null +++ b/docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md @@ -0,0 +1,240 @@ +# Architecture Decision Proposal — AP1.0 Steering Foundation & Scope Lock + +**Status:** angenommen (Umsetzung ausstehend) +**Stand:** 2026-07-05 +**Autor:** Product/Architektur-Review (Cursor-Session) +**Auslöser:** AP0.8–0.10b liefern OM-Inventar + provisorische Steuerungsfragmente ohne `backend/steering/` — Architektur-Kohärenz und MVP-Nachweis fehlen. + +--- + +## Problem + +Kairo hat drei parallele Steuerungsfragmente ohne gemeinsamen Kern: + +| Fragment | Ort | Rolle | +|----------|-----|--------| +| Attention / NextAction | `data_layer/attention.py` | Regelbasierte Signale | +| Transitions | `services/operating_transitions.py` | Blocker/Review Side Effects | +| Operating Phase | `initiative_snapshot._derive_operating_phase` | UI-Heuristik (nicht persistiert) | + +Das **Zielmodell** (Standard Lifecycle, Method Registry, Hook Registry, SteeringContext) ist in Target Architecture und Universal Steering Engine **entschieden**, aber **nicht implementiert** (`backend/steering/` = 0 Dateien). + +Gleichzeitig wurde der dokumentierte Pfad „Inventar → Validation → Steering Core“ **gebrochen**: + +- AP0.10a/b lieferten Integration/UI statt Validation +- Offene Entscheidungen (Universal Steering §33: `steering_contexts`, `roadmap_item_id`) blieben ungeklärt +- `milestones`-Tabelle als MVP-Brücke ohne Roadmap-Konsolidierungs-ADP + +**Leitfrage:** Bauen wir weiter provisorische Enden, die später teuer refactored werden müssen — oder wechseln wir zu **Foundation-first** mit schmalem Steering-Skeleton? + +--- + +## Betroffene Regel + +| Dokument | Regel | +|----------|--------| +| `Kairo_Product_Definition_and_MVP_Reset_v0.1.md` §4 | Kairo ≠ `Initiative → Action` | +| `Kairo_Canonical_Operating_Model_v0.1.md` §8 | Data Layer Read; Services Write | +| `Kairo_Target_Architecture_…_v0.1.md` §7 | Standard Lifecycle als gemeinsame Basis | +| `Kairo_Universal_Steering_Engine_Decision_v0.1.md` §32 | AP0.8 max. Ebene 1; spätere Fähigkeit nicht verbauen | +| `Kairo_Operating_Model_Steering_Bridge_v0.1.md` §6 | Evolutionspfad AP1.0 → AP1.3 | +| `Kairo_Sprint0_Principle_Gate_v0.1.md` G-01 | Verhindert teures Refactoring? | +| `Kairo_MVP_Usability_Recovery_Plan_v0.1.md` §3 | Roadmap-Korrektur (wird durch dieses ADP ersetzt) | + +--- + +## Optionen + +| Option | Kurzbeschreibung | Pro | Contra | +|--------|------------------|-----|--------| +| A | **Weiter wie bisher** — AP0.10c UI, mehr Heuristiken, AP1.0 „irgendwann“ | Kurzfristig sichtbar | Mehr lose Enden; Doppel-Wahrheit Phase/Lifecycle; teurer Refactor | +| B | **Strategiewechsel:** Scope Lock + AP1.0 Skeleton + Konsolidierung | Passt zu Zielarchitektur; eine Steuerungswahrheit | Kurz weniger neue UI-Features | +| C | **Big Bang** — vollständiger Steering Core + Method Registry sofort | Architektonisch „fertig“ | Over-Engineering; ohne Validation falsche Methoden | + +--- + +## Empfehlung + +**Option B — angenommen.** + +Begründung: + +1. OM-Tabellen (AP0.8/0.9) sind **Andockdaten**, kein Wegwerf-Inventar. +2. Was teuer wird, ist **verteilte Steuerungslogik** — nicht die Entitäten. +3. Ein **schmaler AP1.0-Skeleton** (persistierter Lifecycle, Registry-Gerüst, Signal-Engine-Interface) ist die fehlende Foundation, auf die Methoden und Features einrasten. +4. Validation (AP0.10d) **informiert** AP1.1 (erste Built-in-Methode), blockiert AP1.0 nicht. + +--- + +## Scope Lock — ab sofort verbindlich + +### Eingefroren (kein neues Code ohne ADP-Ausnahme) + +| # | Verbot | Begründung | +|---|--------|------------| +| L1 | Neue Steuerungs-Heuristiken in `initiative_snapshot`, Routern, Frontend | Ersetzt durch `lifecycle_state` ab AP1.0 | +| L2 | Duplizierte NextAction-/Attention-Regeln (neue SQL-Varianten) | Konsolidierung in `steering/signals/` | +| L3 | **AP0.10c** Initiative Flow UI (Maßnahmen-Hub, Sektionen) | Erst nach AP1.0a — UI soll `lifecycle_state` lesen, nicht dritte Phase-Logik | +| L4 | Neue OM-Tabellen | Regel aus Recovery Plan bleibt | +| L5 | Workflow Runtime, Method Designer, KI/MCP | Product Direction | +| L6 | `operating_phase` als zweite Wahrheit ausbauen | Nur Anzeige-Proxy bis AP1.2; dann aus `SteeringContext` | + +### Erlaubt ohne ADP + +| # | Erlaubt | +|---|---------| +| E1 | Bugfixes, Tenant-Invarianten, Tests | +| E2 | AP0.10d Validation (Dokument + Checkliste, kein neues Steuerungs-SQL) | +| E3 | AP1.0 Implementierung gemäß Assignment | +| E4 | Refactor bestehender Regeln **in** `backend/steering/` (keine neuen Regeln) | +| E5 | UI-Labels, die `lifecycle_state` / Hook-Signale **anzeigen** (nach AP1.0) | + +--- + +## AP1.0 — Minimaler Steering-Skeleton (nächstes Code-Paket) + +**Ziel:** Eine persistierte Steuerungswahrheit pro Initiative — Methoden können später einhängen. + +**Nicht-Ziel:** Workflow Runtime, Agenten, Method Designer, Roadmap-Konsolidierung, volle Method Packages. + +### A) Schema (Migration 009) + +```text +steering_contexts ( + id, tenant_id, initiative_id UNIQUE, + method_key DEFAULT 'generic_operating', + method_version DEFAULT '0.1.0', + lifecycle_state NOT NULL, -- Standard Lifecycle Slug + lifecycle_metadata JSONB DEFAULT '{}', + created_at, updated_at +) +``` + +**Initial `lifecycle_state`:** bestehende Initiativen → `action_selection` wenn offene Actions, sonst `structure_setup` (Seed/Backfill-Logik im Assignment). + +**Entscheidung Milestone-Brücke:** `milestones`-Tabelle **bleibt** bis AP1.2/ADP-TA-08 — keine Migration 009 zu `roadmap_items`. + +### B) Backend-Modul `backend/steering/` + +```text +steering/ + __init__.py + lifecycle/ + states.py # STANDARD_LIFECYCLE_STEPS (12 Slugs) + orchestrator.py # get_state, transition (minimal, Guards stub) + hooks/ + registry.py # HookDefinition + register_hook (Slugs aus Target Arch §7.3) + dispatch.py # dispatch_hook(slug, ctx, payload) — no-op + audit stub + methods/ + registry.py # MethodDefinition + register_method + registrations/ + generic_operating.py # einzige Built-in-Methode AP1.0 + signals/ + engine.py # evaluate(ctx, scope) → ruft Provider auf + default_rules.py # Move/wrap aus attention.py (keine neuen Regeln) + context.py # get_or_create_steering_context, resolve_for_initiative +``` + +### C) Services-Integration (minimal) + +| Heute | AP1.0 | +|-------|-------| +| `operating_transitions.after_blocker_*` | ruft `dispatch_hook('on_blocker_resolved', …)` + Lifecycle-Transition wenn Guard passt | +| `operating_transitions.after_review_*` | `on_review_completed` | +| `initiative_snapshot.operating_phase` | **Deprecated-Feld**; parallel `lifecycle_state` + `lifecycle_label` aus Context | +| `data_layer/attention.py` | Delegiert an `signals/engine.py` (Wrapper, Datei bleibt für API-Kompatibilität) | + +### D) API (minimal) + +| Endpoint | Beschreibung | +|----------|--------------| +| `GET /api/initiatives/{id}/steering-context` | Context + lifecycle_state + method_key | +| Erweiterung `steering-snapshot` | Felder `lifecycle_state`, `lifecycle_label`; `operating_phase` als deprecated alias | + +### E) Frontend (minimal) + +- Steuerungszustand-Panel: **Lifecycle-Label** aus API (fachliches Label via `operating.js` Mapping auf Standard Lifecycle) +- Kein AP0.10c Maßnahmen-Hub + +### F) Tests + +- SteeringContext tenant-scoped, Cross-Tenant 404 +- Lifecycle-Transition bei Blocker resolved (bestehendes Verhalten, neuer Pfad) +- Signal Engine liefert gleiche Attention-Count wie vor Refactor (Regression) + +### G) Version + +- `0.11.0-ap1.0` · Schema `009` + +--- + +## Konsolidierungs-Roadmap nach AP1.0 + +```text +AP1.0 Steering Foundation (dieses ADP) ← JETZT +AP0.10d MVP Validation (5 Vorhaben) ← parallel / unmittelbar danach +AP1.1 Method Registry + 2. Built-in-Methode (product_milestone_driven) +AP1.2 attention.py vollständig → signals/; operating_phase entfernen +AP1.3 operating_transitions → Hook Orchestrator +AP1.4 Milestone → RoadmapItem (ADP-TA-08) — nur mit separater ADP +AP0.10c Initiative Flow UI ← nach AP1.2 +``` + +--- + +## Was bleibt / was wird refactored / was nicht weggeworfen + +| Kategorie | Inhalt | +|-----------|--------| +| **Bleibt** | OM-Tabellen, Tenant/Actor/Capabilities, Data Layer Pattern, Backlog→Action, CRUD Services | +| **Refactor** | `attention.py`, `operating_transitions.py`, Snapshot-Phase | +| **Deprecated dann entfernen** | `operating_phase` Heuristik (AP1.2) | +| **Spätere ADP** | `milestones` → `roadmap_items`, Project-Entität, generisches Assignment | + +--- + +## Risiko + +| Risiko | Schwere | Mitigation | +|--------|---------|------------| +| AP1.0 verzögert Nutzer-UI | mittel | AP0.10b bleibt; Validation dokumentiert Lücken | +| Refactor bricht Attention-Tests | mittel | Regression-Suite vor Move | +| Lifecycle zu abstrakt | mittel | Fachliche Labels in UI; `generic_operating` Method | +| Doppelmodell Phase + Lifecycle kurz | niedrig | `operating_phase` deprecated; ADP L6 | +| Over-Engineering AP1.0 | mittel | Assignment definiert harte Nicht-Ziele | + +--- + +## Rückbaubarkeit + +- Migration 009 additiv; `steering_contexts` droppbar ohne OM-Tabellen zu berühren +- Wrapper in `attention.py` kann wieder direkt SQL ausführen +- `operating_transitions` bleibt bis AP1.3 als Fallback aufrufbar +- Method Registry mit einem Eintrag — Erweiterung ohne Schema-Change + +--- + +## Auswirkung auf Sprint 0 / Sprint 1 + +| Thema | Auswirkung | +|-------|------------| +| AP0.8–0.9 | **Gültig** — System of Record | +| AP0.10a/b | **Gültig** — Integration/UI; keine weiteren Ausbauten | +| AP0.10c | **Eingefroren** bis AP1.2 | +| AP0.10d | **Freigegeben** — Validation Report als Gate für AP1.1 | +| Sprint 1 Start | AP1.0 = offizieller Sprint-1-Einstieg (Steering Foundation) | +| Foundation AP0.1–0.7 | Unverändert gültig | + +--- + +## Nächste Schritte + +1. ~~ADP (dieses Dokument)~~ — angenommen +2. `docs/sprints/Sprint1_AP1_0_Steering_Foundation_Assignment_v0.1.md` — Implementierungsauftrag +3. `Kairo_MVP_Usability_Recovery_Plan_v0.1.md` §3 — Roadmap an ADP anbinden +4. Umsetzung AP1.0 auf Branch `develop` +5. AP0.10d Validation Report vor AP1.1 + +--- + +*Referenz: KAIRO-ARCH-01, Operating Model Steering Bridge, Universal Steering Engine Decision §31–32* diff --git a/docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md b/docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md index 70d627f..2291a6d 100644 --- a/docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md +++ b/docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md @@ -43,25 +43,23 @@ Das ist **nicht** Feature-Parität mit Todoist — sondern **Steuerungsmehrwert* ## 3. Strategische Korrektur der Roadmap -Die bisherige Reihenfolge: +**Stand 2026-07-05 (Strategiewechsel):** Siehe `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md` + +Die Reihenfolge „Inventar → mehr UI → irgendwann Core“ hat **lose Steuerungsfragmente** erzeugt ohne `backend/steering/`. **Neue Leitlinie: Foundation-first.** ```text -AP0.8 Entitäten → AP0.9 Entitäten → AP0.10 Validation → Sprint 1 Steering Core +AP0.10b Steering UI Minimum ✓ erledigt + Scope Lock aktiv ← keine neuen Heuristiken / kein AP0.10c +AP1.0 Steering Foundation Skeleton ← NÄCHSTES Code-Paket +AP0.10d MVP Validation (5 Vorhaben) ← parallel / unmittelbar nach AP1.0 +AP1.1 Method Registry + 2. Methode +AP1.2 Signals konsolidieren; operating_phase entfernen +AP1.3 operating_transitions → Hook Orchestrator +AP0.10c Initiative Flow UI ← erst nach AP1.2 (lifecycle-aware) +AP1.4 Milestone → RoadmapItem ← separate ADP ``` -**Problem:** Validation auf CRUD-Listen liefert nur „fehlt alles“ — ohne vorherige **Steering Surface**. - -**Korrigierte Reihenfolge:** - -```text -AP0.10b Steering UI Minimum ← zuerst (Nutzen sichtbar) -AP0.10c Initiative Flow Minimum -AP0.10d MVP Validation (5 Vorhaben) -AP1.0 Steering Core Foundation (nur mit Validation-Ergebnis) -AP1.1 Method Registry minimal -``` - -**Regel bis AP1.0:** Keine neuen OM-Tabellen. Nur verbinden, anzeigen, orchestrieren, validieren. +**Regel bis AP1.2:** Keine neuen OM-Tabellen. Keine parallele Steuerungslogik außerhalb `backend/steering/`. --- @@ -82,9 +80,12 @@ AP1.1 Method Registry minimal --- -### AP0.10c — Initiative Flow Minimum (Priorität 2) +### AP0.10c — Initiative Flow Minimum — **EINGEFROREN** -**Ziel:** Vorhaben-Detail beantwortet „Wo stehe ich?“ — nicht „Hier sind 8 Listen“. +**Status:** eingefroren bis AP1.2 (ADP AP1.0 Scope Lock). +**Grund:** UI ohne Lifecycle-Foundation würde dritte Steuerungswahrheit erzeugen. + +**Ziel (später):** Vorhaben-Detail beantwortet „Wo stehe ich?“ — nicht „Hier sind 8 Listen“. | # | Lieferung | Aufwand | Nutzen | |---|-----------|---------|--------| diff --git a/docs/sprints/Sprint1_AP1_0_Steering_Foundation_Assignment_v0.1.md b/docs/sprints/Sprint1_AP1_0_Steering_Foundation_Assignment_v0.1.md new file mode 100644 index 0000000..ce51842 --- /dev/null +++ b/docs/sprints/Sprint1_AP1_0_Steering_Foundation_Assignment_v0.1.md @@ -0,0 +1,224 @@ +# AP1.0 — Steering Foundation (Skeleton) +## Implementierungsauftrag v0.1 + +**Status:** umgesetzt +**Stand:** 2026-07-05 +**Vorgänger:** AP0.10b ✓ +**ADP:** `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md` +**Zielversion:** `0.11.0-ap1.0` · Schema `009` + +--- + +## Ziel + +**Eine persistierte Steuerungswahrheit pro Initiative** — Standard Lifecycle als Foundation, in die Methoden später einhängen. + +Leitfrage bleibt: *Welcher nächste Schritt bringt ein Vorhaben am wirkungsvollsten voran?* — technisch beantwortet über `SteeringContext.lifecycle_state` + Signal Engine, nicht über verstreute Heuristiken. + +--- + +## Scope Lock (verbindlich) + +Siehe ADP § Scope Lock. **Kein AP0.10c.** Keine neuen Attention-Regeln. Keine neuen OM-Tabellen außer `steering_contexts`. + +--- + +## Teil A — Schema 009 + +### Tabelle `steering_contexts` + +```sql +CREATE TABLE steering_contexts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + method_key VARCHAR(64) NOT NULL DEFAULT 'generic_operating', + method_version VARCHAR(32) NOT NULL DEFAULT '0.1.0', + lifecycle_state VARCHAR(64) NOT NULL DEFAULT 'intake', + lifecycle_metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, initiative_id) +); +CREATE INDEX idx_steering_contexts_tenant ON steering_contexts(tenant_id); +``` + +### Backfill (Seed oder Migration-Block) + +Bestehende Initiativen erhalten Context: + +| Bedingung | `lifecycle_state` | +|-----------|-------------------| +| `status IN (completed, archived)` | `closure` | +| offene Actions existieren | `action_selection` | +| nur Backlog (new/triaged/accepted), keine offenen Actions | `structure_setup` | +| sonst active/paused | `planning` | + +--- + +## Teil B — `backend/steering/` + +### B1 — Lifecycle + +**Datei:** `steering/lifecycle/states.py` + +```python +STANDARD_LIFECYCLE_STEPS = ( + "intake", "method_selection", "structure_setup", "planning", + "action_selection", "assignment", "waiting", "result_intake", + "validation", "review", "adaptation", "closure", +) +``` + +**Datei:** `steering/lifecycle/orchestrator.py` + +- `get_lifecycle_state(ctx, initiative_id) -> str` +- `transition(ctx, initiative_id, to_state, *, reason: str) -> dict` — Guard minimal: nur erlaubte Übergänge aus `generic_operating` oder freier Übergang für AP1.0 stub +- Audit-Log bei Transition + +### B2 — Hook Registry + +**Datei:** `steering/hooks/registry.py` + +- `HookDefinition` dataclass (slug, lifecycle_step, description, since_version) +- `register_hook()`, `get_hook()`, `list_hooks()` +- Registrierung der Slugs aus Target Architecture §7.3 (Definition only, Implementierung stub) + +**Datei:** `steering/hooks/dispatch.py` + +- `dispatch_hook(slug, ctx, payload) -> list[dict]` — ruft registrierte Handler; AP1.0: leere Handler + Audit-Eintrag + +### B3 — Method Registry + +**Datei:** `steering/methods/registry.py` + `registrations/generic_operating.py` + +- Eine Built-in-Methode: `generic_operating` +- Liefert: `default_lifecycle_steps`, erlaubte Hooks (alle globalen), keine Structure Builder + +### B4 — Signal Engine + +**Datei:** `steering/signals/default_rules.py` + +- Bestehende Regeln aus `data_layer/attention.py` **verschieben** (nicht duplizieren) +- `evaluate_attention(ctx) -> list[dict]` +- `evaluate_next_actions(ctx, *, limit, initiative_id=None) -> list[dict]` + +**Datei:** `steering/signals/engine.py` + +- `evaluate(ctx, kind='attention'|'next_action', **kwargs)` — delegiert an default_rules + +**Datei:** `data_layer/attention.py` + +- Wird dünner Wrapper um `steering.signals.engine` (API-Kompatibilität) + +### B5 — Steering Context Service + +**Datei:** `steering/context.py` + `services/steering_context.py` (Write) + +- `get_or_create_for_initiative(ctx, initiative_id)` +- `get_steering_context(ctx, initiative_id) -> Optional[dict]` +- Tenant-scoped; Cross-Tenant → None / 404 + +--- + +## Teil C — Integration bestehender Flows + +### C1 — `operating_transitions.py` + +Vor/nach bestehender Logik: + +```python +from steering.hooks.dispatch import dispatch_hook +dispatch_hook("on_blocker_resolved", ctx, {...}) +# optional: lifecycle transition assignment → action_selection +``` + +Verhalten Blocker→entblocken **unverändert** (Regression-Tests). + +### C2 — `initiative_snapshot.py` + +Snapshot ergänzt: + +```json +{ + "lifecycle_state": "action_selection", + "lifecycle_label": "Maßnahmen auswählen", + "operating_phase": "execute", + "operating_phase_deprecated": true +} +``` + +`_derive_operating_phase` bleibt für Rückwärtskompatibilität; markiert deprecated in Docstring. + +### C3 — Initiative Create + +Bei `create_initiative` → `steering_contexts` Zeile anlegen (`lifecycle_state=intake` oder Backfill-Regel). + +--- + +## Teil D — API + +| Endpoint | Capability | Beschreibung | +|----------|------------|--------------| +| `GET /api/initiatives/{id}/steering-context` | `kairo.initiative.read` | Context DTO | +| `GET .../steering-snapshot` | (bestehend) | + lifecycle Felder | + +--- + +## Teil E — Frontend (minimal) + +**Datei:** `frontend/src/constants/operating.js` + +- `LIFECYCLE_LABELS` für 12 Standard-Schritte (DE) +- Steuerungszustand-Panel: primär `lifecycle_label`; `operating_phase` nur Fallback + +Kein Maßnahmen-Hub. Keine neuen Widgets. + +--- + +## Teil F — Tests + +| Test | Erwartung | +|------|-----------| +| `test_ap1_steering_context.py` | CRUD Context, tenant isolation | +| `test_ap1_lifecycle.py` | Transition + Audit | +| `test_ap1_signals_regression.py` | Attention/NextAction gleich wie vor Refactor | +| `test_ap10_integration.py` | weiter grün; Snapshot hat lifecycle_state | + +--- + +## Nicht-Scope + +- Workflow Runtime, Waiting/Reminder Scheduler +- Zweite Built-in-Methode (`product_milestone_driven`) → AP1.1 +- `roadmap_items`, `projects`, `milestones`-Migration +- AP0.10c UI +- Prompt/KI/MCP +- Entfernen von `operating_phase` → AP1.2 + +--- + +## Abnahme + +1. Migration 009 deployed; bestehende Initiativen haben Context +2. `backend/steering/` existiert mit Lifecycle, Hooks, Methods, Signals +3. Attention/NextAction APIs unverändert im Verhalten (Regression) +4. Snapshot + neuer steering-context Endpoint liefern `lifecycle_state` +5. Scope Lock aus ADP eingehalten (kein AP0.10c) +6. pytest grün remote + +--- + +## Reihenfolge Implementierung + +1. Migration 009 + Backfill +2. `steering/lifecycle`, `steering/context` +3. Initiative-Create Hook +4. `steering/signals` Refactor aus attention.py +5. Hook dispatch in operating_transitions +6. API + Snapshot + Frontend Labels +7. Tests + Version bump + +--- + +*Nächster Schritt nach Abnahme: AP0.10d Validation Report, dann AP1.1 Method Registry erweitern* diff --git a/frontend/package.json b/frontend/package.json index 2365355..d9798bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.10.1-ap0.10b", + "version": "0.11.0-ap1.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/SteeringSnapshotPanel.jsx b/frontend/src/components/SteeringSnapshotPanel.jsx index 609ff0d..899285d 100644 --- a/frontend/src/components/SteeringSnapshotPanel.jsx +++ b/frontend/src/components/SteeringSnapshotPanel.jsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' import { + LIFECYCLE_LABELS, PHASE_LABELS, PHASE_DESCRIPTIONS, SIGNAL_LABELS, @@ -39,20 +40,30 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) { } const { + lifecycle_state, + lifecycle_label, operating_phase, + operating_phase_deprecated, phase_signals, counts, upcoming_milestones = [], next_actions = [], } = snapshot - const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase - const phaseDesc = PHASE_DESCRIPTIONS[operating_phase] || '' + const displayLabel = + lifecycle_label || + LIFECYCLE_LABELS[lifecycle_state] || + PHASE_LABELS[operating_phase] || + lifecycle_state || + operating_phase + const phaseDesc = lifecycle_state + ? null + : PHASE_DESCRIPTIONS[operating_phase] || '' return (

Steuerungszustand

- {phaseLabel} + {displayLabel}
{phaseDesc &&

{phaseDesc}

} @@ -130,7 +141,8 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {

- Heuristischer Steuerungszustand — später methodengeführt über Steering Core. + Steuerungszustand über Standard Lifecycle + {lifecycle_state ? ` (${lifecycle_state})` : ''}.

) diff --git a/frontend/src/constants/operating.js b/frontend/src/constants/operating.js index 07ca56c..ba0fe62 100644 --- a/frontend/src/constants/operating.js +++ b/frontend/src/constants/operating.js @@ -1,3 +1,18 @@ +export const LIFECYCLE_LABELS = { + intake: 'Aufnahme', + method_selection: 'Methode wählen', + structure_setup: 'Struktur aufbauen', + planning: 'Planung', + action_selection: 'Maßnahmen auswählen', + assignment: 'Zuweisen', + waiting: 'Warten', + result_intake: 'Ergebnis aufnehmen', + validation: 'Validierung', + review: 'Review', + adaptation: 'Anpassung', + closure: 'Abschluss', +} + export const OPERATING_PHASES = [ 'capture', 'triage',