Kairo-Jinkendo/backend/data_layer/initiative_snapshot.py
Lars 09433b5d28
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m30s
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
AP1.2 + AP1.4: Signals konsolidieren, RoadmapItem mit Gate-Verify
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 <cursoragent@cursor.com>
2026-07-05 18:45:36 +02:00

323 lines
11 KiB
Python

"""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, 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
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 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, 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, 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),
)
roadmap_items_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")),
}
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,
}
)
roadmap_items: list[dict[str, Any]] = []
for m in roadmap_items_raw:
mid = _sid(m["id"])
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, []),
}
)
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"))
signals = derive_initiative_signals(
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,
)
upcoming_milestones = sorted(
[
m
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 ""),
)[:5]
next_actions = get_next_action_candidates_for_initiative(
ctx, initiative_id=initiative_id, limit=5
)
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
return {
"initiative_id": initiative_id,
"initiative_title": initiative["title"],
"initiative_status": initiative["status"],
"lifecycle_state": steering["lifecycle_state"],
"lifecycle_label": steering["lifecycle_label"],
"method_key": steering["method_key"],
"method_label": steering.get("method_label"),
"method_version": steering["method_version"],
"signals": signals,
"upcoming_milestones": upcoming_milestones,
"upcoming_roadmap_items": upcoming_milestones,
"next_actions": next_actions,
"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,
"roadmap_items": roadmap_items,
"milestones": roadmap_items,
"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",
}