feat(steering): Kernel Spine v0.1.1 als zentraler Initiative-Einstieg
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Failing after 3m33s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Failing after 3m33s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
evaluate_steering() vereinheitlicht Horizon, Next Work und Attention; Snapshot und Signal Engine delegieren dorthin; slot_map deklarativ fuer sequential_dependency und generic_operating. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
753f178b0c
commit
75a6a63665
|
|
@ -6,7 +6,7 @@ alwaysApply: true
|
||||||
|
|
||||||
# Kairo Plugin-Architektur (AP2.3 / AP2.4)
|
# Kairo Plugin-Architektur (AP2.3 / AP2.4)
|
||||||
|
|
||||||
Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md`, `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`
|
Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md`, `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, `docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md`
|
||||||
|
|
||||||
## Vier Schichten — Auflösung zur Laufzeit
|
## Vier Schichten — Auflösung zur Laufzeit
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v
|
||||||
| **Archetyp** | `entity_archetypes` + `ui_profile_json` | Nav, Routen, Plan-Outline, `om_capabilities` |
|
| **Archetyp** | `entity_archetypes` + `ui_profile_json` | Nav, Routen, Plan-Outline, `om_capabilities` |
|
||||||
| **Methode** | `steering/methods/registrations/` | `data_slices`, `steering_elements`, `ui_features`, `graph_profile`, Strategie |
|
| **Methode** | `steering/methods/registrations/` | `data_slices`, `steering_elements`, `ui_features`, `graph_profile`, Strategie |
|
||||||
| **Element** | `steering/elements/registry.py` + FE `steeringElementRegistry.js` | Control-/Work-Bausteine |
|
| **Element** | `steering/elements/registry.py` + FE `steeringElementRegistry.js` | Control-/Work-Bausteine |
|
||||||
| **Runtime** | `GET /api/initiatives/:id/operating-context` | Schnittmenge + Methoden-Vertrag |
|
| **Runtime** | `GET /api/initiatives/:id/operating-context` + **`steering/kernel/evaluate_steering()`** | Plugin-Auflösung + **Herzmaschine** |
|
||||||
|
|
||||||
```text
|
```text
|
||||||
data_slices = archetype.om_capabilities ∩ method.data_slices
|
data_slices = archetype.om_capabilities ∩ method.data_slices
|
||||||
|
|
@ -48,6 +48,7 @@ Archetyp-Default-Methode ist **Empfehlung**, nicht Lock — kompatible Methoden
|
||||||
- Hardcodierte Methoden-UI in `methodUiDefaults.js` für **neue** Archetypen
|
- Hardcodierte Methoden-UI in `methodUiDefaults.js` für **neue** Archetypen
|
||||||
- Parallele Steuerungslogik außerhalb `backend/steering/`
|
- Parallele Steuerungslogik außerhalb `backend/steering/`
|
||||||
- Methoden-Dropdown ohne Archetyp-Filter (`GET /steering/methods?archetype_key=…`)
|
- Methoden-Dropdown ohne Archetyp-Filter (`GET /steering/methods?archetype_key=…`)
|
||||||
|
- **Initiative-Steuerung** (Next, Attention) außerhalb `steering/kernel/evaluate_steering()` — siehe `ADP_Steering_Kernel_Spine_v0.1.md`
|
||||||
|
|
||||||
## Fallback (legacy)
|
## Fallback (legacy)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ 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 data_layer.attention import get_next_action_candidates_for_initiative
|
|
||||||
from entity_archetypes.registry import archetype_label
|
from entity_archetypes.registry import archetype_label
|
||||||
from method_profiles.registry import get_method_profile
|
from method_profiles.registry import get_method_profile
|
||||||
from steering.context import get_steering_context_dto
|
from steering.context import get_steering_context_dto
|
||||||
|
from steering.kernel import evaluate_steering
|
||||||
from steering.signals.snapshot_signals import derive_initiative_signals
|
from steering.signals.snapshot_signals import derive_initiative_signals
|
||||||
from services.initiatives import get_initiative
|
from services.initiatives import get_initiative
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
@ -293,9 +293,9 @@ def get_initiative_steering_snapshot(
|
||||||
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
|
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
|
||||||
)[:5]
|
)[:5]
|
||||||
|
|
||||||
next_actions = get_next_action_candidates_for_initiative(
|
evaluation = evaluate_steering(ctx, initiative_id=initiative_id, next_work_limit=5)
|
||||||
ctx, initiative_id=initiative_id, limit=5
|
next_actions = evaluation.next_work
|
||||||
)
|
kernel_attention = evaluation.attention
|
||||||
|
|
||||||
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||||
metadata = steering.get("lifecycle_metadata") or {}
|
metadata = steering.get("lifecycle_metadata") or {}
|
||||||
|
|
@ -335,8 +335,22 @@ def get_initiative_steering_snapshot(
|
||||||
signals=signals,
|
signals=signals,
|
||||||
method_key=steering["method_key"],
|
method_key=steering["method_key"],
|
||||||
),
|
),
|
||||||
"attention_items": _attention_items(signals),
|
"attention_items": _attention_items(signals)
|
||||||
|
+ [
|
||||||
|
{
|
||||||
|
"code": item.get("reason_code") or item.get("kind", "attention"),
|
||||||
|
"label": item.get("summary") or item.get("title") or "Attention",
|
||||||
|
}
|
||||||
|
for item in kernel_attention[:5]
|
||||||
|
],
|
||||||
"signals": signals,
|
"signals": signals,
|
||||||
|
"steering_kernel": {
|
||||||
|
"horizon": evaluation.horizon.to_dict(),
|
||||||
|
"lifecycle": evaluation.lifecycle.to_dict(),
|
||||||
|
"primary_method_key": evaluation.binding.primary_method_key,
|
||||||
|
"composition_modifier": evaluation.binding.composition_modifier,
|
||||||
|
"data_source": evaluation.data_source,
|
||||||
|
},
|
||||||
"upcoming_milestones": upcoming_milestones,
|
"upcoming_milestones": upcoming_milestones,
|
||||||
"upcoming_roadmap_items": upcoming_milestones,
|
"upcoming_roadmap_items": upcoming_milestones,
|
||||||
"next_actions": next_actions,
|
"next_actions": next_actions,
|
||||||
|
|
|
||||||
14
backend/steering/kernel/__init__.py
Normal file
14
backend/steering/kernel/__init__.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Steering Kernel Spine v0.1 — universal runtime entry for initiative steering."""
|
||||||
|
|
||||||
|
from steering.kernel.evaluate import evaluate_steering
|
||||||
|
from steering.kernel.events import apply_steering_event
|
||||||
|
from steering.kernel.models import HorizonMarker, LifecycleContext, SteeringBinding, SteeringEvaluation
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HorizonMarker",
|
||||||
|
"LifecycleContext",
|
||||||
|
"SteeringBinding",
|
||||||
|
"SteeringEvaluation",
|
||||||
|
"apply_steering_event",
|
||||||
|
"evaluate_steering",
|
||||||
|
]
|
||||||
162
backend/steering/kernel/attention.py
Normal file
162
backend/steering/kernel/attention.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Attention evaluation (Q5) — method- and horizon-aware for one initiative."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from steering.kernel.models import HorizonMarker, SteeringBinding
|
||||||
|
from steering.strategies.next_action.execution_ready import gate_horizon_scope_for_method
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
_GATE_METHODS = frozenset(
|
||||||
|
{
|
||||||
|
"sequential_dependency",
|
||||||
|
"program_delivery",
|
||||||
|
"maturity_progression",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_attention(
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
initiative_id: str,
|
||||||
|
binding: SteeringBinding,
|
||||||
|
horizon: HorizonMarker,
|
||||||
|
next_work: list[dict[str, Any]],
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Kernel attention for a single initiative — no fake next when planning debt."""
|
||||||
|
from services import actions as action_service
|
||||||
|
from services import blockers as blocker_service
|
||||||
|
from services import roadmap as roadmap_service
|
||||||
|
from services.execution_plan import list_dependencies_for_initiative
|
||||||
|
from steering.graph.execution_engine import (
|
||||||
|
compute_execution_graph_state,
|
||||||
|
compute_planning_debt,
|
||||||
|
execution_waiting_to_attention_items,
|
||||||
|
planning_debt_to_attention_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
blockers = blocker_service.list_blockers_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
for blocker in blockers:
|
||||||
|
if blocker.get("status") not in ("open", "in_progress"):
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "open_blocker",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": blocker.get("title") or "Blocker",
|
||||||
|
"summary": "Offener Blocker im Vorhaben",
|
||||||
|
"scope_type": "blocker",
|
||||||
|
"scope_id": str(blocker["id"]),
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"action_id": (
|
||||||
|
str(blocker["action_id"]) if blocker.get("action_id") else None
|
||||||
|
),
|
||||||
|
"blocker_id": str(blocker["id"]),
|
||||||
|
"reason_code": "path_blocked",
|
||||||
|
"data_source": "steering_kernel",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = action_service.list_actions_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
for action in actions:
|
||||||
|
if action.get("status") != "blocked":
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "blocked_action",
|
||||||
|
"severity": "critical",
|
||||||
|
"title": action.get("title") or "Arbeitspaket",
|
||||||
|
"summary": "Maßnahme ist blockiert",
|
||||||
|
"scope_type": "action",
|
||||||
|
"scope_id": str(action["id"]),
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"action_id": str(action["id"]),
|
||||||
|
"reason_code": "path_blocked",
|
||||||
|
"data_source": "steering_kernel",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
gate_scope = gate_horizon_scope_for_method(
|
||||||
|
binding.primary_method_key, ctx, initiative_id
|
||||||
|
)
|
||||||
|
roadmap_items = roadmap_service.list_roadmap_items_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
|
||||||
|
scoped_roadmap = roadmap_items
|
||||||
|
scoped_actions = actions
|
||||||
|
if gate_scope:
|
||||||
|
scope = str(gate_scope)
|
||||||
|
scoped_roadmap = [ri for ri in roadmap_items if str(ri.get("id")) == scope]
|
||||||
|
scoped_actions = [
|
||||||
|
a
|
||||||
|
for a in actions
|
||||||
|
if a.get("roadmap_item_id") and str(a["roadmap_item_id"]) == scope
|
||||||
|
]
|
||||||
|
|
||||||
|
if binding.primary_method_key in _GATE_METHODS:
|
||||||
|
debts = compute_planning_debt(
|
||||||
|
actions=scoped_actions, roadmap_items=scoped_roadmap
|
||||||
|
)
|
||||||
|
items.extend(
|
||||||
|
planning_debt_to_attention_items(
|
||||||
|
initiative_id=initiative_id, debts=debts
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dependencies = list_dependencies_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
graph_state = compute_execution_graph_state(
|
||||||
|
actions=actions,
|
||||||
|
dependencies=dependencies,
|
||||||
|
scope_roadmap_item_id=gate_scope,
|
||||||
|
)
|
||||||
|
items.extend(
|
||||||
|
execution_waiting_to_attention_items(
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
actions=actions,
|
||||||
|
graph_state=graph_state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not next_work
|
||||||
|
and binding.primary_method_key in _GATE_METHODS
|
||||||
|
and horizon.kind == "gate"
|
||||||
|
):
|
||||||
|
has_planning = any(i.get("reason_code") == "planning_debt" for i in items)
|
||||||
|
has_waiting = any(i.get("reason_code") == "execution_waiting" for i in items)
|
||||||
|
if not has_planning and not has_waiting and not graph_state.get("ready_actions"):
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "initiative_without_next_action",
|
||||||
|
"severity": "info",
|
||||||
|
"title": horizon.gate_title or "Aktiver Horizont",
|
||||||
|
"summary": "Keine ausführungsbereite Arbeit im aktiven Horizont",
|
||||||
|
"scope_type": "milestone",
|
||||||
|
"scope_id": horizon.gate_roadmap_item_id,
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"reason_code": "horizon_no_ready_work",
|
||||||
|
"data_source": "steering_kernel",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item.setdefault("data_source", "steering_kernel")
|
||||||
|
|
||||||
|
items.sort(
|
||||||
|
key=lambda x: {"critical": 0, "warning": 1, "info": 2}.get(
|
||||||
|
x.get("severity", "info"), 9
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items[:limit]
|
||||||
52
backend/steering/kernel/binding.py
Normal file
52
backend/steering/kernel/binding.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""Resolve primary method and composition modifier."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services import steering_context as sc_service
|
||||||
|
from services.work_cycle import get_active_work_cycle
|
||||||
|
from steering.kernel.models import SteeringBinding
|
||||||
|
from steering.methods.registry import get_method
|
||||||
|
from steering.strategies.next_action.default_strategy import default_strategy
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
_AGILE_KEY = "agile_iteration"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_primary_method_key(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["method_key"]
|
||||||
|
return "generic_operating"
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_composes_with_agile(primary_method_key: str) -> bool:
|
||||||
|
agile = get_method(_AGILE_KEY)
|
||||||
|
if not agile:
|
||||||
|
return False
|
||||||
|
return primary_method_key in agile.composes_with
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_steering_binding(ctx: TenantContext, initiative_id: str) -> SteeringBinding:
|
||||||
|
primary_key = resolve_primary_method_key(ctx, initiative_id)
|
||||||
|
primary = get_method(primary_key)
|
||||||
|
primary_strategy = (
|
||||||
|
primary.next_action_strategy_key if primary else default_strategy.key
|
||||||
|
)
|
||||||
|
|
||||||
|
composition: str | None = None
|
||||||
|
strategy_key = primary_strategy
|
||||||
|
|
||||||
|
active_cycle = get_active_work_cycle(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
if active_cycle and _primary_composes_with_agile(primary_key):
|
||||||
|
composition = _AGILE_KEY
|
||||||
|
strategy_key = _AGILE_KEY
|
||||||
|
|
||||||
|
return SteeringBinding(
|
||||||
|
primary_method_key=primary_key,
|
||||||
|
composition_modifier=composition,
|
||||||
|
next_work_strategy_key=strategy_key,
|
||||||
|
)
|
||||||
66
backend/steering/kernel/evaluate.py
Normal file
66
backend/steering/kernel/evaluate.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Steering Kernel Spine — single entry point v0.1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from steering.kernel.attention import evaluate_attention
|
||||||
|
from steering.kernel.binding import resolve_steering_binding
|
||||||
|
from steering.kernel.horizon import resolve_horizon
|
||||||
|
from steering.kernel.lifecycle import resolve_lifecycle_context
|
||||||
|
from steering.kernel.models import SteeringEvaluation
|
||||||
|
from steering.kernel.next_work import evaluate_next_work
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_steering(
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
initiative_id: str,
|
||||||
|
next_work_limit: int = 10,
|
||||||
|
attention_limit: int = 20,
|
||||||
|
) -> SteeringEvaluation:
|
||||||
|
"""
|
||||||
|
Herzmaschine — ein Wahrheitsmodell pro Vorhaben.
|
||||||
|
|
||||||
|
Alle Initiative-Steuerung (Snapshot, Cockpit, API) muss hier durch.
|
||||||
|
"""
|
||||||
|
if next_work_limit < 1:
|
||||||
|
next_work_limit = 1
|
||||||
|
if attention_limit < 1:
|
||||||
|
attention_limit = 1
|
||||||
|
|
||||||
|
binding = resolve_steering_binding(ctx, initiative_id)
|
||||||
|
lifecycle = resolve_lifecycle_context(
|
||||||
|
ctx, initiative_id=initiative_id, binding=binding
|
||||||
|
)
|
||||||
|
horizon = resolve_horizon(
|
||||||
|
ctx,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
primary_method_key=binding.primary_method_key,
|
||||||
|
composition_modifier=binding.composition_modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
next_work = evaluate_next_work(
|
||||||
|
ctx,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
binding=binding,
|
||||||
|
horizon=horizon,
|
||||||
|
limit=next_work_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
attention = evaluate_attention(
|
||||||
|
ctx,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
binding=binding,
|
||||||
|
horizon=horizon,
|
||||||
|
next_work=next_work,
|
||||||
|
limit=attention_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SteeringEvaluation(
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
binding=binding,
|
||||||
|
horizon=horizon,
|
||||||
|
lifecycle=lifecycle,
|
||||||
|
next_work=next_work,
|
||||||
|
attention=attention,
|
||||||
|
)
|
||||||
42
backend/steering/kernel/events.py
Normal file
42
backend/steering/kernel/events.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Event ingress stub — dual trigger (Plan | Event) v0.1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
EventKind = Literal[
|
||||||
|
"actor_result",
|
||||||
|
"external_event",
|
||||||
|
"cadence_due",
|
||||||
|
"situational",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_steering_event(
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
initiative_id: str,
|
||||||
|
event_kind: EventKind,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Event-Schub in den Kernel (Stub v0.1).
|
||||||
|
|
||||||
|
Plan-dominante Methoden: Event wird protokolliert, Auswertung erfolgt
|
||||||
|
weiterhin über evaluate_steering (Plan-Zug). Volle Event-Pipeline folgt
|
||||||
|
für dispute_procedure, care_navigation, recurring_control.
|
||||||
|
"""
|
||||||
|
from steering.kernel.evaluate import evaluate_steering
|
||||||
|
|
||||||
|
evaluation = evaluate_steering(ctx, initiative_id=initiative_id)
|
||||||
|
return {
|
||||||
|
"accepted": True,
|
||||||
|
"event_kind": event_kind,
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"handled": False,
|
||||||
|
"message": "Event ingress stub — re-evaluate via Plan-Zug",
|
||||||
|
"payload_keys": sorted((payload or {}).keys()),
|
||||||
|
"evaluation": evaluation.to_dict(),
|
||||||
|
}
|
||||||
67
backend/steering/kernel/horizon.py
Normal file
67
backend/steering/kernel/horizon.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Horizon resolution (Q1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from steering.kernel.models import HorizonMarker
|
||||||
|
from steering.strategies.next_action.execution_ready import (
|
||||||
|
first_active_gate_id,
|
||||||
|
gate_horizon_scope_for_method,
|
||||||
|
)
|
||||||
|
from services.work_cycle import get_active_work_cycle
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_horizon(
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
initiative_id: str,
|
||||||
|
primary_method_key: str,
|
||||||
|
composition_modifier: str | None,
|
||||||
|
) -> HorizonMarker:
|
||||||
|
gate_id = gate_horizon_scope_for_method(
|
||||||
|
primary_method_key, ctx, initiative_id
|
||||||
|
)
|
||||||
|
gate_title: str | None = None
|
||||||
|
if gate_id:
|
||||||
|
gate_title = _load_gate_title(ctx, gate_id)
|
||||||
|
|
||||||
|
active_cycle = get_active_work_cycle(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
if composition_modifier and active_cycle:
|
||||||
|
return HorizonMarker(
|
||||||
|
kind="work_cycle",
|
||||||
|
gate_roadmap_item_id=gate_id,
|
||||||
|
gate_title=gate_title,
|
||||||
|
work_cycle_id=str(active_cycle["id"]),
|
||||||
|
work_cycle_title=active_cycle.get("title"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if gate_id:
|
||||||
|
return HorizonMarker(
|
||||||
|
kind="gate",
|
||||||
|
gate_roadmap_item_id=gate_id,
|
||||||
|
gate_title=gate_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
return HorizonMarker(kind="none")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_gate_title(ctx: TenantContext, gate_id: str) -> str | None:
|
||||||
|
from db import get_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT title FROM roadmap_items
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(gate_id, ctx.tenant_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row["title"] if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
65
backend/steering/kernel/lifecycle.py
Normal file
65
backend/steering/kernel/lifecycle.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Lifecycle slot resolution — Kernel §4 (declarative, v0.1.1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from steering.kernel.models import LifecycleContext, SteeringBinding
|
||||||
|
from steering.lifecycle.slot_maps import (
|
||||||
|
active_slot_keys,
|
||||||
|
is_slot_operational,
|
||||||
|
slot_map_from_tuple,
|
||||||
|
)
|
||||||
|
from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS, lifecycle_label
|
||||||
|
from steering.methods.registry import get_method
|
||||||
|
from services import steering_context as sc_service
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_slot_map_from_steps(steps: tuple[str, ...]) -> dict[str, str]:
|
||||||
|
active = set(steps)
|
||||||
|
return {
|
||||||
|
step: ("active" if step in active else "n/a") for step in STANDARD_LIFECYCLE_STEPS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_method_slot_map(method_key: str) -> dict[str, str]:
|
||||||
|
method = get_method(method_key)
|
||||||
|
if not method:
|
||||||
|
return _derive_slot_map_from_steps(STANDARD_LIFECYCLE_STEPS)
|
||||||
|
if method.slot_map:
|
||||||
|
return slot_map_from_tuple(method.slot_map)
|
||||||
|
return _derive_slot_map_from_steps(method.default_lifecycle_steps)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_lifecycle_context(
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
initiative_id: str,
|
||||||
|
binding: SteeringBinding,
|
||||||
|
) -> LifecycleContext:
|
||||||
|
slot_map = resolve_method_slot_map(binding.primary_method_key)
|
||||||
|
|
||||||
|
row = sc_service.get_steering_context(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
current_state = row["lifecycle_state"] if row else "intake"
|
||||||
|
if current_state not in STANDARD_LIFECYCLE_STEPS:
|
||||||
|
current_state = "intake"
|
||||||
|
|
||||||
|
active = active_slot_keys(slot_map) # type: ignore[arg-type]
|
||||||
|
operational = tuple(
|
||||||
|
step for step in STANDARD_LIFECYCLE_STEPS if is_slot_operational(slot_map.get(step, "n/a")) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
return LifecycleContext(
|
||||||
|
current_state=current_state,
|
||||||
|
current_state_label=lifecycle_label(current_state),
|
||||||
|
slot_map=slot_map,
|
||||||
|
active_slots=active,
|
||||||
|
operational_slots=operational,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lifecycle_to_dict(lifecycle: LifecycleContext) -> dict[str, Any]:
|
||||||
|
return lifecycle.to_dict()
|
||||||
81
backend/steering/kernel/models.py
Normal file
81
backend/steering/kernel/models.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Steering Kernel Spine — data models v0.1.1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HorizonMarker:
|
||||||
|
"""Aktiver Steuerungs-Horizont (Q1)."""
|
||||||
|
|
||||||
|
kind: Literal["gate", "work_cycle", "none"]
|
||||||
|
gate_roadmap_item_id: str | None = None
|
||||||
|
gate_title: str | None = None
|
||||||
|
work_cycle_id: str | None = None
|
||||||
|
work_cycle_title: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": self.kind,
|
||||||
|
"gate_roadmap_item_id": self.gate_roadmap_item_id,
|
||||||
|
"gate_title": self.gate_title,
|
||||||
|
"work_cycle_id": self.work_cycle_id,
|
||||||
|
"work_cycle_title": self.work_cycle_title,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SteeringBinding:
|
||||||
|
"""Aufgelöste Methode: Primary + optional Composition."""
|
||||||
|
|
||||||
|
primary_method_key: str
|
||||||
|
composition_modifier: str | None
|
||||||
|
next_work_strategy_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LifecycleContext:
|
||||||
|
"""Deklarativer Lifecycle-Vertrag + persistierter Pointer."""
|
||||||
|
|
||||||
|
current_state: str
|
||||||
|
current_state_label: str
|
||||||
|
slot_map: dict[str, str]
|
||||||
|
active_slots: tuple[str, ...]
|
||||||
|
operational_slots: tuple[str, ...]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"current_state": self.current_state,
|
||||||
|
"current_state_label": self.current_state_label,
|
||||||
|
"slot_map": dict(self.slot_map),
|
||||||
|
"active_slots": list(self.active_slots),
|
||||||
|
"operational_slots": list(self.operational_slots),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SteeringEvaluation:
|
||||||
|
"""Ergebnis von evaluate_steering — ein Wahrheitsmodell pro Vorhaben."""
|
||||||
|
|
||||||
|
initiative_id: str
|
||||||
|
binding: SteeringBinding
|
||||||
|
horizon: HorizonMarker
|
||||||
|
lifecycle: LifecycleContext
|
||||||
|
next_work: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
attention: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
data_source: str = "steering_kernel_v0.1.1"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"initiative_id": self.initiative_id,
|
||||||
|
"primary_method_key": self.binding.primary_method_key,
|
||||||
|
"composition_modifier": self.binding.composition_modifier,
|
||||||
|
"next_work_strategy_key": self.binding.next_work_strategy_key,
|
||||||
|
"horizon": self.horizon.to_dict(),
|
||||||
|
"lifecycle": self.lifecycle.to_dict(),
|
||||||
|
"next_work": self.next_work,
|
||||||
|
"attention": self.attention,
|
||||||
|
"data_source": self.data_source,
|
||||||
|
}
|
||||||
37
backend/steering/kernel/next_work.py
Normal file
37
backend/steering/kernel/next_work.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Next work evaluation (Schlitz 5) — routes to registered strategies only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from steering.kernel.models import HorizonMarker, SteeringBinding
|
||||||
|
from steering.strategies.next_action.default_strategy import default_strategy
|
||||||
|
from steering.strategies.next_action.registry import get_next_action_strategy
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_next_work(
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
initiative_id: str,
|
||||||
|
binding: SteeringBinding,
|
||||||
|
horizon: HorizonMarker,
|
||||||
|
limit: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Invoke the bound next-work strategy (primary or composition)."""
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
|
||||||
|
strategy = get_next_action_strategy(binding.next_work_strategy_key) or default_strategy
|
||||||
|
candidates = strategy.evaluate(
|
||||||
|
ctx, initiative_id=initiative_id, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in candidates:
|
||||||
|
item.setdefault("data_source", "steering_kernel")
|
||||||
|
if horizon.gate_roadmap_item_id:
|
||||||
|
item.setdefault("horizon_gate_id", horizon.gate_roadmap_item_id)
|
||||||
|
if horizon.work_cycle_id:
|
||||||
|
item.setdefault("horizon_work_cycle_id", horizon.work_cycle_id)
|
||||||
|
|
||||||
|
return candidates[:limit]
|
||||||
76
backend/steering/lifecycle/slot_maps.py
Normal file
76
backend/steering/lifecycle/slot_maps.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Method slot maps — Kernel §4 / Spec-D D5."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS
|
||||||
|
|
||||||
|
SlotStatus = Literal["active", "skip", "n/a", "optional"]
|
||||||
|
|
||||||
|
VALID_SLOT_STATUSES = frozenset({"active", "skip", "n/a", "optional"})
|
||||||
|
|
||||||
|
# Spec-D sequential_dependency D5
|
||||||
|
SEQUENTIAL_DEPENDENCY_SLOTS: dict[str, SlotStatus] = {
|
||||||
|
"intake": "active",
|
||||||
|
"method_selection": "active",
|
||||||
|
"structure_setup": "active",
|
||||||
|
"planning": "active",
|
||||||
|
"action_selection": "active",
|
||||||
|
"assignment": "active",
|
||||||
|
"waiting": "active",
|
||||||
|
"result_intake": "active",
|
||||||
|
"validation": "active",
|
||||||
|
"review": "optional",
|
||||||
|
"adaptation": "active",
|
||||||
|
"closure": "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Spec-D generic_operating D5 (method_bind → method_selection, next_work → action_selection)
|
||||||
|
GENERIC_OPERATING_SLOTS: dict[str, SlotStatus] = {
|
||||||
|
"intake": "active",
|
||||||
|
"method_selection": "active",
|
||||||
|
"structure_setup": "optional",
|
||||||
|
"planning": "n/a",
|
||||||
|
"action_selection": "active",
|
||||||
|
"assignment": "active",
|
||||||
|
"waiting": "optional",
|
||||||
|
"result_intake": "active",
|
||||||
|
"validation": "n/a",
|
||||||
|
"review": "n/a",
|
||||||
|
"adaptation": "optional",
|
||||||
|
"closure": "optional",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_slot_map(slot_map: dict[str, SlotStatus]) -> None:
|
||||||
|
for step, status in slot_map.items():
|
||||||
|
if step not in STANDARD_LIFECYCLE_STEPS:
|
||||||
|
raise ValueError(f"Unknown lifecycle slot: {step}")
|
||||||
|
if status not in VALID_SLOT_STATUSES:
|
||||||
|
raise ValueError(f"Invalid slot status for {step}: {status}")
|
||||||
|
missing = set(STANDARD_LIFECYCLE_STEPS) - set(slot_map.keys())
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"slot_map missing lifecycle steps: {sorted(missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
def slot_map_to_tuple(slot_map: dict[str, SlotStatus]) -> tuple[tuple[str, SlotStatus], ...]:
|
||||||
|
validate_slot_map(slot_map)
|
||||||
|
return tuple((step, slot_map[step]) for step in STANDARD_LIFECYCLE_STEPS)
|
||||||
|
|
||||||
|
|
||||||
|
def slot_map_from_tuple(items: tuple[tuple[str, SlotStatus], ...]) -> dict[str, SlotStatus]:
|
||||||
|
if not items:
|
||||||
|
return {}
|
||||||
|
slot_map = dict(items)
|
||||||
|
validate_slot_map(slot_map)
|
||||||
|
return slot_map
|
||||||
|
|
||||||
|
|
||||||
|
def active_slot_keys(slot_map: dict[str, SlotStatus]) -> tuple[str, ...]:
|
||||||
|
return tuple(step for step in STANDARD_LIFECYCLE_STEPS if slot_map.get(step) == "active")
|
||||||
|
|
||||||
|
|
||||||
|
def is_slot_operational(status: SlotStatus) -> bool:
|
||||||
|
"""Slots that participate in runtime evaluation (v0.1.1)."""
|
||||||
|
return status in ("active", "optional")
|
||||||
|
|
@ -97,6 +97,7 @@ def register_stub_method(
|
||||||
graph_profile: GraphMethodProfile = LIGHT,
|
graph_profile: GraphMethodProfile = LIGHT,
|
||||||
method_role: Literal["primary", "modifier"] = "primary",
|
method_role: Literal["primary", "modifier"] = "primary",
|
||||||
composes_with: frozenset[str] | None = None,
|
composes_with: frozenset[str] | None = None,
|
||||||
|
slot_map: tuple[tuple[str, str], ...] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if get_method(key):
|
if get_method(key):
|
||||||
return
|
return
|
||||||
|
|
@ -115,6 +116,7 @@ def register_stub_method(
|
||||||
graph_profile=graph_profile,
|
graph_profile=graph_profile,
|
||||||
method_role=method_role,
|
method_role=method_role,
|
||||||
composes_with=composes_with or frozenset(),
|
composes_with=composes_with or frozenset(),
|
||||||
|
slot_map=slot_map or (),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from steering.graph.profiles import FULFILLMENT, LIGHT, STRICT
|
from steering.graph.profiles import FULFILLMENT, LIGHT, STRICT
|
||||||
|
from steering.lifecycle.slot_maps import (
|
||||||
|
SEQUENTIAL_DEPENDENCY_SLOTS,
|
||||||
|
slot_map_to_tuple,
|
||||||
|
)
|
||||||
from steering.methods.registrations._helpers import (
|
from steering.methods.registrations._helpers import (
|
||||||
ELEM_NEXT,
|
ELEM_NEXT,
|
||||||
SLICES_AGILE,
|
SLICES_AGILE,
|
||||||
|
|
@ -37,6 +41,7 @@ def register() -> None:
|
||||||
| frozenset({"critical_path", "gate_fulfillment"}),
|
| frozenset({"critical_path", "gate_fulfillment"}),
|
||||||
ui_features=frozenset({"criticalPathControl"}),
|
ui_features=frozenset({"criticalPathControl"}),
|
||||||
graph_profile=STRICT,
|
graph_profile=STRICT,
|
||||||
|
slot_map=slot_map_to_tuple(SEQUENTIAL_DEPENDENCY_SLOTS),
|
||||||
)
|
)
|
||||||
register_stub_method(
|
register_stub_method(
|
||||||
key="recurring_control",
|
key="recurring_control",
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS
|
|
||||||
from steering.graph.profiles import LIGHT
|
from steering.graph.profiles import LIGHT
|
||||||
|
from steering.lifecycle.slot_maps import GENERIC_OPERATING_SLOTS, slot_map_to_tuple
|
||||||
|
from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS
|
||||||
from steering.methods.registrations._helpers import ELEM_NEXT, SLICES_GENERIC
|
from steering.methods.registrations._helpers import ELEM_NEXT, SLICES_GENERIC
|
||||||
from steering.methods.registry import MethodDefinition, get_method, register_method
|
from steering.methods.registry import MethodDefinition, get_method, register_method
|
||||||
|
|
||||||
|
|
@ -23,5 +24,6 @@ def register() -> None:
|
||||||
compatible_archetype_keys="*",
|
compatible_archetype_keys="*",
|
||||||
steering_elements=ELEM_NEXT,
|
steering_elements=ELEM_NEXT,
|
||||||
graph_profile=LIGHT,
|
graph_profile=LIGHT,
|
||||||
|
slot_map=slot_map_to_tuple(GENERIC_OPERATING_SLOTS),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class MethodDefinition:
|
||||||
graph_profile: GraphMethodProfile = LIGHT
|
graph_profile: GraphMethodProfile = LIGHT
|
||||||
method_role: Literal["primary", "modifier"] = "primary"
|
method_role: Literal["primary", "modifier"] = "primary"
|
||||||
composes_with: frozenset[str] = frozenset()
|
composes_with: frozenset[str] = frozenset()
|
||||||
|
slot_map: tuple[tuple[str, str], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def ui_features_as_dict(features: frozenset[str]) -> dict[str, bool]:
|
def ui_features_as_dict(features: frozenset[str]) -> dict[str, bool]:
|
||||||
|
|
@ -70,6 +71,10 @@ def register_method(defn: MethodDefinition) -> None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Modifier method {defn.key} must declare composes_with primary keys"
|
f"Modifier method {defn.key} must declare composes_with primary keys"
|
||||||
)
|
)
|
||||||
|
if defn.slot_map:
|
||||||
|
from steering.lifecycle.slot_maps import slot_map_from_tuple
|
||||||
|
|
||||||
|
slot_map_from_tuple(defn.slot_map)
|
||||||
_METHODS[defn.key] = defn
|
_METHODS[defn.key] = defn
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -133,6 +138,10 @@ def method_to_dict(
|
||||||
"composes_with": sorted(method.composes_with),
|
"composes_with": sorted(method.composes_with),
|
||||||
"data_slices": sorted(method.data_slices),
|
"data_slices": sorted(method.data_slices),
|
||||||
}
|
}
|
||||||
|
if method.slot_map:
|
||||||
|
payload["slot_map"] = {
|
||||||
|
step: status for step, status in method.slot_map
|
||||||
|
}
|
||||||
if include_compatibility:
|
if include_compatibility:
|
||||||
compat = method.compatible_archetype_keys
|
compat = method.compatible_archetype_keys
|
||||||
payload["compatible_archetype_keys"] = (
|
payload["compatible_archetype_keys"] = (
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"""Signal Engine — method-aware NextAction (AP1.1)."""
|
"""Signal Engine — delegates initiative steering to Kernel Spine v0.1."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from services import steering_context as sc_service
|
from steering.kernel import evaluate_steering
|
||||||
from steering.methods.registry import get_method
|
|
||||||
from steering.signals import default_rules
|
from steering.signals import default_rules
|
||||||
from steering.strategies.next_action.default_strategy import default_strategy
|
from steering.strategies.next_action.default_strategy import default_strategy
|
||||||
from steering.strategies.next_action.registry import get_next_action_strategy
|
from steering.strategies.next_action.registry import get_next_action_strategy
|
||||||
|
|
@ -14,43 +13,6 @@ from tenant_context import TenantContext
|
||||||
SignalKind = Literal["attention", "next_action"]
|
SignalKind = Literal["attention", "next_action"]
|
||||||
|
|
||||||
|
|
||||||
def _resolve_method_key(ctx: TenantContext, initiative_id: str | None) -> str:
|
|
||||||
if not initiative_id:
|
|
||||||
return "generic_operating"
|
|
||||||
row = sc_service.get_steering_context(
|
|
||||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
|
||||||
)
|
|
||||||
if row:
|
|
||||||
return row["method_key"]
|
|
||||||
return "generic_operating"
|
|
||||||
|
|
||||||
|
|
||||||
def _primary_composes_with_agile(method_key: str) -> bool:
|
|
||||||
agile = get_method("agile_iteration")
|
|
||||||
if not agile:
|
|
||||||
return False
|
|
||||||
return method_key in agile.composes_with
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_next_action_strategy_key(
|
|
||||||
ctx: TenantContext, initiative_id: str | None
|
|
||||||
) -> str:
|
|
||||||
method_key = _resolve_method_key(ctx, initiative_id)
|
|
||||||
method = get_method(method_key)
|
|
||||||
strategy_key = method.next_action_strategy_key if method else default_strategy.key
|
|
||||||
if not initiative_id:
|
|
||||||
return strategy_key
|
|
||||||
|
|
||||||
from services.work_cycle import get_active_work_cycle
|
|
||||||
|
|
||||||
active = get_active_work_cycle(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
|
||||||
if active and _primary_composes_with_agile(method_key):
|
|
||||||
agile = get_next_action_strategy("agile_iteration")
|
|
||||||
if agile:
|
|
||||||
return agile.key
|
|
||||||
return strategy_key
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate(
|
def evaluate(
|
||||||
ctx: TenantContext,
|
ctx: TenantContext,
|
||||||
kind: SignalKind = "attention",
|
kind: SignalKind = "attention",
|
||||||
|
|
@ -58,9 +20,19 @@ def evaluate(
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
initiative_id: str | None = None,
|
initiative_id: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
if initiative_id:
|
||||||
|
evaluation = evaluate_steering(
|
||||||
|
ctx,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
next_work_limit=limit,
|
||||||
|
attention_limit=max(limit, 10),
|
||||||
|
)
|
||||||
|
if kind == "next_action":
|
||||||
|
return evaluation.next_work
|
||||||
|
return evaluation.attention
|
||||||
|
|
||||||
if kind == "attention":
|
if kind == "attention":
|
||||||
return default_rules.get_attention_items(ctx)
|
return default_rules.get_attention_items(ctx)
|
||||||
|
|
||||||
strategy_key = _resolve_next_action_strategy_key(ctx, initiative_id)
|
strategy = get_next_action_strategy("default") or default_strategy
|
||||||
strategy = get_next_action_strategy(strategy_key) or default_strategy
|
return strategy.evaluate(ctx, initiative_id=None, limit=limit)
|
||||||
return strategy.evaluate(ctx, initiative_id=initiative_id, limit=limit)
|
|
||||||
|
|
|
||||||
111
backend/tests/test_ap_kernel_spine.py
Normal file
111
backend/tests/test_ap_kernel_spine.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""Steering Kernel Spine v0.1 — unit and integration tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from steering.kernel import apply_steering_event, evaluate_steering
|
||||||
|
from steering.kernel.binding import resolve_steering_binding
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_steering_linear_binding(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Kernel Binding",
|
||||||
|
archetype_key="initiative.linear_project",
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
ctx = TenantContext(tenant_id=user["tenant_id"], user_id=user["id"])
|
||||||
|
binding = resolve_steering_binding(ctx, initiative_id)
|
||||||
|
assert binding.primary_method_key == "sequential_dependency"
|
||||||
|
assert binding.composition_modifier is None
|
||||||
|
assert binding.next_work_strategy_key == "sequential_dependency"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_steering_returns_horizon_and_next(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Kernel Eval",
|
||||||
|
archetype_key="initiative.linear_project",
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
ctx = TenantContext(tenant_id=user["tenant_id"], user_id=user["id"])
|
||||||
|
evaluation = evaluate_steering(ctx, initiative_id=initiative_id)
|
||||||
|
assert evaluation.data_source == "steering_kernel_v0.1.1"
|
||||||
|
assert evaluation.lifecycle.slot_map.get("action_selection") == "active"
|
||||||
|
assert evaluation.lifecycle.slot_map.get("validation") == "active"
|
||||||
|
assert evaluation.lifecycle.slot_map.get("review") == "optional"
|
||||||
|
assert evaluation.horizon.kind in ("gate", "none", "work_cycle")
|
||||||
|
if evaluation.horizon.kind == "gate":
|
||||||
|
assert evaluation.horizon.gate_roadmap_item_id
|
||||||
|
|
||||||
|
snap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap.status_code == 200
|
||||||
|
body = snap.json()
|
||||||
|
assert body.get("steering_kernel")
|
||||||
|
assert body["steering_kernel"]["primary_method_key"] == "sequential_dependency"
|
||||||
|
assert body["next_actions"] == evaluation.next_work
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_routes_through_kernel_not_side_path(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Kernel Path",
|
||||||
|
archetype_key="initiative.linear_project",
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
snap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap.json()["steering_kernel"]["data_source"] == "steering_kernel_v0.1.1"
|
||||||
|
assert "lifecycle" in snap.json()["steering_kernel"]
|
||||||
|
assert snap.json()["steering_kernel"]["lifecycle"]["slot_map"]["closure"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_steering_event_stub(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Event Stub",
|
||||||
|
archetype_key="initiative.linear_project",
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
ctx = TenantContext(tenant_id=user["tenant_id"], user_id=user["id"])
|
||||||
|
result = apply_steering_event(
|
||||||
|
ctx,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
event_kind="actor_result",
|
||||||
|
payload={"action_id": "test"},
|
||||||
|
)
|
||||||
|
assert result["accepted"] is True
|
||||||
|
assert result["handled"] is False
|
||||||
|
assert result["evaluation"]["data_source"] == "steering_kernel_v0.1.1"
|
||||||
93
docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md
Normal file
93
docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# ADP — Steering Kernel Spine v0.1
|
||||||
|
|
||||||
|
**Status:** PO-freigegeben — **Implementierung aktiv** (2026-07-26)
|
||||||
|
**Stand:** 2026-07-26
|
||||||
|
**Bezug:** `Kairo_Steering_Method_Kernel_v0.1.md`, `ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, Normierungsprogramm M5
|
||||||
|
**Ersetzt nicht:** AP2.3/AP2.4 Plugin-Schicht — **setzt darauf auf**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
Steuerungslogik (Next Action, Attention, Horizont) entsteht heute **verstreut** in Strategies, `signals/engine.py` und Snapshot-Hilfsregeln. Es gibt **keinen** zentralen Runtime-Einstieg — Produkt wirkt unführbar, Spec-Kern (M1c) ist nicht im Code.
|
||||||
|
|
||||||
|
**PO-Entscheidung:** Erst **Herzmaschine** (Kernel Spine), dann Archetyp-Slices und UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope Lock — Spine v0.1 (schmal)
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
| # | Lieferung |
|
||||||
|
|---|-----------|
|
||||||
|
| K1 | `backend/steering/kernel/` — **einziger** Einstieg `evaluate_steering()` pro Vorhaben |
|
||||||
|
| K2 | `SteeringBinding` — Primary-Methode + optional Composition (`agile_iteration`) |
|
||||||
|
| K3 | `HorizonMarker` — aktives Gate / aktiver Sprint |
|
||||||
|
| K4 | `next_work` + `attention` — nur über Spine; Snapshot/API für Initiative |
|
||||||
|
| K5 | Event-Ingress **Stub** `apply_steering_event()` (Plan-Methoden: no-op) |
|
||||||
|
| K6 | Referenz-Abnahme: **A2** + `sequential_dependency` (+ Agile-Komposition) |
|
||||||
|
|
||||||
|
### Out of Scope (v0.1)
|
||||||
|
|
||||||
|
- Voller 12-Schritt-Lifecycle-Orchestrator als Schleife
|
||||||
|
- Nested Programm-Impulse (B2a P0)
|
||||||
|
- Event-Methoden C1/Care voll
|
||||||
|
- Neue OM-Tabellen
|
||||||
|
- Frontend-Umbau (nutzt bestehende Snapshot-Felder)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architektur
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET …/steering-snapshot ─┐
|
||||||
|
data_layer/attention ─┼─► steering.kernel.evaluate_steering()
|
||||||
|
signals/engine (init.) ─┘ │
|
||||||
|
├─ resolve_binding (primary + modifier)
|
||||||
|
├─ resolve_horizon
|
||||||
|
├─ next_work (Strategy via binding)
|
||||||
|
└─ attention (method-aware, horizon-scoped)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stop-the-line:** Keine neue Steuerungsheuristik außerhalb `backend/steering/kernel/` und registrierten Method-Strategies, die **nur** vom Kernel aufgerufen werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Abnahme (DoD)
|
||||||
|
|
||||||
|
1. A2 Happy Path Spec §9: Next im Gate-Horizont, blockiert → Attention, kein Fake-Next
|
||||||
|
2. Agile: Primary ohne Sprint; Sprint aktiv → `work_cycle_ready` ∩ Horizont
|
||||||
|
3. pytest `test_ap_kernel_spine.py` + `test_ap22c_*` grün
|
||||||
|
4. Kein Initiative-Snapshot-Pfad umgeht `evaluate_steering()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Migration
|
||||||
|
|
||||||
|
| Alt | Neu |
|
||||||
|
|-----|-----|
|
||||||
|
| `signals/engine.evaluate(..., initiative_id=…)` | delegiert an Kernel |
|
||||||
|
| Composition-If in `engine._resolve_next_action_strategy_key` | `kernel/binding.py` |
|
||||||
|
| Horizont in einzelnen Strategies | `kernel/horizon.py` + Strategies konsumieren Scope |
|
||||||
|
|
||||||
|
Strategies bleiben **Plugin-Lieferanten** — werden nicht dupliziert, aber nur über Kernel geroutet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Nächste Phasen (nach Spine ✓)
|
||||||
|
|
||||||
|
1. Zweite Methode durch dieselbe Spine (`continuous_product`)
|
||||||
|
2. `checklist_flow`, `care_navigation` als Plugins
|
||||||
|
3. Event-Ingress für hybrid/event-Methoden
|
||||||
|
4. Lifecycle-Schleife schrittweise (nicht Big Bang)
|
||||||
|
|
||||||
|
### Phase v0.1.1 (2026-07-26) ✓
|
||||||
|
|
||||||
|
- `slot_map` in `MethodDefinition` (Spec-D D5) für `sequential_dependency`, `generic_operating`
|
||||||
|
- `evaluate_steering()` liefert `lifecycle`: `current_state`, `slot_map`, `active_slots`
|
||||||
|
- Snapshot-Feld `steering_kernel.lifecycle`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Bei Erweiterung: v0.2 Event-Pfad + Slot-Provider — v0.2 + PO-Freigabe.*
|
||||||
|
|
@ -86,6 +86,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Initiative Steering Snapshot | ✓ | Actions + linked; Archetyp/Guidance AP2.0c |
|
| Initiative Steering Snapshot | ✓ | Actions + linked; Archetyp/Guidance AP2.0c |
|
||||||
| Operating Context API | ✓ | AP2.3a: `GET …/operating-context`, `ui_profile_json` Migration 027 |
|
| Operating Context API | ✓ | AP2.3a: `GET …/operating-context`, `ui_profile_json` Migration 027 |
|
||||||
| Archetyp-/Methoden-Plugin-Architektur | ✓ | AP2.3 A–F + AP2.4; Operating Context, Slice-Loader, Route Gating |
|
| Archetyp-/Methoden-Plugin-Architektur | ✓ | AP2.3 A–F + AP2.4; Operating Context, Slice-Loader, Route Gating |
|
||||||
|
| **Steering Kernel Spine** | ◐ | v0.1.1: evaluate_steering + slot_map (A2); Event-Ingress Stub |
|
||||||
| Steuerungselement-Registry | ✓ | AP2.4: `steering/elements/`, FE `steeringElementRegistry`, Methoden-Vertrag |
|
| Steuerungselement-Registry | ✓ | AP2.4: `steering/elements/`, FE `steeringElementRegistry`, Methoden-Vertrag |
|
||||||
| operating_phase | ✗ | entfernt AP1.2 |
|
| operating_phase | ✗ | entfernt AP1.2 |
|
||||||
| signals (Snapshot) | ✓ | `snapshot_signals.py` |
|
| signals (Snapshot) | ✓ | `snapshot_signals.py` |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user