AP1.0: Steering Foundation — Lifecycle, Context, Signal Engine
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m19s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 11s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m19s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 11s
Persistierter Standard Lifecycle pro Initiative, backend/steering/ Skeleton, Attention-Refactor und Hook-Dispatch als Basis für Method Registry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
451d62ed46
commit
95feba6204
|
|
@ -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.
|
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/`.
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,12 @@ Operating Model Integration (AP0.10, Schema weiter `008`):
|
||||||
|
|
||||||
| Endpoint | Methode | Capability | Beschreibung |
|
| 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`
|
**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`**
|
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`
|
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||||
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Literal, Optional
|
from steering.signals.engine import evaluate
|
||||||
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
|
|
||||||
from db import get_connection
|
|
||||||
from tenant_context import TenantContext
|
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[
|
def get_attention_items(ctx: TenantContext):
|
||||||
"assign_action",
|
return evaluate(ctx, kind="attention")
|
||||||
"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]:
|
def get_next_action_candidates(ctx: TenantContext, *, limit: int = 10):
|
||||||
item = dict(row)
|
return evaluate(ctx, kind="next_action", limit=limit)
|
||||||
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(
|
def get_next_action_candidates_for_initiative(
|
||||||
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
||||||
) -> list[dict[str, Any]]:
|
):
|
||||||
"""NextActionCandidates für ein Vorhaben — tenant-scoped."""
|
return evaluate(
|
||||||
if limit < 1:
|
ctx,
|
||||||
limit = 1
|
kind="next_action",
|
||||||
if limit > 20:
|
limit=limit,
|
||||||
limit = 20
|
initiative_id=initiative_id,
|
||||||
|
)
|
||||||
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()
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
from db import get_connection
|
from db import get_connection
|
||||||
from data_layer.attention import get_next_action_candidates_for_initiative
|
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 services.initiatives import get_initiative
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
@ -310,11 +311,18 @@ def get_initiative_steering_snapshot(
|
||||||
ctx, initiative_id=initiative_id, limit=5
|
ctx, initiative_id=initiative_id, limit=5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"initiative_id": initiative_id,
|
"initiative_id": initiative_id,
|
||||||
"initiative_title": initiative["title"],
|
"initiative_title": initiative["title"],
|
||||||
"initiative_status": initiative["status"],
|
"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": phase,
|
||||||
|
"operating_phase_deprecated": True,
|
||||||
"phase_signals": phase_signals,
|
"phase_signals": phase_signals,
|
||||||
"upcoming_milestones": upcoming_milestones,
|
"upcoming_milestones": upcoming_milestones,
|
||||||
"next_actions": next_actions,
|
"next_actions": next_actions,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
from db import check_db
|
from db import check_db
|
||||||
from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION
|
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("KAIRO_DB_READY") != "1":
|
||||||
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
|
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
|
||||||
import run_migrations
|
import run_migrations
|
||||||
|
|
|
||||||
41
backend/migrations/009_steering_contexts.sql
Normal file
41
backend/migrations/009_steering_contexts.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -9,6 +9,7 @@ from capabilities import require_capability
|
||||||
from data_layer import initiative_snapshot as dl_initiative_snapshot
|
from data_layer import initiative_snapshot as dl_initiative_snapshot
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from steering.context import get_steering_context_dto
|
||||||
from services import actions as action_service
|
from services import actions as action_service
|
||||||
from services import backlog as backlog_service
|
from services import backlog as backlog_service
|
||||||
from services import blockers as blocker_service
|
from services import blockers as blocker_service
|
||||||
|
|
@ -168,6 +169,18 @@ def get_initiative_steering_snapshot(
|
||||||
return 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}")
|
@router.patch("/{initiative_id}")
|
||||||
def update_initiative(
|
def update_initiative(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,7 @@ def update_blocker(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
blocker_id=blocker_id,
|
blocker_id=blocker_id,
|
||||||
action_id=result.get("action_id"),
|
action_id=result.get("action_id"),
|
||||||
|
initiative_id=result.get("initiative_id"),
|
||||||
old_status=old_status,
|
old_status=old_status,
|
||||||
new_status=status,
|
new_status=status,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,13 @@ def create_initiative(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
details={"initiative_id": row["id"], "title": title, "status": status},
|
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
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
"""Minimale Operating-Transitions — Vorstufe zum Steering Orchestrator (AP0.10a).
|
"""Minimale Operating-Transitions — AP1.0 mit Hook Dispatch."""
|
||||||
|
|
||||||
Side Effects bei Statusänderungen. Später: Hook Registry + Method Strategies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -10,6 +7,7 @@ from typing import Any, Optional
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
from db import get_connection
|
from db import get_connection
|
||||||
|
from steering.hooks.dispatch import dispatch_hook
|
||||||
|
|
||||||
RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"})
|
RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"})
|
||||||
|
|
||||||
|
|
@ -19,6 +17,7 @@ def after_blocker_status_change(
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
blocker_id: str,
|
blocker_id: str,
|
||||||
action_id: Optional[str],
|
action_id: Optional[str],
|
||||||
|
initiative_id: Optional[str] = None,
|
||||||
old_status: str,
|
old_status: str,
|
||||||
new_status: str,
|
new_status: str,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
|
|
@ -86,6 +85,31 @@ def after_blocker_status_change(
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
if effects and user_id:
|
||||||
from services.audit import log_audit
|
from services.audit import log_audit
|
||||||
|
|
||||||
|
|
@ -103,6 +127,7 @@ def after_review_status_change(
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
review_id: str,
|
review_id: str,
|
||||||
action_id: Optional[str],
|
action_id: Optional[str],
|
||||||
|
initiative_id: Optional[str] = None,
|
||||||
old_status: str,
|
old_status: str,
|
||||||
new_status: str,
|
new_status: str,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
|
|
@ -137,6 +162,20 @@ def after_review_status_change(
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
if effects and user_id:
|
||||||
from services.audit import log_audit
|
from services.audit import log_audit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,7 @@ def update_review(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
review_id=review_id,
|
review_id=review_id,
|
||||||
action_id=result.get("action_id"),
|
action_id=result.get("action_id"),
|
||||||
|
initiative_id=result.get("initiative_id"),
|
||||||
old_status=old_status,
|
old_status=old_status,
|
||||||
new_status=status,
|
new_status=status,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|
|
||||||
208
backend/services/steering_context.py
Normal file
208
backend/services/steering_context.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
15
backend/steering/__init__.py
Normal file
15
backend/steering/__init__.py
Normal file
|
|
@ -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()
|
||||||
35
backend/steering/context.py
Normal file
35
backend/steering/context.py
Normal file
|
|
@ -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
|
||||||
0
backend/steering/hooks/__init__.py
Normal file
0
backend/steering/hooks/__init__.py
Normal file
35
backend/steering/hooks/dispatch.py
Normal file
35
backend/steering/hooks/dispatch.py
Normal file
|
|
@ -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}]
|
||||||
63
backend/steering/hooks/registry.py
Normal file
63
backend/steering/hooks/registry.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
0
backend/steering/lifecycle/__init__.py
Normal file
0
backend/steering/lifecycle/__init__.py
Normal file
78
backend/steering/lifecycle/orchestrator.py
Normal file
78
backend/steering/lifecycle/orchestrator.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
42
backend/steering/lifecycle/states.py
Normal file
42
backend/steering/lifecycle/states.py
Normal file
|
|
@ -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}")
|
||||||
0
backend/steering/methods/__init__.py
Normal file
0
backend/steering/methods/__init__.py
Normal file
0
backend/steering/methods/registrations/__init__.py
Normal file
0
backend/steering/methods/registrations/__init__.py
Normal file
19
backend/steering/methods/registrations/generic_operating.py
Normal file
19
backend/steering/methods/registrations/generic_operating.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
33
backend/steering/methods/registry.py
Normal file
33
backend/steering/methods/registry.py
Normal file
|
|
@ -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()
|
||||||
0
backend/steering/signals/__init__.py
Normal file
0
backend/steering/signals/__init__.py
Normal file
706
backend/steering/signals/default_rules.py
Normal file
706
backend/steering/signals/default_rules.py
Normal file
|
|
@ -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()
|
||||||
26
backend/steering/signals/engine.py
Normal file
26
backend/steering/signals/engine.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -69,6 +69,7 @@ def _seed_hygiene():
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def _sync_rights_registry():
|
def _sync_rights_registry():
|
||||||
import rights_registrations # noqa: F401
|
import rights_registrations # noqa: F401
|
||||||
|
import steering # noqa: F401 — bootstrap hooks & methods
|
||||||
from rights_registry import sync_rights_registry_to_db
|
from rights_registry import sync_rights_registry_to_db
|
||||||
|
|
||||||
assert sync_rights_registry_to_db() == 0
|
assert sync_rights_registry_to_db() == 0
|
||||||
|
|
|
||||||
91
backend/tests/test_ap1_steering_foundation.py
Normal file
91
backend/tests/test_ap1_steering_foundation.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.10.1-ap0.10b"
|
APP_VERSION = "0.11.0-ap1.0"
|
||||||
DB_SCHEMA_VERSION = "008"
|
DB_SCHEMA_VERSION = "009"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -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*
|
||||||
|
|
@ -43,25 +43,23 @@ Das ist **nicht** Feature-Parität mit Todoist — sondern **Steuerungsmehrwert*
|
||||||
|
|
||||||
## 3. Strategische Korrektur der Roadmap
|
## 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
|
```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**.
|
**Regel bis AP1.2:** Keine neuen OM-Tabellen. Keine parallele Steuerungslogik außerhalb `backend/steering/`.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -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 |
|
| # | Lieferung | Aufwand | Nutzen |
|
||||||
|---|-----------|---------|--------|
|
|---|-----------|---------|--------|
|
||||||
|
|
|
||||||
|
|
@ -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*
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.10.1-ap0.10b",
|
"version": "0.11.0-ap1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
|
LIFECYCLE_LABELS,
|
||||||
PHASE_LABELS,
|
PHASE_LABELS,
|
||||||
PHASE_DESCRIPTIONS,
|
PHASE_DESCRIPTIONS,
|
||||||
SIGNAL_LABELS,
|
SIGNAL_LABELS,
|
||||||
|
|
@ -39,20 +40,30 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
lifecycle_state,
|
||||||
|
lifecycle_label,
|
||||||
operating_phase,
|
operating_phase,
|
||||||
|
operating_phase_deprecated,
|
||||||
phase_signals,
|
phase_signals,
|
||||||
counts,
|
counts,
|
||||||
upcoming_milestones = [],
|
upcoming_milestones = [],
|
||||||
next_actions = [],
|
next_actions = [],
|
||||||
} = snapshot
|
} = snapshot
|
||||||
const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase
|
const displayLabel =
|
||||||
const phaseDesc = PHASE_DESCRIPTIONS[operating_phase] || ''
|
lifecycle_label ||
|
||||||
|
LIFECYCLE_LABELS[lifecycle_state] ||
|
||||||
|
PHASE_LABELS[operating_phase] ||
|
||||||
|
lifecycle_state ||
|
||||||
|
operating_phase
|
||||||
|
const phaseDesc = lifecycle_state
|
||||||
|
? null
|
||||||
|
: PHASE_DESCRIPTIONS[operating_phase] || ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card steering-snapshot">
|
<section className="card steering-snapshot">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Steuerungszustand</h2>
|
<h2>Steuerungszustand</h2>
|
||||||
<span className="badge status-badge status-active">{phaseLabel}</span>
|
<span className="badge status-badge status-active">{displayLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
|
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
|
||||||
|
|
@ -130,7 +141,8 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<p className="muted snapshot-hint">
|
<p className="muted snapshot-hint">
|
||||||
Heuristischer Steuerungszustand — später methodengeführt über Steering Core.
|
Steuerungszustand über Standard Lifecycle
|
||||||
|
{lifecycle_state ? ` (${lifecycle_state})` : ''}.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
export const OPERATING_PHASES = [
|
||||||
'capture',
|
'capture',
|
||||||
'triage',
|
'triage',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user