Kairo-Jinkendo/backend/data_layer/initiative_snapshot.py
Lars a5154cbd3b
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 4m15s
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
feat(steering): Kernel v0.4 Agent-Slots, Tech Debt und Review-Attention
K-Ext-4/P8 deklarative Agent-Slots; P7 tech_debt Read Model und Vokabular; Review-Attention in den Kernel verlagert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:54:48 +02:00

417 lines
15 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 entity_archetypes.registry import archetype_label
from method_profiles.registry import get_method_profile
from steering.context import get_steering_context_dto
from steering.kernel import evaluate_steering
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")
_REASON_LABELS = {
"open_action_priority": "Offenes Arbeitspaket",
"milestone_at_risk_or_active": "Plan-Element aktiv oder gefährdet",
"blocked_action": "Blockiertes Arbeitspaket",
"backlog_triage": "Eingang triagieren",
}
def _steering_guidance(
*,
next_actions: list[dict[str, Any]],
signals: list[str],
method_key: str,
) -> str:
if next_actions:
item = next_actions[0]
title = item.get("title") or "Nächster Schritt"
hint = (
item.get("recommended_action")
or item.get("summary")
or _REASON_LABELS.get(item.get("reason_code", ""), "")
)
if hint:
return f"Als Nächstes: {title}{hint}"
return f"Als Nächstes: {title}"
if signals:
return "Attention: Struktur oder Ist-Arbeit ergänzen, um Next Actions zu erzeugen."
return (
f"Methode {method_key}: Plan oder Eingang befüllen, dann committete Arbeit starten."
)
def _attention_items(signals: list[str]) -> list[dict[str, str]]:
return [{"code": code, "label": code.replace("_", " ")} for code in signals]
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, item_kind,
parent_backlog_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 ri.id, ri.title, ri.status, ri.target_date, ri.item_type,
ri.sequencing_mode, 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.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]
evaluation = evaluate_steering(ctx, initiative_id=initiative_id, next_work_limit=5)
next_actions = evaluation.next_work
kernel_attention = evaluation.attention
epic_rollup = evaluation.read_models.get("epic_rollup") or []
sprint_commit_proposals = evaluation.proposals.get("sprint_commit") or []
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
metadata = steering.get("lifecycle_metadata") or {}
if isinstance(metadata, str):
metadata = {}
method_profile_key = metadata.get("method_profile_key")
method_profile_label = None
if method_profile_key:
profile = get_method_profile(method_profile_key)
if profile:
method_profile_label = profile.get("label")
initiative_archetype = initiative.get("archetype_key") or "initiative.generic"
from services.work_cycle import get_active_work_cycle
active_work_cycle = get_active_work_cycle(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
return {
"initiative_id": initiative_id,
"initiative_title": initiative["title"],
"initiative_status": initiative["status"],
"archetype_key": initiative_archetype,
"archetype_label": archetype_label(
entity_type="initiative", archetype_key=initiative_archetype
),
"method_profile_key": method_profile_key,
"method_profile_label": method_profile_label,
"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"],
"steering_guidance": _steering_guidance(
next_actions=next_actions,
signals=signals,
method_key=steering["method_key"],
),
"attention_items": _attention_items(signals)
+ [
{
"code": item.get("reason_code") or item.get("kind", "attention"),
"label": item.get("summary") or item.get("title") or "Attention",
}
for item in kernel_attention[:5]
],
"signals": signals,
"steering_kernel": {
"horizon": evaluation.horizon.to_dict(),
"lifecycle": evaluation.lifecycle.to_dict(),
"primary_method_key": evaluation.binding.primary_method_key,
"composition_modifier": evaluation.binding.composition_modifier,
"data_source": evaluation.data_source,
"read_models": evaluation.read_models,
"proposals": evaluation.proposals,
"agent_slots": evaluation.agent_slots,
},
"upcoming_milestones": upcoming_milestones,
"upcoming_roadmap_items": upcoming_milestones,
"next_actions": next_actions,
"active_work_cycle": active_work_cycle,
"epic_rollup": epic_rollup,
"sprint_commit_proposals": sprint_commit_proposals,
"gate_next_actions_proposals": evaluation.proposals.get("gate_next_actions") or [],
"intake_triage_proposals": evaluation.proposals.get("intake_triage") or [],
"agent_slots": evaluation.agent_slots,
"tech_debt_summary": evaluation.read_models.get("tech_debt_summary") or {},
"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"],
"item_kind": b.get("item_kind"),
"parent_backlog_id": _sid(b.get("parent_backlog_id")),
"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",
}