"""Backend-authoritative context for the Personal Space AI entry.""" from __future__ import annotations from typing import Any from sqlalchemy import and_, desc, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import Project, SavedIdea, Task, User, UserProject from ..services.auth import has_any_role from .adaptive_capacity_service import AdaptiveCapacityService DONE_TASK_STATUSES = {"done", "completed", "accepted", "released"} ACTIVE_TASK_STATUSES = {"in_progress", "review", "blocked", "coding", "testing"} class SpaceRuntimeContextBuilder: """Build the customer-safe Personal Space context from backend facts.""" def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) async def build( self, *, current_user: User, surface_context: dict[str, Any] | None = None, current_project_id: int | None = None, project_limit: int = 8, saved_idea_limit: int = 6, ) -> dict[str, Any]: projects = await self._load_visible_projects(current_user=current_user, limit=project_limit) project_ids = [int(project.id) for project in projects] task_counts = await self._load_task_counts(project_ids=project_ids) saved_ideas = await self._load_saved_ideas( user_id=int(current_user.id), limit=saved_idea_limit, ) surface = _normalize_dict(surface_context) workbench = _normalize_dict(surface.get("workbench")) surface_counts = _normalize_dict(workbench.get("counts")) top_next_actions = self._merge_next_actions( projects=projects, task_counts=task_counts, surface_actions=list(workbench.get("top_next_actions") or workbench.get("topNextActions") or []), ) recent_projects = [ self._project_to_context_row( project=project, task_counts=task_counts.get(int(project.id), {}), ) for project in projects ] capacity_packet = await self._load_capacity_packet( current_user=current_user, projects=recent_projects, current_project_id=current_project_id, ) counts = { "projects": len(recent_projects), "savedIdeas": len(saved_ideas), "nextActions": len(top_next_actions), "capacityItems": len(capacity_packet.get("project_capacity") or []), "pendingConfirmations": _safe_int(surface_counts.get("pendingConfirmations")) or 0, "aiProgress": _safe_int(surface_counts.get("aiProgress")) or 0, "drafts": _safe_int(surface_counts.get("drafts")) or 0, "releaseAttention": _safe_int(surface_counts.get("releaseAttention")) or 0, "feedbackAttention": _safe_int(surface_counts.get("feedbackAttention")) or 0, } warnings = self._runtime_warnings( projects=recent_projects, saved_ideas=saved_ideas, surface_warnings=list(workbench.get("warnings") or []), ) operating_packet = self._build_space_operating_packet( current_user=current_user, current_project_id=current_project_id, workbench=workbench, ai_readiness=_normalize_dict(surface.get("ai_readiness")), counts=counts, recent_projects=recent_projects, saved_ideas=saved_ideas, top_next_actions=top_next_actions, capacity_packet=capacity_packet, warnings=warnings, ) packet = { "packet_version": "space_runtime_context.v1", "visibility": "customer", "source": "backend_authoritative", "tenant_id": self.tenant_id, "user_id": int(current_user.id), "current_project_id": _safe_int(current_project_id), "space_operating_packet": operating_packet, "workbench": { "counts": counts, "recent_projects": recent_projects, "projects": recent_projects, "top_next_actions": top_next_actions, "proactive_suggestions": operating_packet.get("proactive_suggestions") or [], "capacity_packet": capacity_packet, "capacity": capacity_packet, "saved_ideas": saved_ideas, "attention_queue": operating_packet.get("attention_queue") or [], "ai_work_items": operating_packet.get("ai_work_items") or [], "pending_confirmations": operating_packet.get("pending_confirmations") or [], "drafts": operating_packet.get("drafts") or [], "warnings": warnings, }, "source_mix": { "projects": "backend", "savedIdeas": "backend", "pendingConfirmations": "surface_hint", "aiProgress": "surface_hint", "drafts": "surface_hint", "capacity": "backend", }, } return packet def merge_surface_context( self, *, surface_context: dict[str, Any] | None, runtime_context: dict[str, Any], ) -> dict[str, Any]: surface = dict(surface_context or {}) runtime_workbench = _normalize_dict(runtime_context.get("workbench")) surface_workbench = _normalize_dict(surface.get("workbench")) merged_workbench = dict(surface_workbench) merged_counts = dict(_normalize_dict(surface_workbench.get("counts"))) merged_counts.update(_normalize_dict(runtime_workbench.get("counts"))) merged_workbench.update( { "counts": merged_counts, "recent_projects": list(runtime_workbench.get("recent_projects") or []), "recentProjects": list(runtime_workbench.get("recent_projects") or []), "projects": list(runtime_workbench.get("projects") or []), "top_next_actions": list(runtime_workbench.get("top_next_actions") or []), "topNextActions": list(runtime_workbench.get("top_next_actions") or []), "proactive_suggestions": list(runtime_workbench.get("proactive_suggestions") or []), "proactiveSuggestions": list(runtime_workbench.get("proactive_suggestions") or []), "capacity_packet": _normalize_dict(runtime_workbench.get("capacity_packet")), "capacityPacket": _normalize_dict(runtime_workbench.get("capacity_packet")), "capacity": _normalize_dict(runtime_workbench.get("capacity_packet")), "saved_ideas": list(runtime_workbench.get("saved_ideas") or []), "warnings": list(runtime_workbench.get("warnings") or surface_workbench.get("warnings") or []), } ) surface["surface"] = _clean_text(surface.get("surface") or "personal_space", limit=80) surface["page"] = _clean_text(surface.get("page") or surface.get("route") or "/space", limit=120) surface["workbench"] = merged_workbench surface["space_runtime_context"] = runtime_context surface["space_operating_packet"] = _normalize_dict(runtime_context.get("space_operating_packet")) return surface async def _load_visible_projects(self, *, current_user: User, limit: int) -> list[Project]: stmt = ( select(Project) .where( and_( Project.tenant_id == self.tenant_id, self._visible_project_filter(current_user=current_user), ) ) .order_by(desc(Project.updated_at), desc(Project.id)) .limit(max(1, min(int(limit), 20))) ) return list((await self.db.execute(stmt)).scalars().all()) async def _load_task_counts(self, *, project_ids: list[int]) -> dict[int, dict[str, int]]: if not project_ids: return {} rows = ( await self.db.execute( select(Task.project_id, Task.status, func.count(Task.id)) .where(and_(Task.tenant_id == self.tenant_id, Task.project_id.in_(project_ids))) .group_by(Task.project_id, Task.status) ) ).all() counts: dict[int, dict[str, int]] = {} for project_id, status, count in rows: project_counts = counts.setdefault( int(project_id), {"total": 0, "completed": 0, "in_progress": 0, "pending": 0, "blocked": 0}, ) value = int(count or 0) project_counts["total"] += value status_value = str(status or "").strip().lower() if status_value in DONE_TASK_STATUSES: project_counts["completed"] += value elif status_value == "blocked": project_counts["blocked"] += value project_counts["in_progress"] += value elif status_value in ACTIVE_TASK_STATUSES: project_counts["in_progress"] += value else: project_counts["pending"] += value return counts async def _load_saved_ideas(self, *, user_id: int, limit: int) -> list[dict[str, Any]]: stmt = ( select(SavedIdea) .where( and_( SavedIdea.tenant_id == self.tenant_id, SavedIdea.user_id == int(user_id), SavedIdea.status != "archived", ) ) .order_by(desc(SavedIdea.updated_at), desc(SavedIdea.id)) .limit(max(1, min(int(limit), 20))) ) rows = list((await self.db.execute(stmt)).scalars().all()) return [ { "id": str(row.idea_id), "object_ref": f"saved_idea:{row.idea_id}", "title": _clean_text(row.title, limit=160), "summary": _clean_text(row.summary or row.ai_summary or row.raw_text, limit=500), "status": _clean_text(row.status, limit=40), "readiness_score": _safe_float(row.readiness_score), "href": "/project-launch?state=human_review" if row.launch_draft_id else "/space", "updated_at": row.updated_at.isoformat() if row.updated_at else None, } for row in rows ] async def _load_capacity_packet( self, *, current_user: User, projects: list[dict[str, Any]], current_project_id: int | None, ) -> dict[str, Any]: target_ids: list[int] = [] if current_project_id is not None: value = _safe_int(current_project_id) if value is not None: target_ids.append(value) for project in projects: value = _safe_int(project.get("id") or project.get("project_id")) if value is not None and value not in target_ids: target_ids.append(value) service = AdaptiveCapacityService(self.db, tenant_id=self.tenant_id) project_title_by_id = { int(project.get("id") or project.get("project_id")): _clean_text(project.get("title"), limit=160) for project in projects if _safe_int(project.get("id") or project.get("project_id")) is not None } rows: list[dict[str, Any]] = [] warnings: list[str] = [] for project_id in target_ids[:4]: try: plan = await service.get_plan(project_id=int(project_id), current_user_id=int(current_user.id)) except Exception: warnings.append(f"capacity_unavailable: 项目 {project_id} 今日容量暂不可用。") continue payload = plan.model_dump(mode="json") if hasattr(plan, "model_dump") else {} project_title = project_title_by_id.get(int(project_id)) or f"项目 {project_id}" member_plans = list(payload.get("memberPlans") or []) current_member = _select_capacity_member(member_plans=member_plans, current_user_id=int(current_user.id)) human_touches = list(payload.get("humanTouches") or []) team_effective = _safe_int(payload.get("teamEffectiveMinutes")) or 0 team_planned = _safe_int(_normalize_dict(payload.get("teamPlannedTouchMinutes")).get("total")) or 0 rows.append( { "project_id": int(project_id), "project_title": project_title, "href": f"/team-layer?projectId={int(project_id)}&view=capacity", "date": _clean_text(payload.get("date"), limit=40), "team": { "effective_minutes": team_effective, "planned_minutes": team_planned, "remaining_minutes": max(0, team_effective - team_planned), "member_count": len(member_plans), "overloaded_count": len( [ item for item in member_plans if str(item.get("capacityStatus") or "") in {"near_full", "overloaded", "blocked"} ] ), }, "personal": _capacity_member_payload(current_member), "human_touches": [_capacity_touch_payload(item) for item in human_touches[:8] if isinstance(item, dict)], "ai_reduction_potential_minutes": sum( _safe_int(item.get("aiReductionPotentialMinutes")) or 0 for item in human_touches if isinstance(item, dict) ), "recommendations": [ _clean_text(item, limit=180) for item in list(payload.get("recommendations") or [])[:5] if _clean_text(item, limit=180) ], "warnings": [ _clean_text(item, limit=180) for item in list(payload.get("warnings") or [])[:6] if _clean_text(item, limit=180) ], } ) return { "packet_version": "space_capacity_packet.v1", "visibility": "customer", "source": "adaptive_human_capacity", "current_user_id": str(int(current_user.id)), "project_capacity": rows, "warnings": warnings[:6], } def _project_to_context_row(self, *, project: Project, task_counts: dict[str, int]) -> dict[str, Any]: total = int(task_counts.get("total") or 0) completed = int(task_counts.get("completed") or 0) blocked = int(task_counts.get("blocked") or 0) pending = int(task_counts.get("pending") or 0) progress = round((completed / total) * 100, 2) if total else 0.0 title = _clean_text(project.title or project.name, limit=160) return { "id": int(project.id), "project_id": int(project.id), "object_ref": f"project:{int(project.id)}", "title": title, "name": title, "description": _clean_text(project.description, limit=500), "status": _clean_text(project.status, limit=60), "stage": self._project_stage(project=project, task_counts=task_counts), "progress": progress, "total_tasks": total, "completed_tasks": completed, "in_progress_tasks": int(task_counts.get("in_progress") or 0), "pending_tasks": pending, "blocked_tasks": blocked, "next_action": _clean_text(getattr(project, "next_action", "") or "", limit=160), "href": f"/pmflow-os?projectId={int(project.id)}", "updated_at": project.updated_at.isoformat() if project.updated_at else None, } def _merge_next_actions( self, *, projects: list[Project], task_counts: dict[int, dict[str, int]], surface_actions: list[Any], ) -> list[dict[str, Any]]: actions: list[dict[str, Any]] = [] for item in surface_actions: if not isinstance(item, dict): continue title = _clean_text(item.get("title"), limit=160) if not title: continue actions.append( { "title": title, "project": _clean_text(item.get("project"), limit=120), "priority": _clean_text(item.get("priority"), limit=40) or "normal", "href": _clean_text(item.get("href"), limit=240), "object_ref": _clean_text(item.get("object_ref") or item.get("objectRef"), limit=120), "object_anchor": _clean_object_anchor(item.get("object_anchor") or item.get("objectAnchor")), } ) deduped: list[dict[str, Any]] = [] seen: set[tuple[str, str]] = set() for action in actions: key = (str(action.get("title") or ""), str(action.get("href") or "")) if key in seen: continue seen.add(key) deduped.append(action) return deduped[:6] def _project_stage(self, *, project: Project, task_counts: dict[str, int]) -> str: if int(task_counts.get("blocked") or 0) > 0: return "blocked" if int(task_counts.get("in_progress") or 0) > 0: return "execution" if int(task_counts.get("pending") or 0) > 0: return "task_planning" return str(project.status or "active") def _runtime_warnings( self, *, projects: list[dict[str, Any]], saved_ideas: list[dict[str, Any]], surface_warnings: list[Any], ) -> list[str]: warnings = [_clean_text(item, limit=160) for item in surface_warnings if _clean_text(item, limit=160)] if not projects: warnings.append("当前账号没有可见项目") if saved_ideas and not projects: warnings.append("已有暂存想法,但还没有转成正式项目") return warnings[:6] def _build_space_operating_packet( self, *, current_user: User, current_project_id: int | None, workbench: dict[str, Any], ai_readiness: dict[str, Any], counts: dict[str, int], recent_projects: list[dict[str, Any]], saved_ideas: list[dict[str, Any]], top_next_actions: list[dict[str, Any]], capacity_packet: dict[str, Any], warnings: list[str], ) -> dict[str, Any]: pending_confirmations = self._surface_items( workbench=workbench, keys=("pending_confirmations", "pendingConfirmations"), kind="pending_confirmation", default_href="/change-review", ) ai_work_items = self._surface_items( workbench=workbench, keys=("ai_work_items", "aiWorkItems", "ai_progress", "aiProgress"), kind="ai_work", default_href="/execution-bridge", ) drafts = self._surface_items( workbench=workbench, keys=("drafts", "draft_items", "draftItems"), kind="draft", default_href="/space/drafts", ) attention_queue = self._build_attention_queue( top_next_actions=top_next_actions, pending_confirmations=pending_confirmations, ai_work_items=ai_work_items, saved_ideas=saved_ideas, drafts=drafts, warnings=warnings, ) proactive_suggestions = self._build_proactive_suggestions(attention_queue=attention_queue) return { "packet_version": "space_operating_packet.v1", "visibility": "customer", "source": "backend_authoritative_with_surface_hint", "tenant_id": self.tenant_id, "user_id": int(current_user.id), "current_project_id": _safe_int(current_project_id), "visible_projects": [ { "id": item.get("id"), "object_ref": item.get("object_ref"), "title": item.get("title"), "status": item.get("status"), "summary": item.get("description"), "next_action": item.get("next_action"), "health": "blocked" if int(item.get("blocked_tasks") or 0) > 0 else "normal", "pending_count": item.get("pending_tasks") or 0, "ai_work_status": "blocked" if int(item.get("blocked_tasks") or 0) > 0 else "idle", "href": item.get("href"), } for item in recent_projects ], "saved_ideas": saved_ideas, "pending_confirmations": pending_confirmations, "ai_work_items": ai_work_items, "drafts": drafts, "capacity_packet": capacity_packet, "attention_queue": attention_queue, "proactive_suggestions": proactive_suggestions, "counts": counts, "readiness": { "status": _clean_text(ai_readiness.get("status"), limit=40) or "unknown", "can_generate_project_draft": bool(ai_readiness.get("can_generate_project_draft")), "can_manage": bool(ai_readiness.get("can_manage")), "blockers": list(ai_readiness.get("blockers") or [])[:6] if isinstance(ai_readiness.get("blockers"), list) else [], }, "warnings": warnings, "automation_policy": { "policy_version": "ai_dual_04_low_risk_proactive.v1", "mode": "suggest_only", "allowed_automation_levels": ["suggest_only", "prepare_only"], "allowed_without_user_confirmation": [ "rank_attention_queue", "prepare_navigation", "summarize_space_context", "prepare_project_launch_draft_preview", ], "requires_user_confirmation_before": [ "project_create", "project_memory_write", "changeset_commit", "execution_handoff", "external_command_execution", "publish_or_deploy", ], }, "surface_hint": { "counts": { "pendingConfirmations": counts.get("pendingConfirmations", 0), "aiProgress": counts.get("aiProgress", 0), "drafts": counts.get("drafts", 0), "releaseAttention": counts.get("releaseAttention", 0), "feedbackAttention": counts.get("feedbackAttention", 0), }, "source": "frontend_surface_hint", }, "allowed_actions": [ "answer_space_status", "rank_attention_queue", "explain_idea_readiness", "route_to_project", "route_to_project_launch", "route_to_change_review", ], "forbidden_actions": [ "project_create_without_confirmation", "project_memory_write", "changeset_commit", "execution_handoff", "external_command_execution", "publish_or_deploy", "provider_model_prompt_diagnosis", ], } def _surface_items( self, *, workbench: dict[str, Any], keys: tuple[str, ...], kind: str, default_href: str, ) -> list[dict[str, Any]]: raw_items: list[Any] = [] for key in keys: value = workbench.get(key) if isinstance(value, list): raw_items = value break rows: list[dict[str, Any]] = [] for index, item in enumerate(raw_items[:8]): if not isinstance(item, dict): continue object_ref = _clean_text( item.get("object_ref") or item.get("objectRef") or item.get("id") or f"{kind}:{index + 1}", limit=120, ) rows.append( { "id": _clean_text(item.get("id") or object_ref, limit=120), "object_ref": object_ref, "kind": kind, "title": _clean_text(item.get("title") or item.get("summary") or kind, limit=160), "summary": _clean_text(item.get("summary") or item.get("description"), limit=500), "status": _clean_text(item.get("status") or item.get("state"), limit=60), "project": _clean_text(item.get("project") or item.get("projectTitle"), limit=160), "next_action": _clean_text(item.get("next_action") or item.get("nextAction"), limit=160), "href": _clean_text(item.get("href") or default_href, limit=240), "object_anchor": _clean_object_anchor(item.get("object_anchor") or item.get("objectAnchor")), } ) return rows def _build_attention_queue( self, *, top_next_actions: list[dict[str, Any]], pending_confirmations: list[dict[str, Any]], ai_work_items: list[dict[str, Any]], saved_ideas: list[dict[str, Any]], drafts: list[dict[str, Any]], warnings: list[str], ) -> list[dict[str, Any]]: queue: list[dict[str, Any]] = [] for item in pending_confirmations[:4]: queue.append(self._attention_row(kind="pending_confirmation", item=item, urgency="high")) for item in ai_work_items[:4]: status = str(item.get("status") or "").lower() queue.append(self._attention_row(kind="ai_work", item=item, urgency="high" if "block" in status else "normal")) for item in top_next_actions[:6]: queue.append( { "kind": "next_action", "object_ref": _clean_text(item.get("object_ref") or item.get("href") or item.get("title"), limit=120), "object_anchor": _clean_object_anchor(item.get("object_anchor")), "title": _clean_text(item.get("title"), limit=160), "reason": _clean_text(item.get("project") or "空间下一步", limit=160), "urgency": _clean_text(item.get("priority"), limit=40) or "normal", "next_action": _clean_text(item.get("title"), limit=160), "href": _clean_text(item.get("href"), limit=240), } ) for item in saved_ideas[:3]: readiness = _safe_float(item.get("readiness_score")) if readiness >= 0.6: queue.append( { "kind": "idea_incubation", "object_ref": item.get("object_ref"), "title": item.get("title"), "reason": "这个想法已经具备继续整理成项目草案的基础", "urgency": "normal", "next_action": "继续补充并生成项目草案", "href": item.get("href") or "/space/ideas", } ) for item in drafts[:3]: queue.append(self._attention_row(kind="draft", item=item, urgency="normal")) for warning in warnings[:3]: queue.append( { "kind": "warning", "object_ref": "space:warning", "title": warning, "reason": warning, "urgency": "normal", "next_action": "查看空间状态", "href": "/space", } ) deduped: list[dict[str, Any]] = [] seen: set[tuple[str, str]] = set() for item in queue: key = (str(item.get("kind") or ""), str(item.get("object_ref") or item.get("href") or item.get("title") or "")) if key in seen: continue seen.add(key) deduped.append(item) return deduped[:10] def _attention_row(self, *, kind: str, item: dict[str, Any], urgency: str) -> dict[str, Any]: return { "kind": kind, "object_ref": item.get("object_ref") or item.get("id"), "object_anchor": _clean_object_anchor(item.get("object_anchor")), "title": item.get("title"), "reason": item.get("summary") or item.get("project") or item.get("status") or item.get("title"), "urgency": urgency, "next_action": item.get("next_action") or "打开处理", "href": item.get("href"), } def _build_proactive_suggestions(self, *, attention_queue: list[dict[str, Any]]) -> list[dict[str, Any]]: suggestions: list[dict[str, Any]] = [] for item in attention_queue[:6]: href = _clean_text(item.get("href"), limit=240) title = _clean_text(item.get("next_action") or item.get("title"), limit=160) if not title or not href: continue kind = _clean_text(item.get("kind"), limit=80) action = "route_to_project" if kind == "pending_confirmation" or href.startswith("/change-review"): action = "route_to_change_review" elif kind == "ai_work" or href.startswith("/execution-bridge"): action = "route_to_execution_bridge" elif kind in {"idea_incubation", "draft"} or href.startswith("/project-launch"): action = "prepare_project_launch_draft_preview" elif href.startswith("/release-growth"): action = "route_to_release_growth" suggestions.append( { "id": _clean_text(item.get("object_ref") or href or title, limit=120), "title": title, "reason": _clean_text(item.get("reason") or item.get("title"), limit=240), "why_now": _clean_text(item.get("reason") or item.get("title"), limit=240), "action": action, "href": href, "object_ref": _clean_text(item.get("object_ref"), limit=120), "object_anchor": _clean_object_anchor(item.get("object_anchor")), "automation_level": "suggest_only", "requires_user_confirm": True, "required_confirmation": "human_confirm_on_target_surface", "risk_level": "high" if _clean_text(item.get("urgency"), limit=40) == "high" else "low", "source_objects": [_clean_text(item.get("object_ref") or href, limit=120)], "audit_event": "space_ai.proactive_suggestion.created", "blocked_automation": [ "project_create_without_confirmation", "project_memory_write", "changeset_commit", "execution_handoff", "external_command_execution", "publish_or_deploy", ], } ) return suggestions[:5] def _visible_project_filter(self, *, current_user: User): if has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]): return True return or_( Project.owner_id == int(current_user.id), Project.id.in_( select(UserProject.project_id).where( and_( UserProject.tenant_id == self.tenant_id, UserProject.user_id == int(current_user.id), ) ) ), ) def _normalize_dict(value: Any) -> dict[str, Any]: return dict(value) if isinstance(value, dict) else {} def _safe_int(value: Any) -> int | None: try: if value is None: return None return int(value) except Exception: return None def _safe_float(value: Any) -> float: try: return float(value or 0.0) except Exception: return 0.0 def _select_capacity_member(*, member_plans: list[Any], current_user_id: int) -> dict[str, Any]: rows = [item for item in member_plans if isinstance(item, dict)] for item in rows: if str(item.get("userId") or "") == str(current_user_id): return item return rows[0] if rows else {} def _capacity_member_payload(member: dict[str, Any]) -> dict[str, Any]: planned = _normalize_dict(member.get("plannedTouchMinutes")) baseline = _normalize_dict(member.get("suggestedBaseline")) current_mix = _normalize_dict(member.get("currentMix")) return { "user_id": _clean_text(member.get("userId"), limit=80), "display_name": _clean_text(member.get("displayName"), limit=120), "role": _clean_text(member.get("role"), limit=80), "status": _clean_text(member.get("capacityStatus"), limit=40), "effective_minutes": _safe_int(member.get("effectiveMinutes")) or 0, "planned_minutes": _safe_int(planned.get("total")) or 0, "remaining_minutes": _safe_int(member.get("remainingMinutes")) or 0, "bottleneck_type": _clean_text(member.get("bottleneckType"), limit=40), "suggested_baseline": {key: _safe_float(baseline.get(key)) for key in ("focus", "review", "acceptance", "coordination")}, "current_mix": {key: _safe_float(current_mix.get(key)) for key in ("focus", "review", "acceptance", "coordination")}, "fatigue_risk": _normalize_dict(member.get("fatigueRisk")), "risk_density": _normalize_dict(member.get("riskDensity")), "can_take_more": [ { "work_type": _clean_text(item.get("workType"), limit=40), "label": _clean_text(item.get("label"), limit=80), "status": _clean_text(item.get("status"), limit=60), "reason": _clean_text(item.get("reason"), limit=180), } for item in list(member.get("canTakeMore") or [])[:6] if isinstance(item, dict) ], "explanations": [ _clean_text(item, limit=180) for item in list(member.get("explanations") or [])[:5] if _clean_text(item, limit=180) ], } def _capacity_touch_payload(touch: dict[str, Any]) -> dict[str, Any]: return { "source_type": _clean_text(touch.get("sourceType"), limit=60), "source_id": _clean_text(touch.get("sourceId"), limit=120), "title": _clean_text(touch.get("title"), limit=160), "summary": _clean_text(touch.get("summary"), limit=240), "work_type": _clean_text(touch.get("workType"), limit=40), "estimated_minutes": _safe_int(touch.get("estimatedMinutes")) or 0, "risk_level": _clean_text(touch.get("riskLevel"), limit=40), "urgency": _clean_text(touch.get("urgency"), limit=40), "binding_type": _clean_text(touch.get("bindingType"), limit=60), "assigned_user_id": _clean_text(touch.get("assignedUserId"), limit=80), "ai_reduction_potential_minutes": _safe_int(touch.get("aiReductionPotentialMinutes")) or 0, "href": _clean_text(touch.get("targetHref"), limit=240), } def _clean_text(value: Any, *, limit: int) -> str: return " ".join(str(value or "").strip().split())[:limit] def _clean_object_anchor(value: Any) -> dict[str, Any] | None: if not isinstance(value, dict): return None object_ref = _clean_text(value.get("object_ref") or value.get("objectRef"), limit=120) object_type = _clean_text(value.get("object_type") or value.get("objectType"), limit=60) object_id = _clean_text(value.get("object_id") or value.get("objectId"), limit=120) if not object_ref and object_type and object_id: object_ref = f"{object_type}:{object_id}" if not object_ref: return None return { "object_ref": object_ref, "object_type": object_type, "object_id": object_id, "surface": _clean_text(value.get("surface"), limit=80), "project_id": _clean_text(value.get("project_id") or value.get("projectId"), limit=80), "project_title": _clean_text(value.get("project_title") or value.get("projectTitle"), limit=160), "href": _clean_text(value.get("href"), limit=240), "parent_object_ref": _clean_text(value.get("parent_object_ref") or value.get("parentObjectRef"), limit=120) or None, } __all__ = ["SpaceRuntimeContextBuilder"]