From 09433b5d287a72700527671cb2b51b98337f31fc Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 5 Jul 2026 18:45:36 +0200 Subject: [PATCH] AP1.2 + AP1.4: Signals konsolidieren, RoadmapItem mit Gate-Verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entfernt operating_phase aus dem Snapshot zugunsten von Lifecycle + signals. Führt methodenneutrale RoadmapItems (Migration 010), Verify-Pfad und Plan-UI ein. Milestone-API bleibt als Compat-Wrapper; Version 0.13.0-ap1.4. Co-authored-by: Cursor --- backend/data_layer/initiative_snapshot.py | 93 +-- backend/main.py | 3 + backend/migrations/010_roadmap_items.sql | 86 +++ backend/routers/roadmap.py | 198 ++++++ backend/services/evidence.py | 12 + backend/services/milestones.py | 233 ++----- backend/services/roadmap.py | 621 ++++++++++++++++++ backend/steering/signals/default_rules.py | 19 +- backend/steering/signals/snapshot_signals.py | 47 ++ .../next_action/product_milestone_driven.py | 22 +- backend/tests/test_ap10_integration.py | 13 +- backend/tests/test_ap12_signals.py | 44 ++ backend/tests/test_ap14_roadmap.py | 122 ++++ backend/tests/test_ap1_steering_foundation.py | 3 +- backend/version.py | 4 +- .../Kairo_Implementation_Truth_Table_v0.1.md | 14 +- frontend/package.json | 2 +- frontend/src/api/roadmap.js | 40 ++ .../src/components/InitiativeNavCards.jsx | 6 +- .../src/components/RoadmapPlanSection.jsx | 220 +++++++ .../src/components/SteeringSnapshotPanel.jsx | 19 +- frontend/src/constants/status.js | 12 + .../context/InitiativeOperationsContext.jsx | 65 +- .../initiative/InitiativeOverviewPage.jsx | 3 +- .../pages/initiative/InitiativePlanPage.jsx | 24 +- 25 files changed, 1582 insertions(+), 343 deletions(-) create mode 100644 backend/migrations/010_roadmap_items.sql create mode 100644 backend/routers/roadmap.py create mode 100644 backend/services/roadmap.py create mode 100644 backend/steering/signals/snapshot_signals.py create mode 100644 backend/tests/test_ap12_signals.py create mode 100644 backend/tests/test_ap14_roadmap.py create mode 100644 frontend/src/api/roadmap.js create mode 100644 frontend/src/components/RoadmapPlanSection.jsx diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index 84bf4fe..4d36313 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -5,28 +5,17 @@ Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steer from __future__ import annotations -from typing import Any, Literal, Optional +from typing import Any, Optional from psycopg2.extras import RealDictCursor from db import get_connection from data_layer.attention import get_next_action_candidates_for_initiative from steering.context import get_steering_context_dto +from steering.signals.snapshot_signals import derive_initiative_signals 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") @@ -40,51 +29,6 @@ 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]]: @@ -155,14 +99,16 @@ def get_initiative_steering_snapshot( 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 + SELECT id, title, status, target_date, item_type, sequencing_mode, + sort_order, created_at, updated_at + FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.tenant_id = %s AND r.initiative_id = %s + ORDER BY ri.sort_order ASC, ri.updated_at DESC """, (ctx.tenant_id, initiative_id), ) - milestones_raw = [dict(r) for r in cur.fetchall()] + roadmap_items_raw = [dict(r) for r in cur.fetchall()] cur.execute( """ @@ -270,14 +216,17 @@ def get_initiative_steering_snapshot( } ) - milestones = [] - for m in milestones_raw: + roadmap_items: list[dict[str, Any]] = [] + for m in roadmap_items_raw: mid = _sid(m["id"]) - milestones.append( + roadmap_items.append( { "id": mid, "title": m["title"], "status": m["status"], + "item_type": m["item_type"], + "sequencing_mode": m["sequencing_mode"], + "sort_order": m.get("sort_order", 0), "target_date": m["target_date"].isoformat() if m.get("target_date") else None, "reviews": reviews_by_milestone.get(mid, []), } @@ -288,7 +237,7 @@ def get_initiative_steering_snapshot( ) backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted")) - phase, phase_signals = _derive_operating_phase( + signals = derive_initiative_signals( initiative_status=initiative["status"], open_actions=open_actions, blocked_actions=blocked_actions, @@ -301,7 +250,7 @@ def get_initiative_steering_snapshot( upcoming_milestones = sorted( [ m - for m in milestones + for m in roadmap_items if m["status"] in ("planned", "active", "at_risk") ], key=lambda m: (m["target_date"] is None, m["target_date"] or ""), @@ -322,10 +271,9 @@ def get_initiative_steering_snapshot( "method_key": steering["method_key"], "method_label": steering.get("method_label"), "method_version": steering["method_version"], - "operating_phase": phase, - "operating_phase_deprecated": True, - "phase_signals": phase_signals, + "signals": signals, "upcoming_milestones": upcoming_milestones, + "upcoming_roadmap_items": upcoming_milestones, "next_actions": next_actions, "counts": { "actions_open": open_actions, @@ -341,7 +289,8 @@ def get_initiative_steering_snapshot( "unlinked_reviews": len(unlinked_reviews), }, "actions": actions, - "milestones": milestones, + "roadmap_items": roadmap_items, + "milestones": roadmap_items, "initiative_level": { "blockers": unlinked_blockers, "evidence": unlinked_evidence, diff --git a/backend/main.py b/backend/main.py index e3a1a66..a406bb7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -68,6 +68,7 @@ from routers import ( # noqa: E402 initiatives, me, milestones, + roadmap, prompts, recurring, reviews, @@ -85,6 +86,8 @@ app.include_router(actions.router) app.include_router(blockers.router) app.include_router(backlog.router) app.include_router(milestones.router) +app.include_router(roadmap.initiative_router) +app.include_router(roadmap.items_router) app.include_router(evidence.router) app.include_router(decisions.router) app.include_router(reviews.router) diff --git a/backend/migrations/010_roadmap_items.sql b/backend/migrations/010_roadmap_items.sql new file mode 100644 index 0000000..49522c4 --- /dev/null +++ b/backend/migrations/010_roadmap_items.sql @@ -0,0 +1,86 @@ +-- AP1.4: Roadmap + RoadmapItem + Dependencies (methodenneutral) + +CREATE TABLE roadmaps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL DEFAULT 'Plan', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, initiative_id) +); + +CREATE INDEX idx_roadmaps_tenant_initiative ON roadmaps(tenant_id, initiative_id); + +CREATE TABLE roadmap_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE, + item_type VARCHAR(32) NOT NULL DEFAULT 'milestone' + CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage')), + title VARCHAR(255) NOT NULL, + goal_description TEXT NOT NULL DEFAULT '', + definition_of_done JSONB NOT NULL DEFAULT '[]'::jsonb, + status VARCHAR(32) NOT NULL DEFAULT 'planned' + CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')), + sequencing_mode VARCHAR(32) NOT NULL DEFAULT 'sequential' + CHECK (sequencing_mode IN ('sequential', 'parallel', 'optional')), + target_date DATE NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_roadmap_items_tenant_roadmap ON roadmap_items(tenant_id, roadmap_id); +CREATE INDEX idx_roadmap_items_tenant_status ON roadmap_items(tenant_id, status); +CREATE INDEX idx_roadmap_items_tenant_type ON roadmap_items(tenant_id, item_type); + +CREATE TABLE roadmap_item_dependencies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + from_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE, + to_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE, + dependency_type VARCHAR(32) NOT NULL DEFAULT 'requires' + CHECK (dependency_type IN ('requires', 'blocks', 'related')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, from_item_id, to_item_id, dependency_type) +); + +CREATE INDEX idx_roadmap_deps_from ON roadmap_item_dependencies(tenant_id, from_item_id); +CREATE INDEX idx_roadmap_deps_to ON roadmap_item_dependencies(tenant_id, to_item_id); + +ALTER TABLE backlog_items + ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL; + +ALTER TABLE actions + ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL; + +CREATE INDEX idx_backlog_roadmap_item ON backlog_items(tenant_id, roadmap_item_id) + WHERE roadmap_item_id IS NOT NULL; +CREATE INDEX idx_actions_roadmap_item ON actions(tenant_id, roadmap_item_id) + WHERE roadmap_item_id IS NOT NULL; + +-- Ein Plan pro Initiative +INSERT INTO roadmaps (tenant_id, initiative_id, title) +SELECT tenant_id, id, 'Plan' FROM initiatives; + +-- Bestehende Meilensteine → RoadmapItems (IDs beibehalten für Evidence/Review-FKs) +INSERT INTO roadmap_items ( + id, tenant_id, roadmap_id, item_type, title, goal_description, status, + sequencing_mode, target_date, sort_order, created_at, updated_at +) +SELECT + m.id, + m.tenant_id, + r.id, + 'milestone', + m.title, + m.goal_description, + m.status, + 'sequential', + m.target_date, + 0, + m.created_at, + m.updated_at +FROM milestones m +JOIN roadmaps r ON r.initiative_id = m.initiative_id AND r.tenant_id = m.tenant_id; diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py new file mode 100644 index 0000000..015044c --- /dev/null +++ b/backend/routers/roadmap.py @@ -0,0 +1,198 @@ +"""Roadmap API — AP1.4.""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Literal, Optional + +from capabilities import require_capability +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from services import roadmap as roadmap_service +from tenant_context import TenantContext + +router = APIRouter(tags=["roadmap"]) + + +class RoadmapItemCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + item_type: Literal["milestone", "review_gate", "maturity_stage"] = "milestone" + goal_description: str = "" + definition_of_done: list[Any] = Field(default_factory=list) + status: Literal[ + "planned", "active", "at_risk", "reached", "moved", "discarded" + ] = "planned" + sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential" + target_date: Optional[date] = None + sort_order: int = 0 + + +class RoadmapItemUpdateRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=255) + item_type: Optional[Literal["milestone", "review_gate", "maturity_stage"]] = None + goal_description: Optional[str] = None + definition_of_done: Optional[list[Any]] = None + status: Optional[ + Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] + ] = None + sequencing_mode: Optional[Literal["sequential", "parallel", "optional"]] = None + target_date: Optional[date] = None + clear_target_date: bool = False + sort_order: Optional[int] = None + + +class DependencyCreateRequest(BaseModel): + to_item_id: str + dependency_type: Literal["requires", "blocks", "related"] = "requires" + + +initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"]) +items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"]) + + +@initiative_router.get("/{initiative_id}/roadmap") +def get_initiative_roadmap( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + roadmap = roadmap_service.get_roadmap_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not roadmap: + return {"initiative_id": initiative_id, "items": []} + items = roadmap_service.list_roadmap_items_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + return {**roadmap, "items": items} + + +@initiative_router.get("/{initiative_id}/roadmap/items") +def list_initiative_roadmap_items( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + return roadmap_service.list_roadmap_items_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201) +def create_initiative_roadmap_item( + initiative_id: str, + body: RoadmapItemCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + try: + return roadmap_service.create_roadmap_item( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + user_id=ctx.user_id, + title=body.title, + item_type=body.item_type, + goal_description=body.goal_description, + definition_of_done=body.definition_of_done, + status=body.status, + sequencing_mode=body.sequencing_mode, + target_date=body.target_date, + sort_order=body.sort_order, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@items_router.get("/{item_id}") +def get_roadmap_item( + item_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + item = roadmap_service.get_roadmap_item(tenant_id=ctx.tenant_id, item_id=item_id) + if not item: + raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") + return item + + +@items_router.patch("/{item_id}") +def update_roadmap_item( + item_id: str, + body: RoadmapItemUpdateRequest, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + try: + item = roadmap_service.update_roadmap_item( + tenant_id=ctx.tenant_id, + item_id=item_id, + user_id=ctx.user_id, + title=body.title, + item_type=body.item_type, + goal_description=body.goal_description, + definition_of_done=body.definition_of_done, + status=body.status, + sequencing_mode=body.sequencing_mode, + target_date=body.target_date, + clear_target_date=body.clear_target_date, + sort_order=body.sort_order, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") + return item + + +@items_router.delete("/{item_id}", status_code=204) +def delete_roadmap_item( + item_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + if not roadmap_service.delete_roadmap_item( + tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id + ): + raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") + + +@items_router.post("/{item_id}/verify-reached") +def verify_roadmap_item_reached( + item_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + try: + return roadmap_service.verify_reached( + tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@items_router.get("/{item_id}/dependencies") +def list_roadmap_item_dependencies( + item_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + return roadmap_service.list_dependencies(tenant_id=ctx.tenant_id, item_id=item_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@items_router.post("/{item_id}/dependencies", status_code=201) +def add_roadmap_item_dependency( + item_id: str, + body: DependencyCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + try: + return roadmap_service.add_dependency( + tenant_id=ctx.tenant_id, + from_item_id=item_id, + to_item_id=body.to_item_id, + dependency_type=body.dependency_type, + user_id=ctx.user_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/services/evidence.py b/backend/services/evidence.py index f82d31a..e4c23e2 100644 --- a/backend/services/evidence.py +++ b/backend/services/evidence.py @@ -85,6 +85,18 @@ def _milestone_in_initiative( """, (milestone_id, tenant_id, initiative_id), ) + if cur.fetchone(): + return True + cur.execute( + """ + SELECT 1 + FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s + AND ri.item_type = 'milestone' + """, + (milestone_id, tenant_id, initiative_id), + ) return cur.fetchone() is not None finally: conn.close() diff --git a/backend/services/milestones.py b/backend/services/milestones.py index dc4d553..2a52a1b 100644 --- a/backend/services/milestones.py +++ b/backend/services/milestones.py @@ -1,46 +1,31 @@ -"""Milestone service — tenant-scoped CRUD (AP0.8d).""" +"""Milestone service — Kompatibilitäts-Wrapper auf RoadmapItems (AP0.8d → AP1.4).""" from __future__ import annotations from datetime import date from typing import Any, Literal, Optional -from psycopg2.extras import RealDictCursor - -from db import get_connection -from services.audit import log_audit -from services.initiatives import get_initiative +from services import roadmap as roadmap_service MilestoneStatus = Literal[ "planned", "active", "at_risk", "reached", "moved", "discarded" ] -MILESTONE_STATUSES = frozenset( - {"planned", "active", "at_risk", "reached", "moved", "discarded"} -) +MILESTONE_STATUSES = roadmap_service.ROADMAP_ITEM_STATUSES -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("target_date"): - result["target_date"] = ( - result["target_date"].isoformat() - if hasattr(result["target_date"], "isoformat") - else str(result["target_date"]) - ) - if result.get("created_at"): - result["created_at"] = result["created_at"].isoformat() - if result.get("updated_at"): - result["updated_at"] = result["updated_at"].isoformat() - return result - - -def _validate_status(status: str) -> None: - if status not in MILESTONE_STATUSES: - raise ValueError(f"Ungültiger Meilenstein-Status: {status}") +def _as_milestone(item: dict[str, Any]) -> dict[str, Any]: + return { + "id": item["id"], + "tenant_id": item["tenant_id"], + "initiative_id": item["initiative_id"], + "title": item["title"], + "goal_description": item.get("goal_description", ""), + "status": item["status"], + "target_date": item.get("target_date"), + "created_at": item["created_at"], + "updated_at": item["updated_at"], + } def create_milestone( @@ -53,80 +38,31 @@ def create_milestone( target_date: Optional[date] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: - title = title.strip() - if not title: - raise ValueError("Titel ist erforderlich") - _validate_status(status) - if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): - raise ValueError("Initiative nicht gefunden") - - conn = get_connection() - try: - with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute( - """ - INSERT INTO milestones ( - tenant_id, initiative_id, title, goal_description, status, target_date - ) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id, tenant_id, initiative_id, title, goal_description, status, - target_date, created_at, updated_at - """, - (tenant_id, initiative_id, title, goal_description, status, target_date), - ) - row = _serialize_row(dict(cur.fetchone())) - conn.commit() - finally: - conn.close() - - log_audit( - "milestone.created", - user_id=user_id, + item = roadmap_service.create_roadmap_item( tenant_id=tenant_id, - details={"milestone_id": row["id"], "initiative_id": initiative_id, "title": title}, + initiative_id=initiative_id, + title=title, + item_type="milestone", + goal_description=goal_description, + status=status, + target_date=target_date, + user_id=user_id, ) - return row + return _as_milestone(item) def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]: - if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): - raise ValueError("Initiative nicht gefunden") - - conn = get_connection() - try: - with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute( - """ - SELECT id, tenant_id, initiative_id, title, goal_description, status, - target_date, created_at, updated_at - FROM milestones - WHERE tenant_id = %s AND initiative_id = %s - ORDER BY target_date NULLS LAST, updated_at DESC, title - """, - (tenant_id, initiative_id), - ) - return [_serialize_row(dict(r)) for r in cur.fetchall()] - finally: - conn.close() + items = roadmap_service.list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + return [_as_milestone(i) for i in items if i["item_type"] == "milestone"] def get_milestone(*, tenant_id: str, milestone_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, title, goal_description, status, - target_date, created_at, updated_at - FROM milestones - WHERE id = %s AND tenant_id = %s - """, - (milestone_id, tenant_id), - ) - row = cur.fetchone() - return _serialize_row(dict(row)) if row else None - finally: - conn.close() + item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id) + if not item or item["item_type"] != "milestone": + return None + return _as_milestone(item) def update_milestone( @@ -140,78 +76,17 @@ def update_milestone( target_date: Optional[date] = None, clear_target_date: bool = False, ) -> Optional[dict[str, Any]]: - existing = get_milestone(tenant_id=tenant_id, milestone_id=milestone_id) - if not existing: - return None - - old_status = existing["status"] - updates: list[str] = [] - params: list[Any] = [] - - if title is not None: - title = title.strip() - if not title: - raise ValueError("Titel ist erforderlich") - updates.append("title = %s") - params.append(title) - if goal_description is not None: - updates.append("goal_description = %s") - params.append(goal_description) - if status is not None: - _validate_status(status) - updates.append("status = %s") - params.append(status) - if clear_target_date: - updates.append("target_date = NULL") - elif target_date is not None: - updates.append("target_date = %s") - params.append(target_date) - - if not updates: - return existing - - updates.append("updated_at = NOW()") - params.extend([milestone_id, tenant_id]) - - conn = get_connection() - try: - with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute( - f""" - UPDATE milestones - SET {", ".join(updates)} - WHERE id = %s AND tenant_id = %s - RETURNING id, tenant_id, initiative_id, title, goal_description, status, - target_date, created_at, updated_at - """, - params, - ) - row = cur.fetchone() - if not row: - return None - result = _serialize_row(dict(row)) - conn.commit() - finally: - conn.close() - - log_audit( - "milestone.updated", - user_id=user_id, + item = roadmap_service.update_roadmap_item( tenant_id=tenant_id, - details={"milestone_id": milestone_id}, + item_id=milestone_id, + user_id=user_id, + title=title, + goal_description=goal_description, + status=status, + target_date=target_date, + clear_target_date=clear_target_date, ) - if status is not None and status != old_status: - log_audit( - "milestone.status_changed", - user_id=user_id, - tenant_id=tenant_id, - details={ - "milestone_id": milestone_id, - "from_status": old_status, - "to_status": status, - }, - ) - return result + return _as_milestone(item) if item else None def delete_milestone( @@ -220,23 +95,9 @@ def delete_milestone( milestone_id: str, user_id: Optional[str] = None, ) -> bool: - conn = get_connection() - try: - with conn.cursor() as cur: - cur.execute( - "DELETE FROM milestones WHERE id = %s AND tenant_id = %s RETURNING id", - (milestone_id, tenant_id), - ) - deleted = cur.fetchone() is not None - conn.commit() - finally: - conn.close() - - if deleted: - log_audit( - "milestone.deleted", - user_id=user_id, - tenant_id=tenant_id, - details={"milestone_id": milestone_id}, - ) - return deleted + item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id) + if not item or item["item_type"] != "milestone": + return False + return roadmap_service.delete_roadmap_item( + tenant_id=tenant_id, item_id=milestone_id, user_id=user_id + ) diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py new file mode 100644 index 0000000..824f08a --- /dev/null +++ b/backend/services/roadmap.py @@ -0,0 +1,621 @@ +"""Roadmap service — tenant-scoped Plan/Gates (AP1.4).""" + +from __future__ import annotations + +import json +from datetime import date +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.audit import log_audit +from services.initiatives import get_initiative + +RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage"] +RoadmapItemStatus = Literal[ + "planned", "active", "at_risk", "reached", "moved", "discarded" +] +SequencingMode = Literal["sequential", "parallel", "optional"] +DependencyType = Literal["requires", "blocks", "related"] + +ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"}) +ROADMAP_ITEM_STATUSES = frozenset( + {"planned", "active", "at_risk", "reached", "moved", "discarded"} +) +SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"}) +DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related"}) + +TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"}) + + +def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id"): + if result.get(key): + result[key] = str(result[key]) + if result.get("target_date"): + result["target_date"] = ( + result["target_date"].isoformat() + if hasattr(result["target_date"], "isoformat") + else str(result["target_date"]) + ) + for ts in ("created_at", "updated_at"): + if result.get(ts): + result[ts] = result[ts].isoformat() + dod = result.get("definition_of_done") + if isinstance(dod, str): + result["definition_of_done"] = json.loads(dod) + return result + + +def _validate_item_type(item_type: str) -> None: + if item_type not in ROADMAP_ITEM_TYPES: + raise ValueError(f"Ungültiger RoadmapItem-Typ: {item_type}") + + +def _validate_status(status: str) -> None: + if status not in ROADMAP_ITEM_STATUSES: + raise ValueError(f"Ungültiger RoadmapItem-Status: {status}") + + +def _validate_sequencing_mode(mode: str) -> None: + if mode not in SEQUENCING_MODES: + raise ValueError(f"Ungültiger sequencing_mode: {mode}") + + +def _validate_dependency_type(dep_type: str) -> None: + if dep_type not in DEPENDENCY_TYPES: + raise ValueError(f"Ungültiger dependency_type: {dep_type}") + + +def _sync_milestone_compat_row( + cur, + *, + tenant_id: str, + initiative_id: str, + item: dict[str, Any], +) -> None: + """Hält milestones-Tabelle für Evidence/Review-FKs synchron (Compat bis Drop).""" + if item["item_type"] != "milestone": + cur.execute( + "DELETE FROM milestones WHERE id = %s AND tenant_id = %s", + (item["id"], tenant_id), + ) + return + + target_date = item.get("target_date") + if target_date and isinstance(target_date, str): + target_date = date.fromisoformat(target_date) + + cur.execute( + """ + INSERT INTO milestones ( + id, tenant_id, initiative_id, title, goal_description, status, target_date + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + goal_description = EXCLUDED.goal_description, + status = EXCLUDED.status, + target_date = EXCLUDED.target_date, + updated_at = NOW() + """, + ( + item["id"], + tenant_id, + initiative_id, + item["title"], + item.get("goal_description", ""), + item["status"], + target_date, + ), + ) + + +def ensure_roadmap(*, tenant_id: str, initiative_id: str) -> dict[str, Any]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, title, created_at, updated_at + FROM roadmaps + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + row = cur.fetchone() + if row: + return _serialize_row(dict(row)) + + cur.execute( + """ + INSERT INTO roadmaps (tenant_id, initiative_id, title) + VALUES (%s, %s, 'Plan') + RETURNING id, tenant_id, initiative_id, title, created_at, updated_at + """, + (tenant_id, initiative_id), + ) + result = _serialize_row(dict(cur.fetchone())) + conn.commit() + return result + finally: + conn.close() + + +def get_roadmap_for_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, title, created_at, updated_at + FROM roadmaps + 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 list_roadmap_items_for_initiative( + *, tenant_id: str, initiative_id: str +) -> list[dict[str, Any]]: + roadmap = get_roadmap_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id) + if not roadmap: + return [] + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT + ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id, + ri.item_type, ri.title, ri.goal_description, ri.definition_of_done, + ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order, + ri.created_at, ri.updated_at + FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.tenant_id = %s AND r.initiative_id = %s + ORDER BY ri.sort_order ASC, ri.target_date NULLS LAST, ri.title + """, + (tenant_id, initiative_id), + ) + return [_serialize_row(dict(r)) for r in cur.fetchall()] + finally: + conn.close() + + +def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT + ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id, + ri.item_type, ri.title, ri.goal_description, ri.definition_of_done, + ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order, + ri.created_at, ri.updated_at + FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.id = %s AND ri.tenant_id = %s + """, + (item_id, tenant_id), + ) + row = cur.fetchone() + return _serialize_row(dict(row)) if row else None + finally: + conn.close() + + +def create_roadmap_item( + *, + tenant_id: str, + initiative_id: str, + title: str, + item_type: RoadmapItemType = "milestone", + goal_description: str = "", + definition_of_done: Optional[list[Any]] = None, + status: RoadmapItemStatus = "planned", + sequencing_mode: SequencingMode = "sequential", + target_date: Optional[date] = None, + sort_order: int = 0, + user_id: Optional[str] = None, +) -> dict[str, Any]: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + _validate_item_type(item_type) + _validate_status(status) + if status == "reached": + raise ValueError("Status 'reached' nur über verify-reached") + _validate_sequencing_mode(sequencing_mode) + + roadmap = ensure_roadmap(tenant_id=tenant_id, initiative_id=initiative_id) + dod = definition_of_done if definition_of_done is not None else [] + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO roadmap_items ( + tenant_id, roadmap_id, item_type, title, goal_description, + definition_of_done, status, sequencing_mode, target_date, sort_order + ) + VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s) + RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, + definition_of_done, status, sequencing_mode, target_date, + sort_order, created_at, updated_at + """, + ( + tenant_id, + roadmap["id"], + item_type, + title, + goal_description, + json.dumps(dod), + status, + sequencing_mode, + target_date, + sort_order, + ), + ) + row = _serialize_row(dict(cur.fetchone())) + row["initiative_id"] = initiative_id + _sync_milestone_compat_row( + cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + item=row, + ) + conn.commit() + finally: + conn.close() + + log_audit( + "roadmap_item.created", + user_id=user_id, + tenant_id=tenant_id, + details={ + "roadmap_item_id": row["id"], + "initiative_id": initiative_id, + "item_type": item_type, + "title": title, + }, + ) + return row + + +def update_roadmap_item( + *, + tenant_id: str, + item_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + goal_description: Optional[str] = None, + definition_of_done: Optional[list[Any]] = None, + item_type: Optional[RoadmapItemType] = None, + status: Optional[RoadmapItemStatus] = None, + sequencing_mode: Optional[SequencingMode] = None, + target_date: Optional[date] = None, + clear_target_date: bool = False, + sort_order: Optional[int] = None, +) -> Optional[dict[str, Any]]: + existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) + if not existing: + return None + + old_status = existing["status"] + updates: list[str] = [] + params: list[Any] = [] + + if title is not None: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + updates.append("title = %s") + params.append(title) + if goal_description is not None: + updates.append("goal_description = %s") + params.append(goal_description) + if definition_of_done is not None: + updates.append("definition_of_done = %s::jsonb") + params.append(json.dumps(definition_of_done)) + if item_type is not None: + _validate_item_type(item_type) + updates.append("item_type = %s") + params.append(item_type) + if status is not None: + _validate_status(status) + if status in TERMINAL_STATUSES and status == "reached": + raise ValueError( + "Status 'reached' nur über verify-reached — nicht direkt setzen" + ) + if status in ("moved", "discarded"): + raise ValueError( + "Status moved/discarded erfordert Decision — noch nicht implementiert" + ) + updates.append("status = %s") + params.append(status) + if sequencing_mode is not None: + _validate_sequencing_mode(sequencing_mode) + updates.append("sequencing_mode = %s") + params.append(sequencing_mode) + if clear_target_date: + updates.append("target_date = NULL") + elif target_date is not None: + updates.append("target_date = %s") + params.append(target_date) + if sort_order is not None: + updates.append("sort_order = %s") + params.append(sort_order) + + if not updates: + return existing + + updates.append("updated_at = NOW()") + params.extend([item_id, tenant_id]) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + UPDATE roadmap_items + SET {", ".join(updates)} + WHERE id = %s AND tenant_id = %s + RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, + definition_of_done, status, sequencing_mode, target_date, + sort_order, created_at, updated_at + """, + params, + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + result["initiative_id"] = existing["initiative_id"] + _sync_milestone_compat_row( + cur, + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + item=result, + ) + conn.commit() + finally: + conn.close() + + log_audit( + "roadmap_item.updated", + user_id=user_id, + tenant_id=tenant_id, + details={"roadmap_item_id": item_id}, + ) + if status is not None and status != old_status: + log_audit( + "roadmap_item.status_changed", + user_id=user_id, + tenant_id=tenant_id, + details={ + "roadmap_item_id": item_id, + "from_status": old_status, + "to_status": status, + }, + ) + return result + + +def delete_roadmap_item( + *, tenant_id: str, item_id: str, user_id: Optional[str] = None +) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM milestones WHERE id = %s AND tenant_id = %s", + (item_id, tenant_id), + ) + cur.execute( + "DELETE FROM roadmap_items WHERE id = %s AND tenant_id = %s RETURNING id", + (item_id, tenant_id), + ) + deleted = cur.fetchone() is not None + conn.commit() + finally: + conn.close() + + if deleted: + log_audit( + "roadmap_item.deleted", + user_id=user_id, + tenant_id=tenant_id, + details={"roadmap_item_id": item_id}, + ) + return deleted + + +def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]: + item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) + if not item: + raise ValueError("RoadmapItem nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, from_item_id, to_item_id, dependency_type, created_at + FROM roadmap_item_dependencies + WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s) + ORDER BY created_at ASC + """, + (tenant_id, item_id, item_id), + ) + items = [] + for row in cur.fetchall(): + dep = dict(row) + for key in ("id", "tenant_id", "from_item_id", "to_item_id"): + dep[key] = str(dep[key]) + if dep.get("created_at"): + dep["created_at"] = dep["created_at"].isoformat() + items.append(dep) + return items + finally: + conn.close() + + +def add_dependency( + *, + tenant_id: str, + from_item_id: str, + to_item_id: str, + dependency_type: DependencyType = "requires", + user_id: Optional[str] = None, +) -> dict[str, Any]: + _validate_dependency_type(dependency_type) + if from_item_id == to_item_id: + raise ValueError("Abhängigkeit auf sich selbst nicht erlaubt") + + from_item = get_roadmap_item(tenant_id=tenant_id, item_id=from_item_id) + to_item = get_roadmap_item(tenant_id=tenant_id, item_id=to_item_id) + if not from_item or not to_item: + raise ValueError("RoadmapItem nicht gefunden") + if from_item["initiative_id"] != to_item["initiative_id"]: + raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO roadmap_item_dependencies ( + tenant_id, from_item_id, to_item_id, dependency_type + ) + VALUES (%s, %s, %s, %s) + RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at + """, + (tenant_id, from_item_id, to_item_id, dependency_type), + ) + row = dict(cur.fetchone()) + for key in ("id", "tenant_id", "from_item_id", "to_item_id"): + row[key] = str(row[key]) + row["created_at"] = row["created_at"].isoformat() + conn.commit() + finally: + conn.close() + + log_audit( + "roadmap_item.dependency_added", + user_id=user_id, + tenant_id=tenant_id, + details={ + "from_item_id": from_item_id, + "to_item_id": to_item_id, + "dependency_type": dependency_type, + }, + ) + return row + + +def verify_reached( + *, tenant_id: str, item_id: str, user_id: Optional[str] = None +) -> dict[str, Any]: + """Gate-Verify: reached nur mit accepted Evidence, completed Review oder gate_override Decision.""" + item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) + if not item: + raise ValueError("RoadmapItem nicht gefunden") + if item["status"] in TERMINAL_STATUSES: + raise ValueError(f"RoadmapItem bereits terminal: {item['status']}") + + initiative_id = item["initiative_id"] + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT 1 FROM evidence + WHERE tenant_id = %s AND initiative_id = %s + AND status = 'accepted' + AND milestone_id = %s + LIMIT 1 + """, + (tenant_id, initiative_id, item_id), + ) + if cur.fetchone(): + verify_reason = "evidence_accepted" + else: + cur.execute( + """ + SELECT 1 FROM reviews + WHERE tenant_id = %s AND initiative_id = %s + AND status = 'completed' + AND (milestone_id = %s) + LIMIT 1 + """, + (tenant_id, initiative_id, item_id), + ) + if cur.fetchone(): + verify_reason = "review_completed" + else: + cur.execute( + """ + SELECT 1 FROM decisions + WHERE tenant_id = %s AND initiative_id = %s + AND status = 'decided' + AND title ILIKE %s + LIMIT 1 + """, + (tenant_id, initiative_id, f"gate_override:{item_id}%"), + ) + if cur.fetchone(): + verify_reason = "gate_override_decision" + else: + raise ValueError( + "Verify fehlgeschlagen — accepted Evidence, completed Review " + "oder gate_override Decision erforderlich" + ) + + cur.execute( + """ + UPDATE roadmap_items + SET status = 'reached', updated_at = NOW() + WHERE id = %s AND tenant_id = %s + RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, + definition_of_done, status, sequencing_mode, target_date, + sort_order, created_at, updated_at + """, + (item_id, tenant_id), + ) + row = _serialize_row(dict(cur.fetchone())) + row["initiative_id"] = initiative_id + row["verify_reason"] = verify_reason + _sync_milestone_compat_row( + cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + item=row, + ) + conn.commit() + finally: + conn.close() + + log_audit( + "roadmap_item.reached", + user_id=user_id, + tenant_id=tenant_id, + details={"roadmap_item_id": item_id, "verify_reason": verify_reason}, + ) + return row diff --git a/backend/steering/signals/default_rules.py b/backend/steering/signals/default_rules.py index 00e4393..a3d8b12 100644 --- a/backend/steering/signals/default_rules.py +++ b/backend/steering/signals/default_rules.py @@ -256,19 +256,20 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]: SELECT 'milestone_at_risk' AS kind, 'warning' AS severity, - m.title AS title, - 'Meilenstein als gef├ñhrdet markiert' AS summary, + ri.title AS title, + 'Plan-Element als gefährdet markiert' AS summary, 'milestone' AS scope_type, - m.id AS scope_id, - m.initiative_id, + ri.id AS scope_id, + r.initiative_id, NULL::uuid AS action_id, NULL::uuid AS blocker_id, - m.id AS milestone_id, + ri.id AS milestone_id, 'milestone_at_risk' AS reason_code, - 'milestones' AS data_source - FROM milestones m - WHERE m.tenant_id = %s AND m.status = 'at_risk' - ORDER BY m.updated_at DESC + 'roadmap_items' AS data_source + FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.tenant_id = %s AND ri.status = 'at_risk' + ORDER BY ri.updated_at DESC LIMIT 20 """, (ctx.tenant_id,), diff --git a/backend/steering/signals/snapshot_signals.py b/backend/steering/signals/snapshot_signals.py new file mode 100644 index 0000000..72d4f25 --- /dev/null +++ b/backend/steering/signals/snapshot_signals.py @@ -0,0 +1,47 @@ +"""Initiative snapshot signals — erklärbare Heuristiken ohne operating_phase (AP1.2).""" + +from __future__ import annotations + + +def derive_initiative_signals( + *, + initiative_status: str, + open_actions: int, + blocked_actions: int, + review_required_actions: int, + open_blockers: int, + backlog_new: int, + planned_reviews_due: int, +) -> list[str]: + """Regelbasierte Signale für Steuerungs-Snapshot — kein Lifecycle-Ersatz.""" + if initiative_status in ("completed", "archived"): + return ["initiative_terminal"] + + signals: list[str] = [] + + if open_actions == 0 and backlog_new > 0: + signals.append("backlog_awaiting_commit") + return signals + + if open_actions == 0 and backlog_new == 0: + signals.append("no_open_work") + return signals + + if blocked_actions > 0: + signals.append("actions_blocked") + if open_blockers > 0: + signals.append("open_blockers") + if blocked_actions > 0 or open_blockers > 0: + return signals + + if review_required_actions > 0: + signals.append("actions_need_review") + if planned_reviews_due > 0: + signals.append("reviews_due") + if review_required_actions > 0 or planned_reviews_due > 0: + return signals + + if open_actions > 0: + signals.append("work_in_progress") + + return signals diff --git a/backend/steering/strategies/next_action/product_milestone_driven.py b/backend/steering/strategies/next_action/product_milestone_driven.py index 53cc3d2..efbfa63 100644 --- a/backend/steering/strategies/next_action/product_milestone_driven.py +++ b/backend/steering/strategies/next_action/product_milestone_driven.py @@ -36,20 +36,22 @@ class ProductMilestoneDrivenStrategy: """ SELECT 'review_milestone' AS kind, - m.title AS title, - 'Meilenstein prüfen oder Status aktualisieren' AS summary, - m.initiative_id, + ri.title AS title, + 'Plan-Element prüfen oder Status aktualisieren' AS summary, + r.initiative_id, NULL::uuid AS action_id, NULL::uuid AS backlog_item_id, 'milestone_at_risk_or_active' AS reason_code, - 'Meilenstein prüfen' AS recommended_action - FROM milestones m - WHERE m.tenant_id = %s AND m.initiative_id = %s - AND m.status IN ('at_risk', 'active') + 'Plan-Element prüfen' AS recommended_action + FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.tenant_id = %s AND r.initiative_id = %s + AND ri.item_type = 'milestone' + AND ri.status IN ('at_risk', 'active') ORDER BY - CASE m.status WHEN 'at_risk' THEN 0 ELSE 1 END, - m.target_date ASC NULLS LAST, - m.updated_at DESC + CASE ri.status WHEN 'at_risk' THEN 0 ELSE 1 END, + ri.target_date ASC NULLS LAST, + ri.updated_at DESC LIMIT %s """, (ctx.tenant_id, initiative_id, limit), diff --git a/backend/tests/test_ap10_integration.py b/backend/tests/test_ap10_integration.py index d896172..e01aa22 100644 --- a/backend/tests/test_ap10_integration.py +++ b/backend/tests/test_ap10_integration.py @@ -30,17 +30,8 @@ def test_steering_snapshot_graph(client): ) assert snap.status_code == 200 data = snap.json() - assert data["operating_phase"] in ( - "execute", - "triage", - "structure", - "review", - "adapt", - "commit", - "capture", - "verify", - "closure", - ) + assert isinstance(data.get("signals"), list) + assert "operating_phase" not in data linked = next(a for a in data["actions"] if a["id"] == action["id"]) assert len(linked["blockers"]) == 1 assert linked["open_blocker_count"] == 1 diff --git a/backend/tests/test_ap12_signals.py b/backend/tests/test_ap12_signals.py new file mode 100644 index 0000000..f7db3c7 --- /dev/null +++ b/backend/tests/test_ap12_signals.py @@ -0,0 +1,44 @@ +"""AP1.2 — Snapshot signals without operating_phase.""" + +from __future__ import annotations + +from steering.signals.snapshot_signals import derive_initiative_signals + + +def test_derive_signals_backlog_awaiting_commit(): + signals = derive_initiative_signals( + initiative_status="active", + open_actions=0, + blocked_actions=0, + review_required_actions=0, + open_blockers=0, + backlog_new=3, + planned_reviews_due=0, + ) + assert signals == ["backlog_awaiting_commit"] + + +def test_derive_signals_work_in_progress(): + signals = derive_initiative_signals( + initiative_status="active", + open_actions=2, + blocked_actions=0, + review_required_actions=0, + open_blockers=0, + backlog_new=0, + planned_reviews_due=0, + ) + assert signals == ["work_in_progress"] + + +def test_derive_signals_terminal(): + signals = derive_initiative_signals( + initiative_status="completed", + open_actions=0, + blocked_actions=0, + review_required_actions=0, + open_blockers=0, + backlog_new=0, + planned_reviews_due=0, + ) + assert signals == ["initiative_terminal"] diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py new file mode 100644 index 0000000..48cb814 --- /dev/null +++ b/backend/tests/test_ap14_roadmap.py @@ -0,0 +1,122 @@ +"""AP1.4 — RoadmapItem & Quality Gate tests.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import ( + _auth, + _create_initiative, + _login, +) + + +def _create_roadmap_item(client, token, initiative_id, **kwargs): + body = {"title": "Gate A", "item_type": "milestone", **kwargs} + return client.post( + f"/api/initiatives/{initiative_id}/roadmap/items", + json=body, + headers=_auth(token), + ) + + +def test_roadmap_item_crud(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + created = _create_roadmap_item( + client, + token, + initiative_id, + sequencing_mode="parallel", + item_type="maturity_stage", + ) + assert created.status_code == 201 + item = created.json() + assert item["item_type"] == "maturity_stage" + assert item["sequencing_mode"] == "parallel" + + listed = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ) + assert listed.status_code == 200 + assert len(listed.json()) >= 1 + + patched = client.patch( + f"/api/roadmap-items/{item['id']}", + json={"status": "at_risk"}, + headers=_auth(token), + ) + assert patched.status_code == 200 + assert patched.json()["status"] == "at_risk" + + deleted = client.delete(f"/api/roadmap-items/{item['id']}", headers=_auth(token)) + assert deleted.status_code == 204 + + +def test_verify_reached_requires_evidence(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + item = _create_roadmap_item(client, token, initiative_id, status="active").json() + + fail = client.post( + f"/api/roadmap-items/{item['id']}/verify-reached", + headers=_auth(token), + ) + assert fail.status_code == 400 + + evidence = client.post( + f"/api/initiatives/{initiative_id}/evidence", + json={ + "title": "Nachweis", + "milestone_id": item["id"], + "status": "accepted", + }, + headers=_auth(token), + ) + assert evidence.status_code == 201 + + ok = client.post( + f"/api/roadmap-items/{item['id']}/verify-reached", + headers=_auth(token), + ) + assert ok.status_code == 200 + assert ok.json()["status"] == "reached" + assert ok.json()["verify_reason"] == "evidence_accepted" + + +def test_milestone_compat_api_uses_roadmap(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + created = client.post( + f"/api/initiatives/{initiative_id}/milestones", + json={"title": "Legacy MS", "status": "planned"}, + headers=_auth(token), + ) + assert created.status_code == 201 + ms_id = created.json()["id"] + + roadmap = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ).json() + assert any(i["id"] == ms_id and i["item_type"] == "milestone" for i in roadmap) + + +def test_steering_snapshot_reads_roadmap_items(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + _create_roadmap_item(client, token, initiative_id, status="active", target_date="2026-12-31") + + snap = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ).json() + assert snap.get("roadmap_items") + assert snap.get("upcoming_milestones") + assert len(snap["upcoming_roadmap_items"]) >= 1 diff --git a/backend/tests/test_ap1_steering_foundation.py b/backend/tests/test_ap1_steering_foundation.py index 30c54e2..1cbdd5f 100644 --- a/backend/tests/test_ap1_steering_foundation.py +++ b/backend/tests/test_ap1_steering_foundation.py @@ -41,7 +41,8 @@ def test_steering_snapshot_has_lifecycle(client): assert snap.status_code == 200 data = snap.json() assert "lifecycle_state" in data - assert data.get("operating_phase_deprecated") is True + assert "signals" in data + assert "operating_phase" not in data assert data["lifecycle_state"] in ( "intake", "planning", diff --git a/backend/version.py b/backend/version.py index ab1fa76..93e66b0 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.12.0-ap1.2c" -DB_SCHEMA_VERSION = "009" +APP_VERSION = "0.13.0-ap1.4" +DB_SCHEMA_VERSION = "010" APP_NAME = "jinkendo-kairo" diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 4682ae0..42db788 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -50,10 +50,10 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Review | ✓ | | | RecurringElement | ✓ | | | Task (unter Action) | ✗ | | -| Roadmap | ✗ | 📄 Target State | -| RoadmapItem | ✗ | 📄; Milestone soll hierhin | -| RoadmapItem Dependencies | ✗ | | -| Plan-Ist-Verknüpfung | ✗ | Backlog/Action → Gate | +| Roadmap | ◐ | 1 pro Initiative; Migration 010 | +| RoadmapItem | ◐ | milestone/review_gate/maturity_stage; Milestone-API Compat | +| RoadmapItem Dependencies | ◐ | requires/blocks/related | +| Plan-Ist-Verknüpfung | ◐ | backlog/actions.roadmap_item_id; Gate-Verify AP1.4 | --- @@ -70,7 +70,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 | | AttentionItem | ◐ | | | Initiative Steering Snapshot | ✓ | Graph für Actions + linked | -| operating_phase | ◐ | deprecated flag; noch im Snapshot | +| operating_phase | ✗ | entfernt AP1.2; Snapshot nutzt `signals` + Lifecycle | +| signals (Snapshot) | ✓ | `backend/steering/signals/snapshot_signals.py` | | Hook Orchestrator | ✗ | AP1.3 geplant | | Structure Builder | ✗ | 📄 | | DoD Engine | ✗ | 📄 | @@ -133,7 +134,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | AP | Erwartete Truth-Table-Änderung | |----|--------------------------------| | AP1.2c | IA-Skeleton ✓ | -| AP1.4 | RoadmapItem ○→◐, Gate Verify ◐ | +| AP1.2 | operating_phase ✗, signals ✓ | +| AP1.4 | RoadmapItem ◐, Gate Verify ◐ | | AP1.5 | Task ○, Project GUI ◐ | | AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ | diff --git a/frontend/package.json b/frontend/package.json index e7860f3..fb6eaac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.12.0-ap1.2c", + "version": "0.13.0-ap1.4", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js new file mode 100644 index 0000000..3226c5e --- /dev/null +++ b/frontend/src/api/roadmap.js @@ -0,0 +1,40 @@ +import { apiFetch } from './client.js' + +export function listInitiativeRoadmapItems(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`) +} + +export function createInitiativeRoadmapItem(initiativeId, body) { + return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function updateRoadmapItem(itemId, body) { + return apiFetch(`/api/roadmap-items/${itemId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +export function deleteRoadmapItem(itemId) { + return apiFetch(`/api/roadmap-items/${itemId}`, { method: 'DELETE' }) +} + +export function verifyRoadmapItemReached(itemId) { + return apiFetch(`/api/roadmap-items/${itemId}/verify-reached`, { + method: 'POST', + }) +} + +export function listRoadmapItemDependencies(itemId) { + return apiFetch(`/api/roadmap-items/${itemId}/dependencies`) +} + +export function addRoadmapItemDependency(itemId, body) { + return apiFetch(`/api/roadmap-items/${itemId}/dependencies`, { + method: 'POST', + body: JSON.stringify(body), + }) +} diff --git a/frontend/src/components/InitiativeNavCards.jsx b/frontend/src/components/InitiativeNavCards.jsx index 721248c..cdac7da 100644 --- a/frontend/src/components/InitiativeNavCards.jsx +++ b/frontend/src/components/InitiativeNavCards.jsx @@ -4,7 +4,7 @@ const AREAS = [ { to: 'plan', title: 'Plan', - description: 'Meilensteine und Roadmap — überprüfbare Zielpunkte pflegen.', + description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.', }, { to: 'execution', @@ -34,8 +34,8 @@ export function InitiativeNavCards({ initiativeId, counts = {} }) { ? `${counts.backlog} Backlog` : area.to === 'execution' && counts.actionsOpen ? `${counts.actionsOpen} offen` - : area.to === 'plan' && counts.milestones - ? `${counts.milestones} Meilensteine` + : area.to === 'plan' && counts.roadmapItems + ? `${counts.roadmapItems} Plan-Elemente` : null return ( !['reached', 'moved', 'discarded'].includes(s) +) + +function formatDate(value) { + if (!value) return null + try { + return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString( + 'de-DE' + ) + } catch { + return value + } +} + +export function RoadmapPlanSection({ + items, + canManage, + onCreate, + onUpdateStatus, + onUpdateItem, + onVerifyReached, + onDelete, + busy, +}) { + const [title, setTitle] = useState('') + const [goalDescription, setGoalDescription] = useState('') + const [targetDate, setTargetDate] = useState('') + const [itemType, setItemType] = useState('milestone') + const [sequencingMode, setSequencingMode] = useState('sequential') + const [showForm, setShowForm] = useState(false) + + async function handleSubmit(e) { + e.preventDefault() + if (!title.trim()) return + await onCreate({ + title: title.trim(), + goal_description: goalDescription.trim(), + target_date: targetDate || undefined, + item_type: itemType, + sequencing_mode: sequencingMode, + }) + setTitle('') + setGoalDescription('') + setTargetDate('') + setItemType('milestone') + setSequencingMode('sequential') + setShowForm(false) + } + + return ( +
+
+
+

Plan

+

+ Roadmap-Elemente methodenneutral — Gates, Reifegrade oder Review-Punkte. + Erreicht nur über Verify (Evidence, Review oder Decision). +

+
+ {canManage && ( + + )} +
+ + {showForm && canManage && ( +
+ + +