AP1.4e: Methodenprofile am Gate-Graph.
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2m18s
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

generic_operating ohne harte Blockade, Reifegrad-Methoden mit Erfuellungsgrad; Attention gate_graph_blocked fuer gate-orientierte Methoden.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-11 08:06:42 +02:00
parent 4f8725e234
commit 1fa61b2dac
14 changed files with 426 additions and 36 deletions

View File

@ -1,5 +1,18 @@
"""Roadmap graph read models (AP1.4d)."""
"""Roadmap graph read models (AP1.4d/4e)."""
from steering.graph.roadmap_engine import compute_initiative_graph_state
from steering.graph.profiles import GraphMethodProfile, get_graph_profile
from steering.graph.roadmap_engine import (
apply_graph_method_profile,
compute_initiative_graph_state,
compute_initiative_graph_state_with_profile,
load_initiative_graph_state,
)
__all__ = ["compute_initiative_graph_state"]
__all__ = [
"GraphMethodProfile",
"get_graph_profile",
"apply_graph_method_profile",
"compute_initiative_graph_state",
"compute_initiative_graph_state_with_profile",
"load_initiative_graph_state",
]

View File

@ -0,0 +1,51 @@
"""Method graph profiles — AP1.4e."""
from __future__ import annotations
from dataclasses import dataclass
DEFAULT_METHOD_KEY = "generic_operating"
@dataclass(frozen=True)
class GraphMethodProfile:
"""Steuert Graph-Read-Models pro steering_context.method_key."""
enforce_gate_blocking: bool
emphasize_fulfillment: bool
_STRICT = GraphMethodProfile(enforce_gate_blocking=True, emphasize_fulfillment=False)
_FULFILLMENT = GraphMethodProfile(enforce_gate_blocking=False, emphasize_fulfillment=True)
_LIGHT = GraphMethodProfile(enforce_gate_blocking=False, emphasize_fulfillment=False)
METHOD_GRAPH_PROFILES: dict[str, GraphMethodProfile] = {
"generic_operating": _LIGHT,
"queue_pull": _LIGHT,
"dispute_procedure": _LIGHT,
"continuous_product": _FULFILLMENT,
"maturity_progression": _FULFILLMENT,
"chapter_based_progression": _FULFILLMENT,
"product_milestone_driven": _STRICT,
"program_delivery": _STRICT,
"sequential_dependency": _STRICT,
"agile_iteration": _STRICT,
"recurring_control": _FULFILLMENT,
}
def get_graph_profile(method_key: str | None) -> GraphMethodProfile:
if not method_key:
return METHOD_GRAPH_PROFILES[DEFAULT_METHOD_KEY]
return METHOD_GRAPH_PROFILES.get(method_key, _STRICT)
def enforce_gate_blocking_for_method(method_key: str | None) -> bool:
return get_graph_profile(method_key).enforce_gate_blocking
def graph_profile_as_dict(profile: GraphMethodProfile) -> dict[str, bool]:
return {
"enforce_gate_blocking": profile.enforce_gate_blocking,
"emphasize_fulfillment": profile.emphasize_fulfillment,
}

View File

