feat(steering): K-Ext-5 Portfolio-Kernel-Aggregation ueber Initiativen
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 4m14s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 4m14s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
GET /api/workspace/steering; Portfolio-Widget nutzt Kernel-Attention, Vorschlaege und Agent-Slots. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
390161e224
commit
b6434bcc50
|
|
@ -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`)
|
- Config: `operatingContext.agent_slot_config` (`enabled_slots`, `guardrail_pack`, `stale_debt_days`)
|
||||||
- UI: `AgentSlotsPanel` + Snapshot `steering_kernel.agent_slots`
|
- 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
|
## Frontend
|
||||||
|
|
||||||
- `SteeringProposalsPanel` + `PROPOSAL_UI_CONFIG` in `utils/steeringProposals.js`
|
- `SteeringProposalsPanel` + `PROPOSAL_UI_CONFIG` in `utils/steeringProposals.js`
|
||||||
|
|
|
||||||
23
backend/data_layer/portfolio_steering.py
Normal file
23
backend/data_layer/portfolio_steering.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -9,6 +9,7 @@ from data_layer import actions as dl_actions
|
||||||
from data_layer import actors as dl_actors
|
from data_layer import actors as dl_actors
|
||||||
from data_layer import attention as dl_attention
|
from data_layer import attention as dl_attention
|
||||||
from data_layer import initiatives as dl_initiatives
|
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 data_layer import workspace as dl_workspace
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
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)
|
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")
|
@router.post("/portfolio/reorder")
|
||||||
def workspace_portfolio_reorder(
|
def workspace_portfolio_reorder(
|
||||||
body: PortfolioReorderRequest,
|
body: PortfolioReorderRequest,
|
||||||
|
|
|
||||||
172
backend/steering/portfolio/aggregate.py
Normal file
172
backend/steering/portfolio/aggregate.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
24
backend/tests/test_portfolio_steering_api.py
Normal file
24
backend/tests/test_portfolio_steering_api.py
Normal file
|
|
@ -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"]
|
||||||
29
backend/tests/test_portfolio_steering_unit.py
Normal file
29
backend/tests/test_portfolio_steering_unit.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -231,7 +231,7 @@ class AgentSlotProvider:
|
||||||
| **K-Ext-2** | `attention/contributors/registry.py`; inline-Attention refactoren | ✓ AP2.2j |
|
| **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-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-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).
|
**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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.3/4 | ✓ Plugin-Architektur + Element-Registry (2026-07-25) |
|
||||||
| AP2.2a | Starter-Kits ◐→✓ |
|
| 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 ✓ |
|
| ADP Backlog/Epic | P1–P8 ✓ · K-Ext-1/2/3/4/5 ✓ |
|
||||||
| Steering Kernel Proposals | ◐ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.3) |
|
| Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) |
|
||||||
| AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ |
|
| AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ |
|
||||||
| AP2.1 | MVP-Abnahfe ✗→✓ |
|
| AP2.1 | MVP-Abnahfe ✗→✓ |
|
||||||
| AP1.7b | Op-API Parität |
|
| AP1.7b | Op-API Parität |
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,7 @@ export function reorderPortfolio(initiativeIds) {
|
||||||
body: JSON.stringify({ initiative_ids: initiativeIds }),
|
body: JSON.stringify({ initiative_ids: initiativeIds }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPortfolioSteering(limit = 30) {
|
||||||
|
return apiFetch(`/api/workspace/steering?limit=${limit}`)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1308,6 +1308,11 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.initiative-portfolio-card-meta {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
.initiative-journey-lead {
|
.initiative-journey-lead {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { getAttentionItems } from '../api/attention.js'
|
||||||
import { listInitiatives } from '../api/initiatives.js'
|
import { listInitiatives } from '../api/initiatives.js'
|
||||||
import { groupAttentionByInitiative } from '../utils/attentionSignals.js'
|
import { groupAttentionByInitiative } from '../utils/attentionSignals.js'
|
||||||
import { InitiativePortfolioSignalChips } from '../components/InitiativePortfolioSignalChips.jsx'
|
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 { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
|
|
@ -24,6 +24,8 @@ export function InitiativePortfolioWidget() {
|
||||||
const canManage = hasCapability('kairo.initiative.manage')
|
const canManage = hasCapability('kairo.initiative.manage')
|
||||||
const [initiatives, setInitiatives] = useState([])
|
const [initiatives, setInitiatives] = useState([])
|
||||||
const [attentionByInitiative, setAttentionByInitiative] = useState({})
|
const [attentionByInitiative, setAttentionByInitiative] = useState({})
|
||||||
|
const [proposalsByInitiative, setProposalsByInitiative] = useState({})
|
||||||
|
const [agentSlotsByInitiative, setAgentSlotsByInitiative] = useState({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
@ -32,12 +34,25 @@ export function InitiativePortfolioWidget() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [data, attention] = await Promise.all([
|
const [data, steering] = await Promise.all([
|
||||||
listInitiatives(),
|
listInitiatives(),
|
||||||
getAttentionItems().catch(() => []),
|
getPortfolioSteering().catch(() => null),
|
||||||
])
|
])
|
||||||
setInitiatives(Array.isArray(data) ? data : [])
|
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) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -98,6 +113,8 @@ export function InitiativePortfolioWidget() {
|
||||||
<div className="initiative-portfolio-grid">
|
<div className="initiative-portfolio-grid">
|
||||||
{sortedInitiatives.map((item, index) => {
|
{sortedInitiatives.map((item, index) => {
|
||||||
const rankLabel = formatPortfolioRank(item.portfolio_rank ?? index)
|
const rankLabel = formatPortfolioRank(item.portfolio_rank ?? index)
|
||||||
|
const proposalRow = proposalsByInitiative[item.id]
|
||||||
|
const slotCount = (agentSlotsByInitiative[item.id] || []).length
|
||||||
return (
|
return (
|
||||||
<article key={item.id} className="initiative-portfolio-card card">
|
<article key={item.id} className="initiative-portfolio-card card">
|
||||||
{canManage && (
|
{canManage && (
|
||||||
|
|
@ -137,6 +154,17 @@ export function InitiativePortfolioWidget() {
|
||||||
<InitiativePortfolioSignalChips
|
<InitiativePortfolioSignalChips
|
||||||
signals={attentionByInitiative[item.id] || []}
|
signals={attentionByInitiative[item.id] || []}
|
||||||
/>
|
/>
|
||||||
|
{(proposalRow?.total > 0 || slotCount > 0) && (
|
||||||
|
<p className="initiative-portfolio-card-meta muted">
|
||||||
|
{proposalRow?.total > 0 && (
|
||||||
|
<span>{proposalRow.total} Steuerungsvorschlag{proposalRow.total === 1 ? '' : 'e'}</span>
|
||||||
|
)}
|
||||||
|
{proposalRow?.total > 0 && slotCount > 0 && ' · '}
|
||||||
|
{slotCount > 0 && (
|
||||||
|
<span>{slotCount} Agent-Slot{slotCount === 1 ? '' : 's'}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user