From b6434bcc507847dfeeeb3cb797a26fd14156ba4d Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 27 Jul 2026 13:13:36 +0200 Subject: [PATCH] feat(steering): K-Ext-5 Portfolio-Kernel-Aggregation ueber Initiativen GET /api/workspace/steering; Portfolio-Widget nutzt Kernel-Attention, Vorschlaege und Agent-Slots. Co-authored-by: Cursor --- .cursor/rules/kairo-steering-kernel.mdc | 6 + backend/data_layer/portfolio_steering.py | 23 +++ backend/routers/workspace.py | 14 ++ backend/steering/portfolio/aggregate.py | 172 ++++++++++++++++++ backend/tests/test_portfolio_steering_api.py | 24 +++ backend/tests/test_portfolio_steering_unit.py | 29 +++ ...DP_Steering_Kernel_Extension_Model_v0.1.md | 2 +- .../Kairo_Implementation_Truth_Table_v0.1.md | 4 +- frontend/src/api/workspace.js | 4 + frontend/src/styles/components.css | 5 + .../src/widgets/InitiativePortfolioWidget.jsx | 36 +++- 11 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 backend/data_layer/portfolio_steering.py create mode 100644 backend/steering/portfolio/aggregate.py create mode 100644 backend/tests/test_portfolio_steering_api.py create mode 100644 backend/tests/test_portfolio_steering_unit.py diff --git a/.cursor/rules/kairo-steering-kernel.mdc b/.cursor/rules/kairo-steering-kernel.mdc index 7cc3dff..db3d756 100644 --- a/.cursor/rules/kairo-steering-kernel.mdc +++ b/.cursor/rules/kairo-steering-kernel.mdc @@ -39,6 +39,12 @@ Aktivierung über `steering_elements` / `data_slices` im Methoden-Vertrag — ** - Config: `operatingContext.agent_slot_config` (`enabled_slots`, `guardrail_pack`, `stale_debt_days`) - UI: `AgentSlotsPanel` + Snapshot `steering_kernel.agent_slots` +## Portfolio (K-Ext-5) + +- Aggregiert Kernel-Output über Initiativen: `GET /api/workspace/steering` +- Modul: `steering/portfolio/aggregate.py` — **kein** Duplikat-Rechnen außerhalb Kernel +- Legacy `/api/workspace/attention` bleibt (SQL-Regeln) bis vollständige Migration + ## Frontend - `SteeringProposalsPanel` + `PROPOSAL_UI_CONFIG` in `utils/steeringProposals.js` diff --git a/backend/data_layer/portfolio_steering.py b/backend/data_layer/portfolio_steering.py new file mode 100644 index 0000000..dda990e --- /dev/null +++ b/backend/data_layer/portfolio_steering.py @@ -0,0 +1,23 @@ +"""Portfolio steering read model — K-Ext-5.""" + +from __future__ import annotations + +from typing import Any + +from steering.portfolio.aggregate import aggregate_portfolio_steering +from tenant_context import TenantContext + + +def get_portfolio_steering( + ctx: TenantContext, + *, + max_initiatives: int = 50, + total_attention: int = 30, + total_next_work: int = 15, +) -> dict[str, Any]: + return aggregate_portfolio_steering( + ctx, + max_initiatives=max_initiatives, + total_attention=total_attention, + total_next_work=total_next_work, + ) diff --git a/backend/routers/workspace.py b/backend/routers/workspace.py index 7f5fe25..e781426 100644 --- a/backend/routers/workspace.py +++ b/backend/routers/workspace.py @@ -9,6 +9,7 @@ from data_layer import actions as dl_actions from data_layer import actors as dl_actors from data_layer import attention as dl_attention from data_layer import initiatives as dl_initiatives +from data_layer import portfolio_steering as dl_portfolio_steering from data_layer import workspace as dl_workspace from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field @@ -96,6 +97,19 @@ def workspace_next_actions( return dl_attention.get_next_action_candidates(ctx, limit=limit or 10) +@router.get("/steering") +def workspace_steering( + limit: Optional[int] = Query(default=30, ge=1, le=100), + ctx: TenantContext = Depends(require_capability("kairo.attention.read")), +): + """Portfolio-Steuerung — Kernel-Aggregation über alle aktiven Initiativen (K-Ext-5).""" + return dl_portfolio_steering.get_portfolio_steering( + ctx, + total_attention=limit or 30, + total_next_work=min(limit or 30, 15), + ) + + @router.post("/portfolio/reorder") def workspace_portfolio_reorder( body: PortfolioReorderRequest, diff --git a/backend/steering/portfolio/aggregate.py b/backend/steering/portfolio/aggregate.py new file mode 100644 index 0000000..7f1b1da --- /dev/null +++ b/backend/steering/portfolio/aggregate.py @@ -0,0 +1,172 @@ +"""Portfolio-level steering aggregation — K-Ext-5.""" + +from __future__ import annotations + +from typing import Any + +from steering.kernel import evaluate_steering +from tenant_context import TenantContext + +_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2} + + +def _portfolio_rank_key(rank: int | None) -> int: + return rank if rank is not None else 999_999 + + +def _load_active_initiatives(ctx: TenantContext, *, limit: int) -> list[dict[str, Any]]: + from data_layer.initiatives import get_active_initiatives + + return get_active_initiatives(ctx, limit=limit) + + +def _enrich_item( + item: dict[str, Any], + *, + initiative_id: str, + initiative_title: str, + portfolio_rank: int | None, +) -> dict[str, Any]: + enriched = dict(item) + enriched.setdefault("initiative_id", initiative_id) + enriched["initiative_title"] = initiative_title + if portfolio_rank is not None: + enriched["portfolio_rank"] = portfolio_rank + enriched.setdefault("data_source", "steering_kernel") + return enriched + + +def _sort_attention(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted( + items, + key=lambda item: ( + _SEVERITY_ORDER.get(item.get("severity", "info"), 99), + _portfolio_rank_key(item.get("portfolio_rank")), + item.get("initiative_title") or "", + item.get("title") or "", + ), + ) + + +def _sort_next_work(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted( + items, + key=lambda item: ( + _portfolio_rank_key(item.get("portfolio_rank")), + item.get("rank") if item.get("rank") is not None else 999, + item.get("title") or "", + ), + ) + + +def aggregate_portfolio_steering( + ctx: TenantContext, + *, + max_initiatives: int = 50, + attention_per_initiative: int = 5, + next_work_per_initiative: int = 3, + total_attention: int = 30, + total_next_work: int = 15, + total_agent_slots: int = 20, +) -> dict[str, Any]: + """ + Aggregiert Kernel-Steuerung über aktive Initiativen (Portfolio-Rang). + """ + initiatives = _load_active_initiatives(ctx, limit=max_initiatives) + + attention: list[dict[str, Any]] = [] + next_work: list[dict[str, Any]] = [] + agent_slots: list[dict[str, Any]] = [] + proposals_summary: list[dict[str, Any]] = [] + attention_by_initiative: dict[str, list[dict[str, Any]]] = {} + agent_slots_by_initiative: dict[str, list[dict[str, Any]]] = {} + + for initiative in initiatives: + initiative_id = str(initiative["id"]) + initiative_title = initiative.get("title") or "Vorhaben" + portfolio_rank = initiative.get("portfolio_rank") + + evaluation = evaluate_steering( + ctx, + initiative_id=initiative_id, + next_work_limit=next_work_per_initiative, + attention_limit=attention_per_initiative, + ) + + initiative_attention: list[dict[str, Any]] = [] + for item in evaluation.attention: + enriched = _enrich_item( + item, + initiative_id=initiative_id, + initiative_title=initiative_title, + portfolio_rank=portfolio_rank, + ) + initiative_attention.append(enriched) + attention.append(enriched) + + attention_by_initiative[initiative_id] = initiative_attention + + for item in evaluation.next_work: + next_work.append( + _enrich_item( + item, + initiative_id=initiative_id, + initiative_title=initiative_title, + portfolio_rank=portfolio_rank, + ) + ) + + initiative_slots: list[dict[str, Any]] = [] + for slot in evaluation.agent_slots: + enriched = _enrich_item( + slot, + initiative_id=initiative_id, + initiative_title=initiative_title, + portfolio_rank=portfolio_rank, + ) + initiative_slots.append(enriched) + agent_slots.append(enriched) + agent_slots_by_initiative[initiative_id] = initiative_slots + + proposal_counts = { + key: len(value) if isinstance(value, list) else 0 + for key, value in (evaluation.proposals or {}).items() + } + if any(proposal_counts.values()): + proposals_summary.append( + { + "initiative_id": initiative_id, + "initiative_title": initiative_title, + "portfolio_rank": portfolio_rank, + "counts": proposal_counts, + "total": sum(proposal_counts.values()), + } + ) + + attention = _sort_attention(attention)[:total_attention] + next_work = _sort_next_work(next_work)[:total_next_work] + agent_slots = sorted( + agent_slots, + key=lambda slot: ( + {"high": 0, "normal": 1, "low": 2}.get(slot.get("priority", "normal"), 9), + _portfolio_rank_key(slot.get("portfolio_rank")), + ), + )[:total_agent_slots] + + proposals_summary.sort( + key=lambda row: ( + -row.get("total", 0), + _portfolio_rank_key(row.get("portfolio_rank")), + ) + ) + + return { + "data_source": "steering_kernel_portfolio_v0.1", + "initiative_count": len(initiatives), + "attention": attention, + "attention_by_initiative": attention_by_initiative, + "next_work": next_work, + "proposals_summary": proposals_summary, + "agent_slots": agent_slots, + "agent_slots_by_initiative": agent_slots_by_initiative, + } diff --git a/backend/tests/test_portfolio_steering_api.py b/backend/tests/test_portfolio_steering_api.py new file mode 100644 index 0000000..04c027c --- /dev/null +++ b/backend/tests/test_portfolio_steering_api.py @@ -0,0 +1,24 @@ +"""Integration tests for portfolio steering API — K-Ext-5.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_workspace_steering_aggregates_kernel(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + created = _create_initiative(client, token, title="Portfolio Kernel") + initiative_id = created.json()["id"] + + res = client.get("/api/workspace/steering", headers=_auth(token)) + assert res.status_code == 200 + body = res.json() + assert body["data_source"] == "steering_kernel_portfolio_v0.1" + assert body["initiative_count"] >= 1 + assert isinstance(body["attention"], list) + assert isinstance(body["next_work"], list) + assert isinstance(body["proposals_summary"], list) + assert isinstance(body["agent_slots"], list) + assert initiative_id in body["attention_by_initiative"] diff --git a/backend/tests/test_portfolio_steering_unit.py b/backend/tests/test_portfolio_steering_unit.py new file mode 100644 index 0000000..cf5cc92 --- /dev/null +++ b/backend/tests/test_portfolio_steering_unit.py @@ -0,0 +1,29 @@ +"""Unit tests for portfolio steering aggregation.""" + +from __future__ import annotations + +from steering.portfolio.aggregate import _sort_attention, _sort_next_work + + +def test_sort_attention_by_severity_then_portfolio_rank(): + items = [ + {"severity": "info", "portfolio_rank": 0, "title": "B"}, + {"severity": "critical", "portfolio_rank": 2, "title": "A"}, + {"severity": "warning", "portfolio_rank": 1, "title": "C"}, + ] + sorted_items = _sort_attention(items) + assert sorted_items[0]["severity"] == "critical" + assert sorted_items[1]["severity"] == "warning" + assert sorted_items[2]["severity"] == "info" + + +def test_sort_next_work_by_portfolio_rank(): + items = [ + {"portfolio_rank": 2, "title": "Late"}, + {"portfolio_rank": 0, "title": "First"}, + {"portfolio_rank": None, "title": "Unranked"}, + ] + sorted_items = _sort_next_work(items) + assert sorted_items[0]["title"] == "First" + assert sorted_items[1]["title"] == "Late" + assert sorted_items[2]["title"] == "Unranked" diff --git a/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md b/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md index 026e4eb..051589b 100644 --- a/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md +++ b/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md @@ -231,7 +231,7 @@ class AgentSlotProvider: | **K-Ext-2** | `attention/contributors/registry.py`; inline-Attention refactoren | ✓ AP2.2j | | **K-Ext-3** | `proposals/registry.py` + `SteeringEvaluation.proposals` | ✓ AP2.2i (P6) | | **K-Ext-4** | `agent_slots/registry.py` + Operating Context Feld `agent_slots` | P8 Reviews; Principle Gate für KI | ✓ | -| **K-Ext-5** | Portfolio-Ebene: Workspace aggregiert Attention/Proposals über Initiativen | Program Director multi-initiative | +| **K-Ext-5** | Portfolio-Ebene: Workspace aggregiert Attention/Proposals über Initiativen | Program Director multi-initiative | ✓ | **Reihenfolge-Empfehlung:** K-Ext-1 → K-Ext-2 (technische Schuld aus P4 abbauen) → K-Ext-3 (P6) → P5 Tasks parallel möglich → K-Ext-4 (P8). diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 9009a23..40de883 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -228,8 +228,8 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1 |----|-----------| | AP2.3/4 | ✓ Plugin-Architektur + Element-Registry (2026-07-25) | | AP2.2a | Starter-Kits ◐→✓ | -| ADP Backlog/Epic | P1–P4 ✓ · P5 Tasks ✓ · P6 Sprint-Vorschlag ✓ · P7 Tech Debt ✓ · P8 Agent-Slots ✓ · K-Ext-1/2/3/4 ✓ | -| Steering Kernel Proposals | ◐ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.3) | +| ADP Backlog/Epic | P1–P8 ✓ · K-Ext-1/2/3/4/5 ✓ | +| Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) | | AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ | | AP1.7b | Op-API Parität | diff --git a/frontend/src/api/workspace.js b/frontend/src/api/workspace.js index ad3355f..a0933b7 100644 --- a/frontend/src/api/workspace.js +++ b/frontend/src/api/workspace.js @@ -30,3 +30,7 @@ export function reorderPortfolio(initiativeIds) { body: JSON.stringify({ initiative_ids: initiativeIds }), }) } + +export function getPortfolioSteering(limit = 30) { + return apiFetch(`/api/workspace/steering?limit=${limit}`) +} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index b3526b9..52e032b 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1308,6 +1308,11 @@ font-weight: 500; } +.initiative-portfolio-card-meta { + margin: 0.5rem 0 0; + font-size: 0.8125rem; +} + .initiative-journey-lead { margin-bottom: 1rem; } diff --git a/frontend/src/widgets/InitiativePortfolioWidget.jsx b/frontend/src/widgets/InitiativePortfolioWidget.jsx index 5d9c2b9..dd4ce99 100644 --- a/frontend/src/widgets/InitiativePortfolioWidget.jsx +++ b/frontend/src/widgets/InitiativePortfolioWidget.jsx @@ -4,7 +4,7 @@ import { getAttentionItems } from '../api/attention.js' import { listInitiatives } from '../api/initiatives.js' import { groupAttentionByInitiative } from '../utils/attentionSignals.js' import { InitiativePortfolioSignalChips } from '../components/InitiativePortfolioSignalChips.jsx' -import { reorderPortfolio } from '../api/workspace.js' +import { getPortfolioSteering, reorderPortfolio } from '../api/workspace.js' import { StatusBadge } from '../components/StatusBadge.jsx' import { PriorityBadge } from '../components/PriorityBadge.jsx' import { EmptyState } from '../components/EmptyState.jsx' @@ -24,6 +24,8 @@ export function InitiativePortfolioWidget() { const canManage = hasCapability('kairo.initiative.manage') const [initiatives, setInitiatives] = useState([]) const [attentionByInitiative, setAttentionByInitiative] = useState({}) + const [proposalsByInitiative, setProposalsByInitiative] = useState({}) + const [agentSlotsByInitiative, setAgentSlotsByInitiative] = useState({}) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -32,12 +34,25 @@ export function InitiativePortfolioWidget() { setLoading(true) setError(null) try { - const [data, attention] = await Promise.all([ + const [data, steering] = await Promise.all([ listInitiatives(), - getAttentionItems().catch(() => []), + getPortfolioSteering().catch(() => null), ]) setInitiatives(Array.isArray(data) ? data : []) - setAttentionByInitiative(groupAttentionByInitiative(attention)) + if (steering?.attention_by_initiative) { + setAttentionByInitiative(steering.attention_by_initiative) + setProposalsByInitiative( + Object.fromEntries( + (steering.proposals_summary || []).map((row) => [row.initiative_id, row]), + ), + ) + setAgentSlotsByInitiative(steering.agent_slots_by_initiative || {}) + } else { + const attention = await getAttentionItems().catch(() => []) + setAttentionByInitiative(groupAttentionByInitiative(attention)) + setProposalsByInitiative({}) + setAgentSlotsByInitiative({}) + } } catch (err) { setError(err.message) } finally { @@ -98,6 +113,8 @@ export function InitiativePortfolioWidget() {
{sortedInitiatives.map((item, index) => { const rankLabel = formatPortfolioRank(item.portfolio_rank ?? index) + const proposalRow = proposalsByInitiative[item.id] + const slotCount = (agentSlotsByInitiative[item.id] || []).length return (
{canManage && ( @@ -137,6 +154,17 @@ export function InitiativePortfolioWidget() { + {(proposalRow?.total > 0 || slotCount > 0) && ( +

+ {proposalRow?.total > 0 && ( + {proposalRow.total} Steuerungsvorschlag{proposalRow.total === 1 ? '' : 'e'} + )} + {proposalRow?.total > 0 && slotCount > 0 && ' · '} + {slotCount > 0 && ( + {slotCount} Agent-Slot{slotCount === 1 ? '' : 's'} + )} +

+ )}
)