AP2.3a: Operating Context API mit ui_profile_json und Archetyp-Sync.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-25 10:26:17 +02:00
parent ad6740ffa8
commit 4ad908bfbf
7 changed files with 486 additions and 3 deletions

View File

@ -7,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 entity_archetypes.ui_profiles import INITIATIVE_UI_PROFILES, resolve_ui_profile
# initiative suffix → default method (shared by initiative + project mirror) # initiative suffix → default method (shared by initiative + project mirror)
_METHOD_BY_SUFFIX: dict[str, str] = { _METHOD_BY_SUFFIX: dict[str, str] = {
@ -70,15 +71,18 @@ def _build_specs() -> tuple[dict[str, Any], ...]:
specs: list[dict[str, Any]] = [] specs: list[dict[str, Any]] = []
for suffix, label, description in _INITIATIVE_SPEC_META: for suffix, label, description in _INITIATIVE_SPEC_META:
method = _METHOD_BY_SUFFIX[suffix] method = _METHOD_BY_SUFFIX[suffix]
initiative_key = f"initiative.{suffix}"
ui_profile = INITIATIVE_UI_PROFILES.get(initiative_key, {})
specs.append( specs.append(
{ {
"key": f"initiative.{suffix}", "key": initiative_key,
"entity_type": "initiative", "entity_type": "initiative",
"label": label, "label": label,
"description": description, "description": description,
"is_system": True, "is_system": True,
"default_method_key": method, "default_method_key": method,
"default_method_profile_key": None, "default_method_profile_key": None,
"ui_profile_json": ui_profile,
} }
) )
specs.append( specs.append(
@ -90,6 +94,7 @@ def _build_specs() -> tuple[dict[str, Any], ...]:
"is_system": True, "is_system": True,
"default_method_key": method, "default_method_key": method,
"default_method_profile_key": None, "default_method_profile_key": None,
"ui_profile_json": {},
} }
) )
return tuple(specs) return tuple(specs)
@ -189,6 +194,14 @@ def resolve_default_method_key(archetype_key: str) -> str:
return spec.get("default_method_key", "generic_operating") return spec.get("default_method_key", "generic_operating")
def get_ui_profile_json(archetype_key: str) -> dict[str, Any]:
"""UI-Profil aus Registry-Seed (DB-Sync-Quelle)."""
spec = _KEY_INDEX.get(archetype_key)
if spec and spec.get("ui_profile_json"):
return dict(spec["ui_profile_json"])
return resolve_ui_profile(archetype_key)
def mirror_project_archetype_key(initiative_archetype_key: str) -> str: def mirror_project_archetype_key(initiative_archetype_key: str) -> str:
if initiative_archetype_key.startswith("initiative."): if initiative_archetype_key.startswith("initiative."):
suffix = initiative_archetype_key[len("initiative.") :] suffix = initiative_archetype_key[len("initiative.") :]

View File

