PMFlow/backend/app/services/space_runtime_context_build...

815 lines
36 KiB
Python

"""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"]