Kairo-Jinkendo/backend/services/steering_context.py
Lars 38fed379ae
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 3m12s
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
AP2.3e: Method Plugin Contract mit data_slices und Archetyp-Kompatibilitaetspruefung.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:27:40 +02:00

393 lines
13 KiB
Python

"""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,
method_key: Optional[str] = None,
lifecycle_metadata: Optional[dict[str, Any]] = 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)
resolved_method = method_key or DEFAULT_METHOD_KEY
from steering.methods.registry import get_method
method = get_method(resolved_method)
if not method:
resolved_method = DEFAULT_METHOD_KEY
method = get_method(resolved_method)
method_version = method.version if method else DEFAULT_METHOD_VERSION
metadata = lifecycle_metadata or {}
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,
resolved_method,
method_version,
state,
Json(metadata),
),
)
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,
"method_key": resolved_method,
},
)
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 update_method_key(
*,
tenant_id: str,
initiative_id: str,
method_key: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
from entity_archetypes.registry import resolve_default_method_key
from method_profiles.registry import get_method_profile
from steering.methods.registry import get_method, method_compatible_with_archetype
from services.initiatives import get_initiative
method = get_method(method_key)
if not method:
raise ValueError(f"Unbekannte Methode: {method_key}")
initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not initiative:
raise ValueError("Vorhaben nicht gefunden")
if not method_compatible_with_archetype(method, initiative["archetype_key"]):
raise ValueError(
f"Methode {method_key} ist nicht kompatibel mit Archetyp "
f"{initiative['archetype_key']}"
)
existing = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
if not existing:
raise ValueError("SteeringContext nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
UPDATE steering_contexts
SET method_key = %s, method_version = %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
""",
(method.key, method.version, 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.method_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"initiative_id": initiative_id,
"from_method": existing["method_key"],
"to_method": method_key,
},
)
return result
def update_method_profile_key(
*,
tenant_id: str,
initiative_id: str,
method_profile_key: Optional[str],
user_id: Optional[str] = None,
) -> dict[str, Any]:
"""Setzt oder entfernt method_profile_key; passt method_key aus Profile/Archetyp an."""
from entity_archetypes.registry import resolve_default_method_key
from method_profiles.registry import get_method_profile
from steering.methods.registry import get_method
initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not initiative:
raise ValueError("Vorhaben nicht gefunden")
existing = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
if not existing:
raise ValueError("SteeringContext nicht gefunden")
metadata = dict(existing.get("lifecycle_metadata") or {})
previous_profile = metadata.get("method_profile_key")
if method_profile_key:
profile = get_method_profile(method_profile_key)
if not profile:
raise ValueError(f"Unbekannte Ausprägung: {method_profile_key}")
if profile["initiative_archetype_key"] != initiative["archetype_key"]:
raise ValueError("Ausprägung passt nicht zum Archetyp des Vorhabens")
metadata["method_profile_key"] = method_profile_key
resolved_method = profile["method_key"]
else:
metadata.pop("method_profile_key", None)
resolved_method = resolve_default_method_key(initiative["archetype_key"])
method = get_method(resolved_method)
if not method:
raise ValueError(f"Unbekannte Methode: {resolved_method}")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
UPDATE steering_contexts
SET method_key = %s,
method_version = %s,
lifecycle_metadata = %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
""",
(
method.key,
method.version,
Json(metadata),
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.method_profile_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"initiative_id": initiative_id,
"from_profile": previous_profile,
"to_profile": method_profile_key,
"method_key": method.key,
},
)
return result
def create_context_for_new_initiative(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str] = None,
archetype_key: str = "initiative.generic",
method_profile_key: Optional[str] = None,
) -> dict[str, Any]:
from entity_archetypes.registry import resolve_default_method_key
from method_profiles.registry import resolve_method_for_profile
method_key = resolve_default_method_key(archetype_key)
lifecycle_metadata: dict[str, Any] = {}
if method_profile_key:
from method_profiles.registry import get_method_profile
profile = get_method_profile(method_profile_key)
if not profile:
raise ValueError(f"Unbekannte Ausprägung: {method_profile_key}")
if profile["initiative_archetype_key"] != archetype_key:
raise ValueError("Ausprägung passt nicht zum Archetyp des Vorhabens")
profile_method = resolve_method_for_profile(method_profile_key)
if profile_method:
method_key = profile_method
lifecycle_metadata["method_profile_key"] = method_profile_key
return ensure_steering_context(
tenant_id=tenant_id,
initiative_id=initiative_id,
initial_state="intake",
user_id=user_id,
method_key=method_key,
lifecycle_metadata=lifecycle_metadata or None,
)