@ -0,0 +1,181 @@
"""UI-Profile für Initiative-Archetypen — migriert aus methodUiDefaults.js (AP2.3a)."""
from __future__ import annotations
from typing import Any
_PRODUCT_PROCESS = (
{"key": "inbox", "to": "/plan/inbox", "label": "Eingang", "mode": "plan"},
{"key": "sprint-plan", "to": "/plan/sprint", "label": "Sprint planen", "mode": "plan"},
{"key": "sprint-work", "to": "/work/sprint", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_LINEAR_PROCESS = (
{"key": "gates", "to": "/plan/gates", "label": "Zielzustände", "mode": "plan"},
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_MATURITY_PROCESS = (
{"key": "gates", "to": "/plan/gates", "label": "Stufen", "mode": "plan"},
{"key": "journey", "to": "/control/journey", "label": "Rhythmen", "mode": "control"},
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_PROGRAM_PROCESS = (
{"key": "gates", "to": "/plan/gates", "label": "Phasen", "mode": "plan"},
{"key": "inbox", "to": "/plan/inbox", "label": "Eingang", "mode": "plan"},
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_SUPPORT_QUEUE_PROCESS = (
{"key": "inbox", "to": "/plan/inbox", "label": "Eingang", "mode": "plan"},
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_COMMON_OM_SLICES = (
"actions",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
)
GENERIC_UI_PROFILE: dict[str, Any] = {
"processSteps": [],
"planDefaultRoute": "/plan/profile",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": None,
"workNavKeys": None,
"dataSlices": ["actions", "backlog", "roadmap", "projects", "blockers", *_COMMON_OM_SLICES[1:]],
"enabledModes": ["plan", "work", "control"],
}
INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
"initiative.product": {
"processSteps": list(_PRODUCT_PROCESS),
"planDefaultRoute": "/plan/inbox",
"workDefaultRoute": "/work/sprint",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "inbox", "sprint", "gates"],
"workNavKeys": ["sprint", "today", "mine"],
"dataSlices": [
"backlog",
"actions",
"roadmap",
"work_cycles",
"projects",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.linear_project": {
"processSteps": list(_LINEAR_PROCESS),
"planDefaultRoute": "/plan/gates",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "structure", "gates", "inbox", "work"],
"workNavKeys": ["today", "mine", "sprint"],
"dataSlices": [
"backlog",
"actions",
"roadmap",
"projects",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.maturity_journey": {
"processSteps": list(_MATURITY_PROCESS),
"planDefaultRoute": "/plan/gates",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "gates", "inbox", "work"],
"workNavKeys": ["today", "mine", "sprint"],
"dataSlices": [
"actions",
"roadmap",
"blockers",
"evidence",
"decisions",
"reviews",
"recurring",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.program": {
"processSteps": list(_PROGRAM_PROCESS),
"planDefaultRoute": "/plan/gates",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "structure", "gates", "inbox", "sprint", "work"],
"workNavKeys": ["today", "mine", "sprint"],
"dataSlices": [
"backlog",
"actions",
"roadmap",
"projects",
"work_cycles",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.support_queue": {
"processSteps": list(_SUPPORT_QUEUE_PROCESS),
"planDefaultRoute": "/plan/inbox",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "inbox", "work"],
"workNavKeys": ["today", "mine"],
"dataSlices": [
"backlog",
"actions",
"blockers",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.recurring_program": {
"processSteps": list(_MATURITY_PROCESS),
"planDefaultRoute": "/plan/gates",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "gates", "inbox", "work"],
"workNavKeys": ["today", "mine"],
"dataSlices": [
"actions",
"roadmap",
"recurring",
"blockers",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
}
def resolve_ui_profile(archetype_key: str) -> dict[str, Any]:
"""UI-Profil aus Code-Seed; unbekannte Archetypen → Generic-Fallback."""
profile = INITIATIVE_UI_PROFILES.get(archetype_key)
if profile:
return dict(profile)
return dict(GENERIC_UI_PROFILE)

View File

@ -14,17 +14,20 @@ def sync_entity_field_registry_to_db() -> int:
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
for archetype in ENTITY_ARCHETYPES: for archetype in ENTITY_ARCHETYPES:
ui_profile = archetype.get("ui_profile_json") or {}
cur.execute( cur.execute(
""" """
INSERT INTO entity_archetypes ( INSERT INTO entity_archetypes (
archetype_key, entity_type, label, description, is_system archetype_key, entity_type, label, description, is_system,
ui_profile_json
) )
VALUES (%s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (archetype_key) DO UPDATE SET ON CONFLICT (archetype_key) DO UPDATE SET
entity_type = EXCLUDED.entity_type, entity_type = EXCLUDED.entity_type,
label = EXCLUDED.label, label = EXCLUDED.label,
description = EXCLUDED.description, description = EXCLUDED.description,
is_system = EXCLUDED.is_system, is_system = EXCLUDED.is_system,
ui_profile_json = EXCLUDED.ui_profile_json,
updated_at = NOW() updated_at = NOW()
""", """,
( (
@ -33,6 +36,7 @@ def sync_entity_field_registry_to_db() -> int:
archetype["label"], archetype["label"],
archetype["description"], archetype["description"],
archetype["is_system"], archetype["is_system"],
json.dumps(ui_profile),
), ),
) )

View File

@ -0,0 +1,4 @@
-- AP2.3a: UI-Profil pro Archetyp (Registry-Sync → Frontend Operating Context)
ALTER TABLE entity_archetypes
ADD COLUMN IF NOT EXISTS ui_profile_json JSONB NOT NULL DEFAULT '{}'::jsonb;

View File

@ -21,6 +21,7 @@ from services import blockers as blocker_service
from services import decisions as decision_service from services import decisions as decision_service
from services import evidence as evidence_service from services import evidence as evidence_service
from services import initiatives as initiative_service from services import initiatives as initiative_service
from services import operating_context as operating_context_service
from services import milestones as milestone_service from services import milestones as milestone_service
from services import recurring as recurring_service from services import recurring as recurring_service
from services import reviews as review_service from services import reviews as review_service
@ -213,6 +214,19 @@ def get_initiative_steering_context(
return get_steering_context_dto(ctx, initiative_id=initiative_id) return get_steering_context_dto(ctx, initiative_id=initiative_id)
@router.get("/{initiative_id}/operating-context")
def get_initiative_operating_context(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
context = operating_context_service.get_operating_context(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if not context:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return context
def _require_initiative(*, tenant_id: str, initiative_id: str) -> dict: def _require_initiative(*, tenant_id: str, initiative_id: str) -> dict:
initiative = initiative_service.get_initiative( initiative = initiative_service.get_initiative(
tenant_id=tenant_id, initiative_id=initiative_id tenant_id=tenant_id, initiative_id=initiative_id

View File

@ -0,0 +1,108 @@
"""Operating Context read model — AP2.3a."""
from __future__ import annotations
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from entity_archetypes.registry import get_ui_profile_json, resolve_ui_profile
from services.initiatives import get_initiative
from services.steering_context import ensure_steering_context, get_steering_context
from steering.methods.registry import get_method
def _load_ui_profile_from_db(archetype_key: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT ui_profile_json
FROM entity_archetypes
WHERE archetype_key = %s
""",
(archetype_key,),
)
row = cur.fetchone()
if not row:
return None
profile = row.get("ui_profile_json")
if isinstance(profile, dict) and profile:
return dict(profile)
return None
except Exception:
return None
finally:
conn.close()
def _resolve_ui_profile(archetype_key: str) -> dict[str, Any]:
db_profile = _load_ui_profile_from_db(archetype_key)
if db_profile:
return db_profile
seed_profile = get_ui_profile_json(archetype_key)
if seed_profile:
return seed_profile
return resolve_ui_profile(archetype_key)
def _resolve_data_slices(
*,
ui_profile: dict[str, Any],
method_key: str,
) -> list[str]:
"""Schnittmenge Archetyp-Default ∩ Method-Slices (Phase E erweitert)."""
archetype_slices = ui_profile.get("dataSlices") or []
method = get_method(method_key)
if method and getattr(method, "data_slices", None):
method_slices = set(method.data_slices)
return [s for s in archetype_slices if s in method_slices]
return list(archetype_slices)
def _method_capabilities(method_key: str) -> dict[str, Any]:
method = get_method(method_key)
if not method:
return {
"lifecycle_steps": [],
"next_action_strategy_key": "default",
}
return {
"lifecycle_steps": list(method.default_lifecycle_steps),
"next_action_strategy_key": method.next_action_strategy_key,
}
def get_operating_context(
*, tenant_id: str, initiative_id: str
) -> Optional[dict[str, Any]]:
initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not initiative:
return None
steering = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
if not steering:
steering = ensure_steering_context(
tenant_id=tenant_id, initiative_id=initiative_id
)
archetype_key = initiative["archetype_key"]
ui_profile = _resolve_ui_profile(archetype_key)
method_key = steering["method_key"]
metadata = steering.get("lifecycle_metadata") or {}
if isinstance(metadata, str):
metadata = {}
method_profile_key = metadata.get("method_profile_key")
data_slices = _resolve_data_slices(ui_profile=ui_profile, method_key=method_key)
return {
"initiative_id": initiative_id,
"archetype_key": archetype_key,
"method_key": method_key,
"method_profile_key": method_profile_key,
"ui_profile": ui_profile,
"data_slices": data_slices,
"method_capabilities": _method_capabilities(method_key),
}

View File

@ -0,0 +1,159 @@
"""AP2.3a — Operating Context API."""
from __future__ import annotations
from auth import AUTH_HEADER
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def test_operating_context_product(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Product Alpha",
archetype_key="initiative.product",
)
assert created.status_code == 201
initiative_id = created.json()["id"]
res = client.get(
f"/api/initiatives/{initiative_id}/operating-context",
headers=_auth(token),
)
assert res.status_code == 200
body = res.json()
assert body["initiative_id"] == initiative_id
assert body["archetype_key"] == "initiative.product"
assert body["method_key"] == "continuous_product"
assert body["ui_profile"]["planDefaultRoute"] == "/plan/inbox"
assert body["ui_profile"]["workDefaultRoute"] == "/work/sprint"
assert "work_cycles" in body["data_slices"]
assert body["ui_profile"]["processSteps"]
assert body["method_capabilities"]["next_action_strategy_key"]
def test_operating_context_linear(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Linear Proj",
archetype_key="initiative.linear_project",
)
initiative_id = created.json()["id"]
res = client.get(
f"/api/initiatives/{initiative_id}/operating-context",
headers=_auth(token),
)
assert res.status_code == 200
body = res.json()
assert body["method_key"] == "sequential_dependency"
assert body["ui_profile"]["planDefaultRoute"] == "/plan/gates"
assert "work_cycles" not in body["data_slices"]
def test_operating_context_maturity(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Maturity",
archetype_key="initiative.maturity_journey",
)
initiative_id = created.json()["id"]
res = client.get(
f"/api/initiatives/{initiative_id}/operating-context",
headers=_auth(token),
)
assert res.status_code == 200
body = res.json()
assert body["method_key"] == "maturity_progression"
assert any(s["key"] == "journey" for s in body["ui_profile"]["processSteps"])
def test_operating_context_program(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Program",
archetype_key="initiative.program",
)
initiative_id = created.json()["id"]
res = client.get(
f"/api/initiatives/{initiative_id}/operating-context",
headers=_auth(token),
)
assert res.status_code == 200
body = res.json()
assert body["method_key"] == "program_delivery"
assert body["ui_profile"]["planOutlineKeys"] is not None
def test_operating_context_generic_fallback(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Dispute",
archetype_key="initiative.dispute_case",
)
initiative_id = created.json()["id"]
res = client.get(
f"/api/initiatives/{initiative_id}/operating-context",
headers=_auth(token),
)
assert res.status_code == 200
body = res.json()
assert body["ui_profile"]["planDefaultRoute"] == "/plan/profile"
assert body["ui_profile"]["processSteps"] == []
def test_operating_context_support_queue_no_work_cycles(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Support Queue",
archetype_key="initiative.support_queue",
)
initiative_id = created.json()["id"]
res = client.get(
f"/api/initiatives/{initiative_id}/operating-context",
headers=_auth(token),
)
assert res.status_code == 200
body = res.json()
assert body["method_key"] == "queue_pull"
assert "work_cycles" not in body["data_slices"]
assert "sprint" not in (body["ui_profile"].get("planOutlineKeys") or [])
def test_operating_context_not_found(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
res = client.get(
"/api/initiatives/00000000-0000-0000-0000-000000000099/operating-context",
headers=_auth(token),
)
assert res.status_code == 404