@ -1,9 +1,16 @@
"""Roadmap gate graph read models — Plan-Zielzustand, kein Workflow (AP1.4d)."""
"""Roadmap graph read models — AP1.4d/4e."""
from __future__ import annotations
from typing import Any, Optional
from steering.graph.profiles import (
DEFAULT_METHOD_KEY,
GraphMethodProfile,
get_graph_profile,
graph_profile_as_dict,
)
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
BLOCKING_STATUSES = frozenset({"planned", "active", "at_risk"})
@ -114,6 +121,7 @@ def compute_initiative_graph_state(
ratio = _fulfillment_ratio(criteria_progress.get(item_id))
item_states[item_id] = {
"status": status,
"blocked": blocked,
"ready": ready,
"blocked_by": blocked_by,
@ -131,12 +139,72 @@ def compute_initiative_graph_state(
}
def _initiative_fulfillment_ratio(item_states: dict[str, dict[str, Any]]) -> Optional[float]:
ratios = [
st["fulfillment_ratio"]
for st in item_states.values()
if st.get("fulfillment_ratio") is not None
]
if not ratios:
return None
return round(sum(ratios) / len(ratios), 4)
def apply_graph_method_profile(
state: dict[str, Any], profile: GraphMethodProfile
) -> dict[str, Any]:
"""Passt blocked/ready an Methodenprofil an (AP1.4e)."""
if profile.enforce_gate_blocking:
state["graph_profile"] = graph_profile_as_dict(profile)
state["initiative_fulfillment_ratio"] = _initiative_fulfillment_ratio(state["items"])
return state
item_states = state["items"]
blocked_items: list[str] = []
ready_items: list[str] = []
for item_id, item_state in item_states.items():
would_block = item_state["blocked"]
item_state["would_block"] = would_block
item_state["blocked"] = False
status = item_state.get("status", "planned")
item_state["ready"] = status in ACTIVE_STATUSES
if item_state["ready"]:
ready_items.append(item_id)
state["items"] = item_states
state["blocked_items"] = blocked_items
state["ready_items"] = ready_items
state["graph_profile"] = graph_profile_as_dict(profile)
state["initiative_fulfillment_ratio"] = _initiative_fulfillment_ratio(item_states)
return state
def compute_initiative_graph_state_with_profile(
*,
items: list[dict[str, Any]],
dependencies: list[dict[str, Any]],
criteria_progress: Optional[dict[str, dict[str, int]]] = None,
method_key: Optional[str] = None,
) -> dict[str, Any]:
base = compute_initiative_graph_state(
items=items,
dependencies=dependencies,
criteria_progress=criteria_progress,
)
profile = get_graph_profile(method_key)
result = apply_graph_method_profile(base, profile)
result["method_key"] = method_key or DEFAULT_METHOD_KEY
return result
def load_initiative_graph_state(
*, tenant_id: str, initiative_id: str
) -> dict[str, Any]:
"""Lädt Items, Kanten und Kriterien-Fortschritt und berechnet Graph-State."""
"""Lädt Items, Kanten, Kriterien und Methodenprofil."""
from services import roadmap as roadmap_service
from services import roadmap_criteria as criteria_service
from services.steering_context import get_steering_context
items = roadmap_service.list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
@ -147,8 +215,12 @@ def load_initiative_graph_state(
criteria_progress = criteria_service.criteria_progress_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
return compute_initiative_graph_state(
steering = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
method_key = (steering or {}).get("method_key") or DEFAULT_METHOD_KEY
return compute_initiative_graph_state_with_profile(
items=items,
dependencies=dependencies,
criteria_progress=criteria_progress,
method_key=method_key,
)

View File

@ -17,6 +17,7 @@ AttentionKind = Literal[
"initiative_without_next_action",
"stale_initiative",
"milestone_at_risk",
"gate_graph_blocked",
"overdue_action",
"review_due",
"recurring_due",
@ -250,6 +251,71 @@ def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _graph_blocked_gates(cur, ctx: TenantContext) -> list[dict[str, Any]]:
from steering.graph.profiles import enforce_gate_blocking_for_method
from steering.graph.roadmap_engine import load_initiative_graph_state
cur.execute(
"""
SELECT sc.initiative_id, sc.method_key
FROM steering_contexts sc
JOIN initiatives i
ON i.id = sc.initiative_id AND i.tenant_id = sc.tenant_id
WHERE sc.tenant_id = %s AND i.status IN ('active', 'paused')
ORDER BY i.updated_at DESC
LIMIT 30
""",
(ctx.tenant_id,),
)
items: list[dict[str, Any]] = []
for row in cur.fetchall():
method_key = row.get("method_key") or "generic_operating"
if not enforce_gate_blocking_for_method(method_key):
continue
initiative_id = str(row["initiative_id"])
state = load_initiative_graph_state(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
for gate_id in state.get("blocked_items", []):
cur.execute(
"""
SELECT title FROM roadmap_items
WHERE id = %s AND tenant_id = %s
""",
(gate_id, ctx.tenant_id),
)
gate_row = cur.fetchone()
if not gate_row:
continue
blocked_by = state["items"].get(gate_id, {}).get("blocked_by") or []
summary = (
"Graph-Voraussetzung noch nicht erreicht"
if len(blocked_by) != 1
else "Graph-Voraussetzung noch nicht erreicht"
)
items.append(
{
"kind": "gate_graph_blocked",
"severity": "warning",
"title": gate_row["title"],
"summary": summary,
"scope_type": "milestone",
"scope_id": gate_id,
"initiative_id": initiative_id,
"action_id": None,
"blocker_id": None,
"milestone_id": gate_id,
"reason_code": "gate_graph_blocked",
"data_source": "roadmap_graph",
}
)
if len(items) >= 20:
return items
return items
def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
@ -424,6 +490,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
items.extend(_unassigned_actions(cur, ctx))
items.extend(_initiatives_without_next_action(cur, ctx))
items.extend(_stale_initiatives(cur, ctx))
items.extend(_graph_blocked_gates(cur, ctx))
items.extend(_milestones_at_risk(cur, ctx))
items.extend(_overdue_actions(cur, ctx))
items.extend(_actions_review_required(cur, ctx))

View File

@ -242,3 +242,30 @@ def test_initiative_roadmap_graph_state(client):
assert body["items"][gate_a["id"]]["blocked"] is True
assert gate_b["id"] in body["items"][gate_a["id"]]["blocked_by"]
assert gate_a["id"] not in body["ready_items"]
def test_graph_blocked_attention_for_gate_enforcing_method(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
client.patch(
f"/api/steering/initiatives/{initiative_id}/context",
json={"method_key": "product_milestone_driven"},
headers=_auth(token),
)
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A", sort_order=0).json()
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B", sort_order=1).json()
client.post(
f"/api/roadmap-items/{gate_a['id']}/dependencies",
json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
headers=_auth(token),
)
attention = client.get("/api/workspace/attention", headers=_auth(token))
assert attention.status_code == 200
assert any(
i["kind"] == "gate_graph_blocked" and i["milestone_id"] == gate_a["id"]
for i in attention.json()
)

View File

@ -0,0 +1,70 @@
"""Unit tests for graph method profiles (AP1.4e)."""
from steering.graph.profiles import get_graph_profile
from steering.graph.roadmap_engine import (
apply_graph_method_profile,
compute_initiative_graph_state,
compute_initiative_graph_state_with_profile,
)
def _item(item_id: str, status: str = "planned", **kwargs):
return {
"id": item_id,
"status": status,
"sort_order": kwargs.get("sort_order", 0),
"sequencing_mode": kwargs.get("sequencing_mode", "sequential"),
}
def test_generic_operating_does_not_enforce_blocking():
items = [
_item("first", "planned", sort_order=0),
_item("second", "planned", sort_order=1),
]
state = compute_initiative_graph_state_with_profile(
items=items,
dependencies=[],
method_key="generic_operating",
)
assert state["graph_profile"]["enforce_gate_blocking"] is False
assert state["items"]["second"]["would_block"] is True
assert state["items"]["second"]["blocked"] is False
assert state["items"]["second"]["ready"] is True
assert state["blocked_items"] == []
def test_product_milestone_enforces_blocking():
items = [
_item("first", "planned", sort_order=0),
_item("second", "planned", sort_order=1),
]
state = compute_initiative_graph_state_with_profile(
items=items,
dependencies=[],
method_key="product_milestone_driven",
)
assert state["graph_profile"]["enforce_gate_blocking"] is True
assert state["items"]["second"]["blocked"] is True
assert "second" in state["blocked_items"]
def test_maturity_progression_emphasizes_fulfillment():
profile = get_graph_profile("maturity_progression")
assert profile.emphasize_fulfillment is True
assert profile.enforce_gate_blocking is False
def test_initiative_fulfillment_ratio_average():
items = [_item("a", "active"), _item("b", "active")]
base = compute_initiative_graph_state(
items=items,
dependencies=[],
criteria_progress={
"a": {"total": 2, "closed": 2, "open": 0},
"b": {"total": 4, "closed": 2, "open": 2},
},
)
profile = get_graph_profile("maturity_progression")
state = apply_graph_method_profile(base, profile)
assert state["initiative_fulfillment_ratio"] == 0.75

View File

@ -0,0 +1,19 @@
export function GateFulfillmentBadge({ graphState, itemId }) {
const ratio = graphState?.items?.[itemId]?.fulfillment_ratio
if (ratio == null) return null
const pct = Math.round(ratio * 100)
const emphasize = graphState?.graph_profile?.emphasize_fulfillment
return (
<span
className={
'status-pill status-pill--fulfillment' +
(emphasize ? ' status-pill--fulfillment-primary' : '')
}
title="Kriterien erfüllt / gesamt"
>
{pct}% Kriterien
</span>
)
}

View File

@ -3,6 +3,10 @@ import { gateTitleById } from './GateSelect.jsx'
export function GateGraphStateBadge({ graphState, itemId, siblingItems = [] }) {
if (!graphState?.items?.[itemId]) return null
if (graphState.graph_profile?.enforce_gate_blocking === false) {
return null
}
const { blocked, ready, blocked_by: blockedBy = [] } = graphState.items[itemId]
if (ready) {

View File

@ -18,27 +18,29 @@ function truncateTitle(title, max = 28) {
return `${title.slice(0, max - 1)}`
}
export function GateMapView({ initiativeId, items }) {
export function GateMapView({ initiativeId, items, graphState: graphStateProp }) {
const navigate = useNavigate()
const [dependencies, setDependencies] = useState([])
const [graphState, setGraphState] = useState(null)
const [graphStateLocal, setGraphStateLocal] = useState(null)
const graphState = graphStateProp ?? graphStateLocal
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
if (!initiativeId) {
setDependencies([])
setGraphState(null)
if (!graphStateProp) setGraphStateLocal(null)
setLoading(false)
return undefined
}
let cancelled = false
setLoading(true)
setError(null)
Promise.allSettled([
listInitiativeRoadmapDependencies(initiativeId),
listInitiativeRoadmapGraphState(initiativeId),
]).then(([depsResult, graphResult]) => {
const loaders = [listInitiativeRoadmapDependencies(initiativeId)]
if (!graphStateProp) {
loaders.push(listInitiativeRoadmapGraphState(initiativeId))
}
Promise.allSettled(loaders).then(([depsResult, graphResult]) => {
if (cancelled) return
if (depsResult.status === 'fulfilled') {
setDependencies(Array.isArray(depsResult.value) ? depsResult.value : [])
@ -47,17 +49,17 @@ export function GateMapView({ initiativeId, items }) {
depsResult.reason?.message || 'Abhängigkeiten konnten nicht geladen werden.',
)
}
if (graphResult.status === 'fulfilled') {
setGraphState(graphResult.value || null)
} else {
setGraphState(null)
if (!graphStateProp && graphResult?.status === 'fulfilled') {
setGraphStateLocal(graphResult.value || null)
} else if (!graphStateProp) {
setGraphStateLocal(null)
}
setLoading(false)
})
return () => {
cancelled = true
}
}, [initiativeId])
}, [initiativeId, graphStateProp])
const layout = useMemo(
() => computeGateGraphLayout(items, dependencies),
@ -152,10 +154,11 @@ export function GateMapView({ initiativeId, items }) {
{layout.nodes.map((node) => {
const item = node.item
const nodeGraph = graphState?.items?.[node.id]
const enforceBlocking = graphState?.graph_profile?.enforce_gate_blocking !== false
const nodeClass =
'gate-map-view__node gate-map-view__node--' +
(item.status || 'planned') +
(nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') +
(enforceBlocking && nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') +
(nodeGraph?.ready ? ' gate-map-view__node--ready' : '')
return (
<g

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { RoadmapPlanSection } from './RoadmapPlanSection.jsx'
import { GateMapView } from './GateMapView.jsx'
import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js'
import { listInitiativeRoadmapGraphState } from '../api/roadmap.js'
export function GatesPlanPanel({
initiativeId,
@ -13,18 +14,56 @@ export function GatesPlanPanel({
busy,
}) {
const [viewMode, setViewMode] = useState(() => resolveGatesViewMode(initiativeId, items.length))
const [graphState, setGraphState] = useState(null)
useEffect(() => {
setViewMode(resolveGatesViewMode(initiativeId, items.length))
}, [initiativeId, items.length])
useEffect(() => {
if (!initiativeId) {
setGraphState(null)
return undefined
}
let cancelled = false
listInitiativeRoadmapGraphState(initiativeId)
.then((data) => {
if (!cancelled) setGraphState(data || null)
})
.catch(() => {
if (!cancelled) setGraphState(null)
})
return () => {
cancelled = true
}
}, [initiativeId, items])
function handleViewChange(mode) {
setViewMode(mode)
storeGatesViewMode(initiativeId, mode)
}
const fulfillmentPct =
graphState?.initiative_fulfillment_ratio != null
? Math.round(graphState.initiative_fulfillment_ratio * 100)
: null
return (
<div className="gates-plan-panel">
{graphState?.graph_profile?.emphasize_fulfillment && fulfillmentPct != null && (
<p className="gates-plan-panel__fulfillment muted">
Erfüllungsgrad (Plan): <strong>{fulfillmentPct}%</strong> Reifegrad zählt, kein
Gate-Zwang.
</p>
)}
{graphState?.graph_profile?.enforce_gate_blocking === false &&
!graphState?.graph_profile?.emphasize_fulfillment && (
<p className="gates-plan-panel__method-hint muted">
Gates sind optional der Graph blockiert nicht hart; Reihenfolge über Kanten oder
Liste.
</p>
)}
<div className="gates-plan-panel__toolbar" role="tablist" aria-label="Zielzustände Ansicht">
<button
type="button"
@ -59,9 +98,10 @@ export function GatesPlanPanel({
onReorder={onReorder}
onDelete={onDelete}
busy={busy}
graphState={graphState}
/>
) : (
<GateMapView initiativeId={initiativeId} items={items} />
<GateMapView initiativeId={initiativeId} items={items} graphState={graphState} />
)}
</div>
)

View File

@ -2,11 +2,10 @@ import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { gatePath } from '../utils/routes.js'
import { listInitiativeRoadmapCriteriaProgress, listInitiativeRoadmapGraphState } from '../api/roadmap.js'
import {
ROADMAP_ITEM_TYPE_LABELS,
} from '../constants/status.js'
import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { GateGraphStateBadge } from './GateGraphStateBadge.jsx'
import { GateFulfillmentBadge } from './GateFulfillmentBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
import { Modal } from './Modal.jsx'
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
@ -33,42 +32,46 @@ export function RoadmapPlanSection({
onReorder,
onDelete,
busy,
graphState: graphStateProp,
}) {
const [showCreate, setShowCreate] = useState(false)
const [dragItemId, setDragItemId] = useState('')
const [dropTargetId, setDropTargetId] = useState('')
const [criteriaProgress, setCriteriaProgress] = useState({})
const [graphState, setGraphState] = useState(null)
const [graphStateLocal, setGraphStateLocal] = useState(null)
const graphState = graphStateProp ?? graphStateLocal
const isDesktop = useMinWidth(1024)
const canReorder = canManage && typeof onReorder === 'function'
useEffect(() => {
if (!initiativeId) {
setCriteriaProgress({})
setGraphState(null)
if (!graphStateProp) setGraphStateLocal(null)
return undefined
}
let cancelled = false
Promise.all([
listInitiativeRoadmapCriteriaProgress(initiativeId),
listInitiativeRoadmapGraphState(initiativeId),
])
.then(([progressData, graphData]) => {
if (!cancelled) {
setCriteriaProgress(progressData || {})
setGraphState(graphData || null)
const loaders = [listInitiativeRoadmapCriteriaProgress(initiativeId)]
if (!graphStateProp) {
loaders.push(listInitiativeRoadmapGraphState(initiativeId))
}
Promise.all(loaders)
.then((results) => {
if (cancelled) return
setCriteriaProgress(results[0] || {})
if (!graphStateProp && results[1]) {
setGraphStateLocal(results[1] || null)
}
})
.catch(() => {
if (!cancelled) {
setCriteriaProgress({})
setGraphState(null)
if (!graphStateProp) setGraphStateLocal(null)
}
})
return () => {
cancelled = true
}
}, [initiativeId, items])
}, [initiativeId, items, graphStateProp])
const sortedItems = useMemo(() => sortByOrder(items), [items])
@ -204,6 +207,7 @@ export function RoadmapPlanSection({
itemId={item.id}
siblingItems={sortedItems}
/>
<GateFulfillmentBadge graphState={graphState} itemId={item.id} />
{canReorder && !isDesktop && (
<ReorderControls
itemId={item.id}

View File

@ -76,6 +76,7 @@ export const ATTENTION_KIND_LABELS = {
initiative_without_next_action: 'Keine nächste Maßnahme',
stale_initiative: 'Inaktives Vorhaben',
milestone_at_risk: 'Meilenstein gefährdet',
gate_graph_blocked: 'Gate blockiert (Graph)',
overdue_action: 'Überfällig',
review_due: 'Review fällig',
recurring_due: 'Wiederkehrend fällig',

View File

@ -1797,6 +1797,22 @@
color: #b91c1c;
}
.status-pill--fulfillment {
background: #eff6ff;
color: #1d4ed8;
}
.status-pill--fulfillment-primary {
background: #dbeafe;
font-weight: 700;
}
.gates-plan-panel__fulfillment,
.gates-plan-panel__method-hint {
margin: 0 0 0.75rem;
font-size: 0.875rem;
}
.gate-map-view__node-title {
fill: var(--jk-text, #1a1a1a);
font-size: 14px;

View File

@ -7,7 +7,7 @@ import { WidgetCard } from '../components/WidgetCard.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
import { actionPath, scopedPath } from '../utils/routes.js'
import { actionPath, gatePath, scopedPath } from '../utils/routes.js'
const SEVERITY_LABELS = {
critical: 'Kritisch',
@ -16,6 +16,9 @@ const SEVERITY_LABELS = {
}
function attentionLink(item) {
if (item.milestone_id) {
return gatePath(item.milestone_id)
}
if (item.action_id) {
return actionPath(item.action_id)
}