AP0.10a: OM-Integration — Steering-Snapshot, Transitions, Verknüpfungen
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m10s
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 1s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m10s
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 1s
Test Suite / compose-smoke (push) Has been skipped
Beantwortet die Lücke zwischen parallelen Tabellen und späterem Steering Core: Graph-Read-Model, minimale Flows, sichtbarer Steuerungszustand. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a025542545
commit
e25ff86420
12
README.md
12
README.md
|
|
@ -230,7 +230,17 @@ Operating Model Extension II (AP0.9, Schema `008`):
|
||||||
|
|
||||||
Capabilities gesamt nach AP0.9: **33**.
|
Capabilities gesamt nach AP0.9: **33**.
|
||||||
|
|
||||||
Zielarchitektur-Einordnung: AP0.9 ist Schritt 7 der Evolutionslinie (Evidence/Review/Decision/Recurring) — siehe `docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md`.
|
Operating Model Integration (AP0.10, Schema weiter `008`):
|
||||||
|
|
||||||
|
| Endpoint | Methode | Capability | Beschreibung |
|
||||||
|
|----------|---------|------------|--------------|
|
||||||
|
| `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Operating Phase |
|
||||||
|
|
||||||
|
**Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required` → `done`
|
||||||
|
|
||||||
|
**Attention Regel 10:** `action_review_required` — Maßnahme ohne geplantes Review
|
||||||
|
|
||||||
|
Version nach AP0.10: **`0.10.0-ap0.10`**. Brücke zum Steering Core: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
|
||||||
|
|
||||||
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`
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ AttentionKind = Literal[
|
||||||
"overdue_action",
|
"overdue_action",
|
||||||
"review_due",
|
"review_due",
|
||||||
"recurring_due",
|
"recurring_due",
|
||||||
|
"action_review_required",
|
||||||
]
|
]
|
||||||
|
|
||||||
NextActionKind = Literal[
|
NextActionKind = Literal[
|
||||||
|
|
@ -275,6 +276,41 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
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]]:
|
def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -389,6 +425,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
items.extend(_stale_initiatives(cur, ctx))
|
items.extend(_stale_initiatives(cur, ctx))
|
||||||
items.extend(_milestones_at_risk(cur, ctx))
|
items.extend(_milestones_at_risk(cur, ctx))
|
||||||
items.extend(_overdue_actions(cur, ctx))
|
items.extend(_overdue_actions(cur, ctx))
|
||||||
|
items.extend(_actions_review_required(cur, ctx))
|
||||||
items.extend(_reviews_due(cur, ctx))
|
items.extend(_reviews_due(cur, ctx))
|
||||||
items.extend(_recurring_due(cur, ctx))
|
items.extend(_recurring_due(cur, ctx))
|
||||||
|
|
||||||
|
|
|
||||||
349
backend/data_layer/initiative_snapshot.py
Normal file
349
backend/data_layer/initiative_snapshot.py
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""Initiative steering snapshot — verknüpftes Read-Model (AP0.10a).
|
||||||
|
|
||||||
|
Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steering Core.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.initiatives import get_initiative
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
OperatingPhase = Literal[
|
||||||
|
"capture",
|
||||||
|
"triage",
|
||||||
|
"structure",
|
||||||
|
"commit",
|
||||||
|
"execute",
|
||||||
|
"verify",
|
||||||
|
"review",
|
||||||
|
"adapt",
|
||||||
|
"closure",
|
||||||
|
]
|
||||||
|
|
||||||
|
OPEN_BLOCKER = ("open", "in_progress")
|
||||||
|
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
|
||||||
|
ACTIVE_INITIATIVE = ("active", "paused")
|
||||||
|
|
||||||
|
|
||||||
|
def _sid(value: Any) -> Optional[str]:
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: Any) -> Optional[str]:
|
||||||
|
return value.isoformat() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_operating_phase(
|
||||||
|
*,
|
||||||
|
initiative_status: str,
|
||||||
|
open_actions: int,
|
||||||
|
blocked_actions: int,
|
||||||
|
review_required_actions: int,
|
||||||
|
open_blockers: int,
|
||||||
|
backlog_new: int,
|
||||||
|
planned_reviews_due: int,
|
||||||
|
) -> tuple[OperatingPhase, list[str]]:
|
||||||
|
"""Heuristische, erklärbare Phase — kein Lifecycle-State-Machine-Ersatz."""
|
||||||
|
signals: list[str] = []
|
||||||
|
|
||||||
|
if initiative_status in ("completed", "archived"):
|
||||||
|
return "closure", ["initiative_terminal"]
|
||||||
|
|
||||||
|
if open_actions == 0 and backlog_new > 0:
|
||||||
|
signals.append("backlog_awaiting_commit")
|
||||||
|
return "triage", signals
|
||||||
|
|
||||||
|
if open_actions == 0 and backlog_new == 0:
|
||||||
|
signals.append("no_open_work")
|
||||||
|
return "structure", signals
|
||||||
|
|
||||||
|
if blocked_actions > 0 or open_blockers > 0:
|
||||||
|
if blocked_actions:
|
||||||
|
signals.append("actions_blocked")
|
||||||
|
if open_blockers:
|
||||||
|
signals.append("open_blockers")
|
||||||
|
return "execute", signals
|
||||||
|
|
||||||
|
if review_required_actions > 0 or planned_reviews_due > 0:
|
||||||
|
if review_required_actions:
|
||||||
|
signals.append("actions_need_review")
|
||||||
|
if planned_reviews_due:
|
||||||
|
signals.append("reviews_due")
|
||||||
|
return "review", signals
|
||||||
|
|
||||||
|
if open_actions > 0:
|
||||||
|
signals.append("work_in_progress")
|
||||||
|
return "execute", signals
|
||||||
|
|
||||||
|
return "adapt", signals
|
||||||
|
|
||||||
|
|
||||||
|
def get_initiative_steering_snapshot(
|
||||||
|
ctx: TenantContext, *, initiative_id: str
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""Tenant-scoped Graph-Snapshot eines Vorhabens."""
|
||||||
|
initiative = get_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||||
|
if not initiative:
|
||||||
|
return None
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, title, description, status, priority, due_at,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM actions
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
actions_raw = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, action_id, milestone_id, title, status, created_at, updated_at
|
||||||
|
FROM blockers
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
blockers = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, action_id, milestone_id, title, status, created_at, updated_at
|
||||||
|
FROM evidence
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
evidence = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, action_id, milestone_id, title, status, due_at,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM reviews
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
reviews = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, title, status, priority, converted_action_id, created_at, updated_at
|
||||||
|
FROM backlog_items
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
backlog = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, title, status, target_date, created_at, updated_at
|
||||||
|
FROM milestones
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
milestones_raw = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, title, status, outcome, created_at, updated_at
|
||||||
|
FROM decisions
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
decisions = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, title, status, next_due_at, created_at, updated_at
|
||||||
|
FROM recurring_elements
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
recurring = [dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
blockers_by_action: dict[str, list[dict]] = {}
|
||||||
|
unlinked_blockers: list[dict] = []
|
||||||
|
for b in blockers:
|
||||||
|
item = {
|
||||||
|
"id": _sid(b["id"]),
|
||||||
|
"title": b["title"],
|
||||||
|
"status": b["status"],
|
||||||
|
"action_id": _sid(b.get("action_id")),
|
||||||
|
"milestone_id": _sid(b.get("milestone_id")),
|
||||||
|
}
|
||||||
|
aid = item["action_id"]
|
||||||
|
if aid:
|
||||||
|
blockers_by_action.setdefault(aid, []).append(item)
|
||||||
|
else:
|
||||||
|
unlinked_blockers.append(item)
|
||||||
|
|
||||||
|
evidence_by_action: dict[str, list[dict]] = {}
|
||||||
|
unlinked_evidence: list[dict] = []
|
||||||
|
for e in evidence:
|
||||||
|
item = {
|
||||||
|
"id": _sid(e["id"]),
|
||||||
|
"title": e["title"],
|
||||||
|
"status": e["status"],
|
||||||
|
"action_id": _sid(e.get("action_id")),
|
||||||
|
"milestone_id": _sid(e.get("milestone_id")),
|
||||||
|
}
|
||||||
|
aid = item["action_id"]
|
||||||
|
if aid:
|
||||||
|
evidence_by_action.setdefault(aid, []).append(item)
|
||||||
|
else:
|
||||||
|
unlinked_evidence.append(item)
|
||||||
|
|
||||||
|
reviews_by_action: dict[str, list[dict]] = {}
|
||||||
|
reviews_by_milestone: dict[str, list[dict]] = {}
|
||||||
|
unlinked_reviews: list[dict] = []
|
||||||
|
planned_reviews_due = 0
|
||||||
|
for r in reviews:
|
||||||
|
item = {
|
||||||
|
"id": _sid(r["id"]),
|
||||||
|
"title": r["title"],
|
||||||
|
"status": r["status"],
|
||||||
|
"due_at": _iso(r.get("due_at")),
|
||||||
|
"action_id": _sid(r.get("action_id")),
|
||||||
|
"milestone_id": _sid(r.get("milestone_id")),
|
||||||
|
}
|
||||||
|
if r["status"] == "planned" and r.get("due_at"):
|
||||||
|
planned_reviews_due += 1
|
||||||
|
aid, mid = item["action_id"], item["milestone_id"]
|
||||||
|
if aid:
|
||||||
|
reviews_by_action.setdefault(aid, []).append(item)
|
||||||
|
elif mid:
|
||||||
|
reviews_by_milestone.setdefault(mid, []).append(item)
|
||||||
|
else:
|
||||||
|
unlinked_reviews.append(item)
|
||||||
|
|
||||||
|
actions: list[dict[str, Any]] = []
|
||||||
|
open_actions = blocked_actions = review_required_actions = 0
|
||||||
|
for a in actions_raw:
|
||||||
|
aid = _sid(a["id"])
|
||||||
|
status = a["status"]
|
||||||
|
if status in OPEN_ACTION:
|
||||||
|
open_actions += 1
|
||||||
|
if status == "blocked":
|
||||||
|
blocked_actions += 1
|
||||||
|
if status == "review_required":
|
||||||
|
review_required_actions += 1
|
||||||
|
action_blockers = blockers_by_action.get(aid, [])
|
||||||
|
open_blocker_count = sum(1 for b in action_blockers if b["status"] in OPEN_BLOCKER)
|
||||||
|
actions.append(
|
||||||
|
{
|
||||||
|
"id": aid,
|
||||||
|
"title": a["title"],
|
||||||
|
"status": status,
|
||||||
|
"priority": a["priority"],
|
||||||
|
"due_at": _iso(a.get("due_at")),
|
||||||
|
"blockers": action_blockers,
|
||||||
|
"evidence": evidence_by_action.get(aid, []),
|
||||||
|
"reviews": reviews_by_action.get(aid, []),
|
||||||
|
"open_blocker_count": open_blocker_count,
|
||||||
|
"has_open_blocker": open_blocker_count > 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
milestones = []
|
||||||
|
for m in milestones_raw:
|
||||||
|
mid = _sid(m["id"])
|
||||||
|
milestones.append(
|
||||||
|
{
|
||||||
|
"id": mid,
|
||||||
|
"title": m["title"],
|
||||||
|
"status": m["status"],
|
||||||
|
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
|
||||||
|
"reviews": reviews_by_milestone.get(mid, []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
open_blockers = sum(
|
||||||
|
1 for b in blockers if b["status"] in OPEN_BLOCKER
|
||||||
|
)
|
||||||
|
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
|
||||||
|
|
||||||
|
phase, phase_signals = _derive_operating_phase(
|
||||||
|
initiative_status=initiative["status"],
|
||||||
|
open_actions=open_actions,
|
||||||
|
blocked_actions=blocked_actions,
|
||||||
|
review_required_actions=review_required_actions,
|
||||||
|
open_blockers=open_blockers,
|
||||||
|
backlog_new=backlog_new,
|
||||||
|
planned_reviews_due=planned_reviews_due,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"initiative_title": initiative["title"],
|
||||||
|
"initiative_status": initiative["status"],
|
||||||
|
"operating_phase": phase,
|
||||||
|
"phase_signals": phase_signals,
|
||||||
|
"counts": {
|
||||||
|
"actions_open": open_actions,
|
||||||
|
"actions_blocked": blocked_actions,
|
||||||
|
"actions_review_required": review_required_actions,
|
||||||
|
"blockers_open": open_blockers,
|
||||||
|
"backlog_open": backlog_new,
|
||||||
|
"decisions": len(decisions),
|
||||||
|
"reviews_planned_due": planned_reviews_due,
|
||||||
|
"recurring_active": sum(1 for r in recurring if r["status"] == "active"),
|
||||||
|
"unlinked_blockers": len(unlinked_blockers),
|
||||||
|
"unlinked_evidence": len(unlinked_evidence),
|
||||||
|
"unlinked_reviews": len(unlinked_reviews),
|
||||||
|
},
|
||||||
|
"actions": actions,
|
||||||
|
"milestones": milestones,
|
||||||
|
"initiative_level": {
|
||||||
|
"blockers": unlinked_blockers,
|
||||||
|
"evidence": unlinked_evidence,
|
||||||
|
"reviews": unlinked_reviews,
|
||||||
|
"decisions": [
|
||||||
|
{"id": _sid(d["id"]), "title": d["title"], "status": d["status"]}
|
||||||
|
for d in decisions
|
||||||
|
],
|
||||||
|
"backlog": [
|
||||||
|
{
|
||||||
|
"id": _sid(b["id"]),
|
||||||
|
"title": b["title"],
|
||||||
|
"status": b["status"],
|
||||||
|
"converted_action_id": _sid(b.get("converted_action_id")),
|
||||||
|
}
|
||||||
|
for b in backlog
|
||||||
|
],
|
||||||
|
"recurring": [
|
||||||
|
{
|
||||||
|
"id": _sid(r["id"]),
|
||||||
|
"title": r["title"],
|
||||||
|
"status": r["status"],
|
||||||
|
"next_due_at": _iso(r.get("next_due_at")),
|
||||||
|
}
|
||||||
|
for r in recurring
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"data_source": "initiative_steering_snapshot",
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ from datetime import datetime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
|
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 services import actions as action_service
|
from services import actions as action_service
|
||||||
|
|
@ -154,6 +155,19 @@ def get_initiative(
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{initiative_id}/steering-snapshot")
|
||||||
|
def get_initiative_steering_snapshot(
|
||||||
|
initiative_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||||
|
):
|
||||||
|
snapshot = dl_initiative_snapshot.get_initiative_steering_snapshot(
|
||||||
|
ctx, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
if not snapshot:
|
||||||
|
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{initiative_id}")
|
@router.patch("/{initiative_id}")
|
||||||
def update_initiative(
|
def update_initiative(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,16 @@ def update_blocker(
|
||||||
"to_status": status,
|
"to_status": status,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
from services.operating_transitions import after_blocker_status_change
|
||||||
|
|
||||||
|
after_blocker_status_change(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
blocker_id=blocker_id,
|
||||||
|
action_id=result.get("action_id"),
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=status,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
147
backend/services/operating_transitions.py
Normal file
147
backend/services/operating_transitions.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
"""Minimale Operating-Transitions — Vorstufe zum Steering Orchestrator (AP0.10a).
|
||||||
|
|
||||||
|
Side Effects bei Statusänderungen. Später: Hook Registry + Method Strategies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
|
||||||
|
RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"})
|
||||||
|
|
||||||
|
|
||||||
|
def after_blocker_status_change(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
blocker_id: str,
|
||||||
|
action_id: Optional[str],
|
||||||
|
old_status: str,
|
||||||
|
new_status: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Blocker geschlossen → verknüpfte blockierte Maßnahme entblocken."""
|
||||||
|
effects: list[dict[str, Any]] = []
|
||||||
|
if new_status == old_status:
|
||||||
|
return effects
|
||||||
|
if new_status not in RESOLVED_BLOCKER_STATUSES or not action_id:
|
||||||
|
return effects
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, status FROM actions
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(action_id, tenant_id),
|
||||||
|
)
|
||||||
|
action = cur.fetchone()
|
||||||
|
if not action or action["status"] != "blocked":
|
||||||
|
return effects
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM blockers
|
||||||
|
WHERE tenant_id = %s AND action_id = %s
|
||||||
|
AND id != %s AND status IN ('open', 'in_progress')
|
||||||
|
""",
|
||||||
|
(tenant_id, action_id, blocker_id),
|
||||||
|
)
|
||||||
|
remaining = cur.fetchone()[0]
|
||||||
|
if remaining > 0:
|
||||||
|
effects.append(
|
||||||
|
{
|
||||||
|
"kind": "action_still_blocked",
|
||||||
|
"action_id": str(action_id),
|
||||||
|
"reason": "other_open_blockers",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return effects
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE actions SET status = 'open', updated_at = NOW()
|
||||||
|
WHERE id = %s AND tenant_id = %s AND status = 'blocked'
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(action_id, tenant_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
effects.append(
|
||||||
|
{
|
||||||
|
"kind": "action_unblocked",
|
||||||
|
"action_id": str(action_id),
|
||||||
|
"trigger": "blocker_resolved",
|
||||||
|
"blocker_id": blocker_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if effects and user_id:
|
||||||
|
from services.audit import log_audit
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"operating_transition.action_unblocked",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"effects": effects, "blocker_id": blocker_id},
|
||||||
|
)
|
||||||
|
return effects
|
||||||
|
|
||||||
|
|
||||||
|
def after_review_status_change(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
review_id: str,
|
||||||
|
action_id: Optional[str],
|
||||||
|
old_status: str,
|
||||||
|
new_status: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Review abgeschlossen → Maßnahme aus review_required weiterführen."""
|
||||||
|
effects: list[dict[str, Any]] = []
|
||||||
|
if new_status == old_status or new_status != "completed" or not action_id:
|
||||||
|
return effects
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE actions
|
||||||
|
SET status = 'done', updated_at = NOW()
|
||||||
|
WHERE id = %s AND tenant_id = %s AND status = 'review_required'
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(action_id, tenant_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
effects.append(
|
||||||
|
{
|
||||||
|
"kind": "action_completed_after_review",
|
||||||
|
"action_id": str(action_id),
|
||||||
|
"trigger": "review_completed",
|
||||||
|
"review_id": review_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if effects and user_id:
|
||||||
|
from services.audit import log_audit
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"operating_transition.action_completed_after_review",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"effects": effects, "review_id": review_id},
|
||||||
|
)
|
||||||
|
return effects
|
||||||
|
|
@ -309,6 +309,16 @@ def update_review(
|
||||||
"to_status": status,
|
"to_status": status,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
from services.operating_transitions import after_review_status_change
|
||||||
|
|
||||||
|
after_review_status_change(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
review_id=review_id,
|
||||||
|
action_id=result.get("action_id"),
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=status,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
90
backend/tests/test_ap10_integration.py
Normal file
90
backend/tests/test_ap10_integration.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""AP0.10 — Integration: Snapshot, Transitions, Attention."""
|
||||||
|
|
||||||
|
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_snapshot_graph(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, title="Linked Action").json()
|
||||||
|
|
||||||
|
blocker = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/blockers",
|
||||||
|
json={"title": "B", "action_id": action["id"]},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert blocker.status_code == 201
|
||||||
|
|
||||||
|
snap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap.status_code == 200
|
||||||
|
data = snap.json()
|
||||||
|
assert data["operating_phase"] in (
|
||||||
|
"execute",
|
||||||
|
"triage",
|
||||||
|
"structure",
|
||||||
|
"review",
|
||||||
|
"adapt",
|
||||||
|
"commit",
|
||||||
|
"capture",
|
||||||
|
"verify",
|
||||||
|
"closure",
|
||||||
|
)
|
||||||
|
linked = next(a for a in data["actions"] if a["id"] == action["id"])
|
||||||
|
assert len(linked["blockers"]) == 1
|
||||||
|
assert linked["open_blocker_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocker_resolve_unblocks_action(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).json()["id"]
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/blockers",
|
||||||
|
json={
|
||||||
|
"title": "Block",
|
||||||
|
"action_id": action,
|
||||||
|
"set_action_blocked": True,
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
blocker_id = created.json()["id"]
|
||||||
|
blocked = client.get(f"/api/actions/{action}", headers=_auth(token)).json()
|
||||||
|
assert blocked["status"] == "blocked"
|
||||||
|
|
||||||
|
client.patch(
|
||||||
|
f"/api/blockers/{blocker_id}",
|
||||||
|
json={"status": "resolved"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
unblocked = client.get(f"/api/actions/{action}", headers=_auth(token)).json()
|
||||||
|
assert unblocked["status"] == "open"
|
||||||
|
|
||||||
|
|
||||||
|
def test_attention_action_review_required_without_review(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).json()
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{action['id']}",
|
||||||
|
json={"status": "review_required"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
res = client.get("/api/workspace/attention", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert any(i["kind"] == "action_review_required" for i in res.json())
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.9.0-ap0.9"
|
APP_VERSION = "0.10.0-ap0.10"
|
||||||
DB_SCHEMA_VERSION = "008"
|
DB_SCHEMA_VERSION = "008"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
146
docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md
Normal file
146
docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
# Kairo — Operating Model → Steering Bridge
|
||||||
|
|
||||||
|
**Status:** Architektur-Einordnung
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Zweck:** Ehrliche Antwort auf die Frage, ob AP0.8/0.9 auf spätere Steuerungslogik einzahlen — und was noch fehlt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Kurzantwort
|
||||||
|
|
||||||
|
**Ja, aber indirekt.** AP0.8/0.9 haben das **Vokabular und die Persistenzschicht** des kanonischen Operating Models geliefert — nicht den **Steuerungsgraph**, nicht den **Lifecycle-Orchestrator** und nicht die **Method Registry**.
|
||||||
|
|
||||||
|
Das ist kein Versehen, sondern die dokumentierte Reihenfolge:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Foundation (AP0.1–0.7)
|
||||||
|
→ Operating Model Entitäten (AP0.8–0.9) ← wir sind hier
|
||||||
|
→ Integration & Validation (AP0.10)
|
||||||
|
→ Steering Core Foundation (Sprint 1 / AP1.x)
|
||||||
|
→ Method Registry, Hooks, Lifecycle Runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne die Entitäten hätte Steering nichts zu lesen und zu steuern.
|
||||||
|
Mit nur den Entitäten **ohne Verbindungslogik** wirkt Kairo zu Recht wie eine Sammlung paralleler Tabellen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Was AP0.8/0.9 tatsächlich vorbereitet
|
||||||
|
|
||||||
|
| Baustein | Beitrag für späteres Steering | Noch nicht da |
|
||||||
|
|----------|------------------------------|---------------|
|
||||||
|
| **Entitäten** | Blocker, Backlog, Milestone, Evidence, Decision, Review, Recurring | SteeringContext, Project, Dependency |
|
||||||
|
| **Schwache FKs** | `blocker.action_id`, `evidence.action_id`, `review.action_id` | Pflicht-Struktur, Graph-Traversierung in UI |
|
||||||
|
| **Attention (Regeln 1–9)** | Proto-Signal-Engine, erklärbare DTOs | Methoden-spezifische Signal Rules, Ranking |
|
||||||
|
| **NextActionCandidates** | Regelbasierte Empfehlungen | Hook `on_next_action_requested`, Strategien |
|
||||||
|
| **Audit-Events** | Nachvollziehbarkeit für Agenten/Steering | Lifecycle-Audit-Kette |
|
||||||
|
| **Actor-Assignments** | Wer handelt | Waiting/Reminder/Escalation |
|
||||||
|
| **Status-Enums** | Anknüpfpunkte für Lifecycle-Transitions | State Machine + Guards |
|
||||||
|
|
||||||
|
**Fazit:** AP0.8/0.9 = **System of Record für OM-Objekte** + **regelbasierte Signale**.
|
||||||
|
Das ist Schritt 5–7 der Evolutionslinie (`Kairo_System_Target_State_v0.1.md` §24), **nicht** Schritt 2–4 (Steering Core, Method Registry, Lifecycle).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Kanonisches Modell vs. Ist-Implementierung
|
||||||
|
|
||||||
|
### Soll (Canonical Operating Model)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Initiative
|
||||||
|
├── Milestone
|
||||||
|
├── BacklogItem
|
||||||
|
├── Action
|
||||||
|
│ ├── Assignment
|
||||||
|
│ ├── Blocker ← primär an Maßnahme
|
||||||
|
│ └── Evidence ← primär an Maßnahme
|
||||||
|
├── Decision
|
||||||
|
├── Review
|
||||||
|
└── RecurringElement
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ist (AP0.9)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Initiative
|
||||||
|
├── [parallele Sektionen in UI]
|
||||||
|
├── Action (Liste)
|
||||||
|
├── Blocker (Liste, action_id optional)
|
||||||
|
├── Evidence (Liste, action_id optional)
|
||||||
|
├── …
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lücke:** Objekte existieren, aber **Struktur und Flow sind nicht sichtbar**.
|
||||||
|
Der Nutzer sieht Listen, keinen **Steuerungszustand** eines Vorhabens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Was „Steuerungslogik“ später bedeutet
|
||||||
|
|
||||||
|
Aus `Kairo_Target_Architecture_Method_Driven_Adaptive_Steering_Core_v0.1.md`:
|
||||||
|
|
||||||
|
| Komponente | Rolle |
|
||||||
|
|------------|-------|
|
||||||
|
| **SteeringContext** | Bindet Initiative an Methode, Lifecycle, Domain |
|
||||||
|
| **Method Registry** | Liefert Structure Builder, Signal Rules, Strategien |
|
||||||
|
| **Standard Lifecycle** | intake → … → closure mit Guards |
|
||||||
|
| **Hook Registry** | `on_review_due`, `on_blocker_open`, `on_next_action_requested` |
|
||||||
|
| **Signal Engine** | Attention + NextAction aus Methode + Kontext |
|
||||||
|
| **Transition Orchestrator** | Statusänderung → Side Effects (unblock, review anstoßen) |
|
||||||
|
|
||||||
|
AP0.8/0.9 **ersetzen** das nicht — sie liefern die **Daten**, an die Hooks und Regeln andocken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. AP0.10 — Integration statt nur Validation
|
||||||
|
|
||||||
|
AP0.10 wird daher **zweigeteilt**:
|
||||||
|
|
||||||
|
### A) Operating Model Integration (technisch)
|
||||||
|
|
||||||
|
- **Steering-Snapshot** Read-Model: Initiative als verknüpfter Graph (Actions mit Blockern/Evidence/Reviews)
|
||||||
|
- **Operating Cycle Phase** (heuristisch, erklärbar): triage / execute / verify / review / adapt
|
||||||
|
- **Transition-Orchestrierung minimal:** z. B. Blocker gelöst → verknüpfte Maßnahme entblocken; Review abgeschlossen → Maßnahme weiterführen
|
||||||
|
- **UI:** Verknüpfungen sichtbar (Maßnahme ↔ Blocker/Evidence), nicht nur parallele Sektionen
|
||||||
|
- **Attention:** Regeln, die **Beziehungen** nutzen (Maßnahme blockiert + offener Blocker auf derselben Maßnahme)
|
||||||
|
|
||||||
|
### B) MVP Validation (fachlich)
|
||||||
|
|
||||||
|
Testvorhaben aus Roadmap (Kairo, Gewaltschutzkurs, …) — Prüffragen: Hilft es bei täglicher Steuerung? Fehlt was **wirklich** als Nächstes?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Evolutionspfad (konkret)
|
||||||
|
|
||||||
|
```text
|
||||||
|
AP0.10 Integration + Validation ← nächster Schritt
|
||||||
|
AP1.0 backend/steering/ Foundation (SteeringContext, Lifecycle stub)
|
||||||
|
AP1.1 Method Registry minimal + Hook Registry
|
||||||
|
AP1.2 Signal Engine aus attention.py → steering/signals/
|
||||||
|
AP1.3 Transition Orchestrator (ersetzt operating_transitions.py)
|
||||||
|
Sprint 2+ Dependencies, Waiting, Method Profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regel:** Keine neuen parallelen Tabellen ohne OM-Bezug.
|
||||||
|
Neue Arbeit muss either **verbinden**, **orchestratieren** oder **validieren**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Leitfrage-Check
|
||||||
|
|
||||||
|
> Zahlt jede Entität auf „Welcher nächste Schritt bringt das Vorhaben voran?“ ein?
|
||||||
|
|
||||||
|
| Entität | Heute | Nach AP0.10 | Nach Steering Core |
|
||||||
|
|---------|-------|-------------|-------------------|
|
||||||
|
| Action | ✓ Commit/Execute | ✓ mit Kontext | ✓ NextAction-Ziel |
|
||||||
|
| Blocker | △ Signal | ✓ an Maßnahme | ✓ Hook on_blocker_open |
|
||||||
|
| Backlog | ✓ Triage | ✓ Convert-Flow | ✓ Structure Builder |
|
||||||
|
| Milestone | △ Signal at_risk | ✓ mit Reviews | ✓ Roadmap-Lane |
|
||||||
|
| Evidence | △ passive | ✓ an Maßnahme | ✓ Verify-Phase |
|
||||||
|
| Review | △ due signal | ✓ schließt Execute | ✓ Review Strategy |
|
||||||
|
| Decision | △ passive | △ Adapt-Hinweis | ✓ Adapt-Hook |
|
||||||
|
| Recurring | △ due signal | △ Routine-Hinweis | ✓ routine_control |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*v0.1 — Referenz für AP0.10 und Sprint-1-Planung*
|
||||||
50
docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md
Normal file
50
docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# AP0.10 — Operating Model Integration & MVP Validation
|
||||||
|
## Implementierungsauftrag v0.1
|
||||||
|
|
||||||
|
**Status:** freigegeben
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Vorgänger:** AP0.9 ✓
|
||||||
|
**Zielversion:** `0.10.0-ap0.10` (keine Schema-Migration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warum AP0.10 anders ist als AP0.8/0.9
|
||||||
|
|
||||||
|
AP0.8/0.9 lieferten **Entitäten + Signale**.
|
||||||
|
AP0.10 liefert **Verknüpfung + erklärbaren Zustand + minimale Flows** — Brücke zum Steering Core.
|
||||||
|
|
||||||
|
Referenz: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil A — Integration (technisch)
|
||||||
|
|
||||||
|
| Slice | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| 10a | Steering-Snapshot Read-Model + API |
|
||||||
|
| 10b | Operating-Transitions (Blocker→Action, Review→Action) |
|
||||||
|
| 10c | Attention Regel: review_required ohne Review |
|
||||||
|
| 10d | UI: Steuerungszustand + Verknüpfungen an Maßnahmen |
|
||||||
|
|
||||||
|
## Teil B — Validation (fachlich)
|
||||||
|
|
||||||
|
Testvorhaben aus Roadmap durchspielen, Lücken dokumentieren, AP1.0-Prioritäten ableiten.
|
||||||
|
|
||||||
|
## Nicht-Scope
|
||||||
|
|
||||||
|
- SteeringContext, Method Registry, Dependencies, Sprints
|
||||||
|
- UI-Redesign
|
||||||
|
- KI/MCP/Workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahme Teil A
|
||||||
|
|
||||||
|
- `GET /api/initiatives/{id}/steering-snapshot` liefert Graph + operating_phase
|
||||||
|
- Blocker resolved → verknüpfte Maßnahme entblockt (wenn keine weiteren offenen Blocker)
|
||||||
|
- Review completed → Maßnahme in review_required → done
|
||||||
|
- UI zeigt Phase + Maßnahmen-Kontext (Blocker/Evidence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Freigegeben — Integration vor Validation.*
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.9.0-ap0.9",
|
"version": "0.10.0-ap0.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ export function getInitiative(id) {
|
||||||
return apiFetch(`/api/initiatives/${id}`)
|
return apiFetch(`/api/initiatives/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInitiativeSteeringSnapshot(id) {
|
||||||
|
return apiFetch(`/api/initiatives/${id}/steering-snapshot`)
|
||||||
|
}
|
||||||
|
|
||||||
export function createInitiative(payload) {
|
export function createInitiative(payload) {
|
||||||
return apiFetch('/api/initiatives', {
|
return apiFetch('/api/initiatives', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
51
frontend/src/components/SteeringSnapshotPanel.jsx
Normal file
51
frontend/src/components/SteeringSnapshotPanel.jsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { PHASE_LABELS } from '../constants/operating.js'
|
||||||
|
|
||||||
|
export function SteeringSnapshotPanel({ snapshot }) {
|
||||||
|
if (!snapshot) return null
|
||||||
|
|
||||||
|
const { operating_phase, phase_signals, counts } = snapshot
|
||||||
|
const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card steering-snapshot">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Steuerungszustand</h2>
|
||||||
|
<span className="badge status-badge status-active">{phaseLabel}</span>
|
||||||
|
</div>
|
||||||
|
{phase_signals?.length > 0 && (
|
||||||
|
<p className="muted snapshot-signals">
|
||||||
|
Signale: {phase_signals.join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<dl className="snapshot-counts">
|
||||||
|
<div>
|
||||||
|
<dt>Offene Maßnahmen</dt>
|
||||||
|
<dd>{counts.actions_open}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Blockiert</dt>
|
||||||
|
<dd>{counts.actions_blocked}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Review nötig</dt>
|
||||||
|
<dd>{counts.actions_review_required}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Offene Blocker</dt>
|
||||||
|
<dd>{counts.blockers_open}</dd>
|
||||||
|
</div>
|
||||||
|
{(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && (
|
||||||
|
<div className="snapshot-warn">
|
||||||
|
<dt>Unverknüpft</dt>
|
||||||
|
<dd>
|
||||||
|
{counts.unlinked_blockers} Blocker, {counts.unlinked_evidence} Evidence
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
<p className="muted snapshot-hint">
|
||||||
|
Kanonischer Operating Cycle — noch heuristisch, später methodengeführt über Steering Core.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
frontend/src/constants/operating.js
Normal file
23
frontend/src/constants/operating.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
export const OPERATING_PHASES = [
|
||||||
|
'capture',
|
||||||
|
'triage',
|
||||||
|
'structure',
|
||||||
|
'commit',
|
||||||
|
'execute',
|
||||||
|
'verify',
|
||||||
|
'review',
|
||||||
|
'adapt',
|
||||||
|
'closure',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PHASE_LABELS = {
|
||||||
|
capture: 'Erfassung',
|
||||||
|
triage: 'Einordnung',
|
||||||
|
structure: 'Struktur',
|
||||||
|
commit: 'Commit',
|
||||||
|
execute: 'Umsetzung',
|
||||||
|
verify: 'Nachweis',
|
||||||
|
review: 'Review',
|
||||||
|
adapt: 'Anpassung',
|
||||||
|
closure: 'Abschluss',
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
getInitiative,
|
getInitiative,
|
||||||
|
getInitiativeSteeringSnapshot,
|
||||||
listInitiativeActions,
|
listInitiativeActions,
|
||||||
createInitiativeAction,
|
createInitiativeAction,
|
||||||
} from '../api/initiatives.js'
|
} from '../api/initiatives.js'
|
||||||
|
|
@ -60,6 +61,7 @@ import { EvidenceSection } from '../components/EvidenceSection.jsx'
|
||||||
import { DecisionsSection } from '../components/DecisionsSection.jsx'
|
import { DecisionsSection } from '../components/DecisionsSection.jsx'
|
||||||
import { ReviewsSection } from '../components/ReviewsSection.jsx'
|
import { ReviewsSection } from '../components/ReviewsSection.jsx'
|
||||||
import { RecurringSection } from '../components/RecurringSection.jsx'
|
import { RecurringSection } from '../components/RecurringSection.jsx'
|
||||||
|
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
|
@ -88,6 +90,7 @@ export function InitiativeDetailPage() {
|
||||||
const [decisions, setDecisions] = useState([])
|
const [decisions, setDecisions] = useState([])
|
||||||
const [reviews, setReviews] = useState([])
|
const [reviews, setReviews] = useState([])
|
||||||
const [recurringItems, setRecurringItems] = useState([])
|
const [recurringItems, setRecurringItems] = useState([])
|
||||||
|
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [showActionForm, setShowActionForm] = useState(false)
|
const [showActionForm, setShowActionForm] = useState(false)
|
||||||
|
|
@ -119,6 +122,11 @@ export function InitiativeDetailPage() {
|
||||||
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
||||||
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
||||||
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
|
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
|
||||||
|
loads.push(
|
||||||
|
getInitiativeSteeringSnapshot(id)
|
||||||
|
.then(setSteeringSnapshot)
|
||||||
|
.catch(() => setSteeringSnapshot(null))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
await Promise.all(loads)
|
await Promise.all(loads)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -136,6 +144,10 @@ export function InitiativeDetailPage() {
|
||||||
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||||
: actions
|
: actions
|
||||||
|
|
||||||
|
const actionContextById = Object.fromEntries(
|
||||||
|
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
|
||||||
|
)
|
||||||
|
|
||||||
async function handleCreateAction(payload) {
|
async function handleCreateAction(payload) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -200,6 +212,23 @@ export function InitiativeDetailPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateBlockerForAction(actionId) {
|
||||||
|
if (!capabilities.has('kairo.blocker.manage')) return
|
||||||
|
setFormBusy(true)
|
||||||
|
try {
|
||||||
|
await createInitiativeBlocker(id, {
|
||||||
|
title: 'Blocker',
|
||||||
|
action_id: actionId,
|
||||||
|
set_action_blocked: true,
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBlockerStatus(blockerId, status) {
|
async function handleBlockerStatus(blockerId, status) {
|
||||||
try {
|
try {
|
||||||
await updateBlocker(blockerId, { status })
|
await updateBlocker(blockerId, { status })
|
||||||
|
|
@ -453,6 +482,10 @@ export function InitiativeDetailPage() {
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{capabilities.has('kairo.initiative.read') && (
|
||||||
|
<SteeringSnapshotPanel snapshot={steeringSnapshot} />
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Maßnahmen</h2>
|
<h2>Maßnahmen</h2>
|
||||||
|
|
@ -498,7 +531,9 @@ export function InitiativeDetailPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="item-list actions-detail-list">
|
<ul className="item-list actions-detail-list">
|
||||||
{visibleActions.map((action) => (
|
{visibleActions.map((action) => {
|
||||||
|
const ctx = actionContextById[action.id]
|
||||||
|
return (
|
||||||
<li key={action.id} className="list-item card-list-item">
|
<li key={action.id} className="list-item card-list-item">
|
||||||
{editingAction === action.id ? (
|
{editingAction === action.id ? (
|
||||||
<ActionForm
|
<ActionForm
|
||||||
|
|
@ -530,6 +565,13 @@ export function InitiativeDetailPage() {
|
||||||
Fällig: {new Date(action.due_at).toLocaleString('de-DE')}
|
Fällig: {new Date(action.due_at).toLocaleString('de-DE')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{ctx && (ctx.open_blocker_count > 0 || ctx.evidence?.length > 0 || ctx.reviews?.length > 0) && (
|
||||||
|
<p className="muted list-item-sub action-links">
|
||||||
|
{ctx.open_blocker_count > 0 && `${ctx.open_blocker_count} Blocker · `}
|
||||||
|
{ctx.evidence?.length > 0 && `${ctx.evidence.length} Evidence · `}
|
||||||
|
{ctx.reviews?.length > 0 && `${ctx.reviews.length} Review(s)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta action-controls">
|
<div className="list-item-meta action-controls">
|
||||||
<StatusBadge status={action.status} />
|
<StatusBadge status={action.status} />
|
||||||
|
|
@ -548,6 +590,16 @@ export function InitiativeDetailPage() {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{capabilities.has('kairo.blocker.manage') && action.status !== 'done' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleCreateBlockerForAction(action.id)}
|
||||||
|
disabled={formBusy}
|
||||||
|
>
|
||||||
|
Blocker
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|
@ -564,7 +616,8 @@ export function InitiativeDetailPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -597,3 +597,38 @@
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.steering-snapshot .snapshot-counts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-snapshot .snapshot-counts div {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-snapshot .snapshot-counts dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--jk-muted, #666);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-snapshot .snapshot-counts dd {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-snapshot .snapshot-warn dd {
|
||||||
|
color: var(--jk-warning, #b45309);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-snapshot .snapshot-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-links {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user