815 lines
36 KiB
Python
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"]
|