Kairo-Jinkendo/backend/services/steering_context.py
Lars fc956d4ceb
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m23s
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 12s
AP1.1: Method Registry — product_milestone_driven + Methode wählbar
Zweite Built-in-Methode mit meilensteinorientierter NextAction-Strategie, API und UI-Auswahl pro Vorhaben; AP0.10d Validation-Vorlagen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:24:17 +02:00

260 lines
8.0 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,
) -> 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 update_method_key(
*,
tenant_id: str,
initiative_id: str,
method_key: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
from steering.methods.registry import get_method
method = get_method(method_key)
if not method:
raise ValueError(f"Unbekannte Methode: {method_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 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,
)