"""Attention contributors for reviews, recurring, tech debt.""" from __future__ import annotations from datetime import datetime, timezone from steering.attention.context import AttentionEvalContext from steering.attention.contributors.registry import ( AttentionContributor, register_attention_contributor, ) def _parse_due(value) -> datetime | None: if not value: return None if isinstance(value, datetime): return value if value.tzinfo else value.replace(tzinfo=timezone.utc) try: text = str(value).replace("Z", "+00:00") parsed = datetime.fromisoformat(text) return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) except ValueError: return None def _reviews_due(ctx: AttentionEvalContext) -> list[dict]: if not ctx.eval_ctx or "reviews" not in ctx.eval_ctx.data_slices: return [] now = datetime.now(timezone.utc) items: list[dict] = [] for review in ctx.eval_ctx.reviews: if review.get("status") != "planned": continue due = _parse_due(review.get("due_at")) if due is None or due > now: continue items.append( { "kind": "review_due", "severity": "warning", "title": review.get("title") or "Review", "summary": "Review fällig", "scope_type": "review", "scope_id": str(review["id"]), "initiative_id": ctx.initiative_id, "review_id": str(review["id"]), "reason_code": "review_due", } ) return items def _recurring_due(ctx: AttentionEvalContext) -> list[dict]: if not ctx.eval_ctx or "recurring" not in ctx.eval_ctx.data_slices: return [] now = datetime.now(timezone.utc) items: list[dict] = [] for element in ctx.eval_ctx.recurring_elements: if element.get("status") != "active": continue due = _parse_due(element.get("next_due_at")) if due is None or due > now: continue items.append( { "kind": "recurring_due", "severity": "info", "title": element.get("title") or "Rhythmus", "summary": "Wiederkehrendes Element fällig", "scope_type": "recurring_element", "scope_id": str(element["id"]), "initiative_id": ctx.initiative_id, "recurring_element_id": str(element["id"]), "reason_code": "recurring_due", } ) return items def _action_review_required(ctx: AttentionEvalContext) -> list[dict]: if not ctx.eval_ctx or "reviews" not in ctx.eval_ctx.data_slices: return [] planned_by_action = { str(review["action_id"]) for review in ctx.eval_ctx.reviews if review.get("status") == "planned" and review.get("action_id") } items: list[dict] = [] for action in ctx.actions: if action.get("status") != "review_required": continue action_id = str(action["id"]) if action_id in planned_by_action: continue items.append( { "kind": "action_review_required", "severity": "warning", "title": action.get("title") or "Arbeitspaket", "summary": "Maßnahme wartet auf Review — kein geplantes Review verknüpft", "scope_type": "action", "scope_id": action_id, "initiative_id": ctx.initiative_id, "action_id": action_id, "reason_code": "action_review_required_no_review", } ) return items def _architecture_debt_stale(ctx: AttentionEvalContext) -> list[dict]: summary = ctx.read_models.get("tech_debt_summary") or {} if not summary.get("enabled") or not summary.get("stale_count"): return [] return [ { "kind": "architecture_debt_stale", "severity": "warning", "title": "Technische Schuld", "summary": ( f"{summary['stale_count']} offene Schuld-Einträge älter als " f"{summary.get('stale_days', 30)} Tage ohne Fortschritt" ), "scope_type": "initiative", "scope_id": ctx.initiative_id, "initiative_id": ctx.initiative_id, "reason_code": "architecture_debt_stale", } ] register_attention_contributor( AttentionContributor(key="review_due", contribute=_reviews_due) ) register_attention_contributor( AttentionContributor( key="recurring_due", requires_elements=frozenset({"recurring_rhythm"}), contribute=_recurring_due, ) ) register_attention_contributor( AttentionContributor(key="action_review_required", contribute=_action_review_required) ) register_attention_contributor( AttentionContributor(key="architecture_debt_stale", contribute=_architecture_debt_stale) )