from __future__ import annotations import re from uuid import uuid4 from app.copilot.schemas import ( AiGenerateImageAction, AiGenerateImageArguments, AiGenerateVideoAction, AiGenerateVideoArguments, AnalyticsOverviewAction, AnalyticsOverviewArguments, AnalyticsSyncAction, AnalyticsSyncArguments, AssetSelectAction, AssetSelectArguments, ShareProjectAction, ShareProjectArguments, CopilotContext, CopilotPlan, EditorAddClipAction, EditorAddClipArguments, EditorDeleteClipAction, EditorDeleteClipArguments, EditorRenderAction, EditorRenderArguments, EditorSetDurationAction, EditorSetDurationArguments, EditorSplitClipAction, EditorSplitClipArguments, ProjectOpenAction, ProjectOpenArguments, PublishingCancelAction, PublishingCancelArguments, PublishingPublishAction, PublishingPublishArguments, PublishingValidateAction, PublishingValidateArguments, TemplateApplyAction, TemplateApplyArguments, TemplateCreateProjectAction, TemplateCreateProjectArguments, TemplateGetAction, TemplateGetArguments, TemplateSearchAction, TemplateSearchArguments, ) class CopilotPlanner: """Closed deterministic planner used until a text tool-calling model exists.""" def plan(self, request: str, context: CopilotContext) -> CopilotPlan: normalized = " ".join(request.strip().split()) lowered = normalized.casefold() action_id = str(uuid4()) project_id = context.project_id selected_asset = context.selected_asset_ids[0] if context.selected_asset_ids else None selected_clip = context.selected_clip_ids[0] if context.selected_clip_ids else None revision = context.editor_summary.revision if context.editor_summary else None unavailable = ( None if "template" in lowered else self._requested_unavailable_capability(lowered, context) ) if unavailable: return self._blocked( normalized, f"This request requires {unavailable}, but that capability is not available.", unsupported=[unavailable], ) template_id = self._template_id(lowered) publishing_post_id = self._template_id(lowered) if "analytics" in lowered and re.search(r"\b(sync|refresh|update)\b", lowered): action = AnalyticsSyncAction( id=action_id, type="analytics.sync", arguments=AnalyticsSyncArguments(project_id=project_id), reason="Queue a durable synchronization through authorized provider adapters.", requires_confirmation=True, destructive=False, external_side_effect=False, required_permission="analytics:sync", required_capability="analytics.sync", ) return self._plan( "Sync analytics", "Queue an idempotent authoritative analytics synchronization.", [action], ) if re.search(r"\b(analytics|performance|performing|insights)\b", lowered): metric = next( ( item for item in ( "views", "impressions", "likes", "comments", "shares", "engagement_rate", ) if item.replace("_", " ") in lowered ), "views", ) action = AnalyticsOverviewAction( id=action_id, type="analytics.overview", arguments=AnalyticsOverviewArguments(project_id=project_id, metric=metric), reason="Read synchronized provider metrics without inferring unavailable values.", requires_confirmation=False, destructive=False, external_side_effect=False, required_permission="analytics:read", required_capability="analytics.overview", ) return self._plan( "Review analytics", "Read authoritative analytics and report freshness explicitly.", [action], ) if publishing_post_id and re.search(r"\b(validate|check)\b.*\b(publish|post)\b", lowered): action = PublishingValidateAction( id=action_id, type="publishing.validate", arguments=PublishingValidateArguments(post_id=publishing_post_id), reason="Validate the existing canonical post and every selected provider target.", requires_confirmation=False, destructive=False, external_side_effect=False, required_permission="social:posts:write", required_capability="publishing.validate", ) return self._plan( "Validate publishing", "Run authoritative per-target publishing validation.", [action], ) if publishing_post_id and re.search(r"\b(publish|send)\b", lowered): action = PublishingPublishAction( id=action_id, type="publishing.publish", arguments=PublishingPublishArguments(post_id=publishing_post_id), reason="Publish the explicitly identified canonical post to its already selected accounts.", requires_confirmation=True, destructive=False, external_side_effect=True, required_permission="social:posts:publish", required_capability="publishing.publish", ) return self._plan( "Publish social post", "Validate and queue external publishing only after explicit confirmation.", [action], ) if publishing_post_id and re.search(r"\bcancel\b.*\b(publish|post)\b", lowered): action = PublishingCancelAction( id=action_id, type="publishing.cancel", arguments=PublishingCancelArguments(post_id=publishing_post_id), reason="Cancel eligible jobs and request cancellation for in-flight provider work.", requires_confirmation=True, destructive=False, external_side_effect=True, required_permission="social:posts:write", required_capability="publishing.cancel", ) return self._plan( "Cancel publishing", "Apply truthful cancellation semantics after confirmation.", [action], ) if "template" in lowered and re.search(r"\b(find|search|browse)\b", lowered): query = re.sub( r"(?i)\b(find|search|browse|for|me|a|an|template|templates)\b", " ", normalized ) query = " ".join(query.split()) or normalized category = next( ( item for item in ( "business", "marketing", "education", "podcast", "gaming", "news", "social", "youtube", "tiktok", "instagram", "product", "personal", ) if item in lowered ), None, ) action = TemplateSearchAction( id=action_id, type="template.search", arguments=TemplateSearchArguments(query=query, category=category), reason="Search the authoritative visible template catalog.", requires_confirmation=False, destructive=False, external_side_effect=False, required_permission="templates:read", required_capability="template.search", ) return self._plan( "Search templates", "Search the versioned marketplace catalog using the current workspace context.", [action], ) if "share" in lowered and "project" in lowered and re.search(r"\b(with)\b", lowered): # Example parsing for demo purposes project_id = "..." # simplified user_id = "..." # simplified role = "viewer" action = ShareProjectAction( id=action_id, type="project.share", arguments=ShareProjectArguments(project_id=project_id, user_id=user_id, role=role), reason="Share project with user.", requires_confirmation=True, destructive=False, external_side_effect=True, required_permission="projects:share", required_capability="project.share", ) return self._plan("Share project", "Share project with another user.", [action]) if template_id and "template" in lowered and re.search(r"\b(open|show|inspect)\b", lowered): action = TemplateGetAction( id=action_id, type="template.get", arguments=TemplateGetArguments(template_id=template_id), reason="Inspect the selected authoritative template.", requires_confirmation=False, destructive=False, external_side_effect=False, required_permission="templates:read", required_capability="template.get", ) return self._plan("Open template", "Open the selected template.", [action]) if template_id and "template" in lowered and re.search(r"\b(use|apply)\b", lowered): if project_id is None: return self._blocked( normalized, "Applying a template requires a selected project.", missing=["project"], ) action = TemplateApplyAction( id=action_id, type="template.apply", arguments=TemplateApplyArguments( template_id=template_id, project_id=project_id, slot_bindings={}, ), reason="Apply the selected versioned template to the current project.", requires_confirmation=True, destructive=True, external_side_effect=False, required_permission="templates:apply", required_capability="template.apply", ) return self._plan( "Apply template", "Validate requirements and replace the current authoritative editor state after confirmation.", [action], ) if template_id and "template" in lowered and "create project" in lowered: name_match = re.search(r'\bnamed\s+["“]?([^"”]+?)["”]?\s*$', normalized, re.IGNORECASE) if name_match is None: return self._blocked( normalized, "Creating a project from a template requires an explicit project name.", missing=["project name"], ) action = TemplateCreateProjectAction( id=action_id, type="template.create_project", arguments=TemplateCreateProjectArguments( template_id=template_id, project_name=name_match.group(1).strip(), slot_bindings={}, ), reason="Create an editable project from the selected template.", requires_confirmation=True, destructive=False, external_side_effect=False, required_permission="templates:apply", required_capability="template.create_project", ) return self._plan( "Create project from template", "Validate requirements and create a new editable project after confirmation.", [action], ) if re.search(r"\b(open|show)\b.*\bproject\b", lowered): if project_id is None: return self._blocked(normalized, "Select a project first.", missing=["project"]) action = ProjectOpenAction( id=action_id, type="project.open", arguments=ProjectOpenArguments(project_id=project_id), reason="Open the selected project.", requires_confirmation=False, destructive=False, external_side_effect=False, required_permission="projects:read", required_capability="project.open", ) return self._plan("Open project", "Open the selected project.", [action]) if re.search(r"\b(open|select|show)\b.*\basset\b", lowered): if selected_asset is None: return self._blocked(normalized, "Select an asset first.", missing=["asset"]) action = AssetSelectAction( id=action_id, type="asset.select", arguments=AssetSelectArguments(asset_id=selected_asset, project_id=project_id), reason="Open the selected canonical asset.", requires_confirmation=False, destructive=False, external_side_effect=False, required_permission="assets:read", required_capability="asset.select", ) return self._plan("Open asset", "Open the selected asset.", [action]) if "generate" in lowered and ("video" in lowered or "animate" in lowered): if "ai.generate_video" not in context.available_capabilities: return self._blocked( normalized, "Video generation is not currently available.", unsupported=["ai.generate_video"], ) if selected_asset is None: return self._blocked( normalized, "Video generation requires a selected source image.", missing=["source image asset"], ) prompt = self._generation_prompt(normalized, "video") action = AiGenerateVideoAction( id=action_id, type="ai.generate_video", arguments=AiGenerateVideoArguments( prompt=prompt, project_id=project_id, source_asset_id=selected_asset, ), reason="Submit a real image-to-video generation job.", requires_confirmation=True, destructive=False, external_side_effect=False, required_permission="ai:generate", required_capability="ai.generate_video", ) return self._plan( "Generate video", "Generate a video from the selected image using AI Studio.", [action], ) if "generate" in lowered and any( word in lowered for word in ("image", "thumbnail", "artwork") ): if "ai.generate_image" not in context.available_capabilities: return self._blocked( normalized, "Image generation is not currently available.", unsupported=["ai.generate_image"], ) prompt = self._generation_prompt(normalized, "image") action = AiGenerateImageAction( id=action_id, type="ai.generate_image", arguments=AiGenerateImageArguments( prompt=prompt, project_id=project_id, source_asset_id=selected_asset, ), reason="Submit a real image generation job.", requires_confirmation=True, destructive=False, external_side_effect=False, required_permission="ai:generate", required_capability="ai.generate_image", ) return self._plan( "Generate image", "Generate an image using the currently available AI Studio model.", [action], ) if re.search(r"\b(render|export)\b", lowered): if project_id is None or revision is None: return self._blocked( normalized, "Rendering requires a project with saved editor state.", missing=["saved editor state"], ) action = EditorRenderAction( id=action_id, type="editor.render", arguments=EditorRenderArguments(project_id=project_id, expected_revision=revision), reason="Submit the current authoritative editor revision for rendering.", requires_confirmation=True, destructive=False, external_side_effect=False, required_permission="projects:update", required_capability="editor.render", ) return self._plan( "Render project", "Validate and submit the current editor revision to the existing render pipeline.", [action], ) split_match = re.search( r"\bsplit\b.*?\b(?:at\s+)?(\d+(?:\.\d+)?)\s*(seconds?|secs?|s)\b", lowered, ) if split_match: missing = self._editor_missing(project_id, revision, selected_clip) if missing: return self._blocked( normalized, "Select a saved timeline clip first.", missing=missing ) action = EditorSplitClipAction( id=action_id, type="editor.split_clip", arguments=EditorSplitClipArguments( project_id=project_id, clip_id=selected_clip, at_ms=round(float(split_match.group(1)) * 1_000), expected_revision=revision, ), reason="Split the selected clip at the requested timeline time.", requires_confirmation=False, destructive=False, external_side_effect=False, required_permission="projects:update", required_capability="editor.split_clip", ) return self._plan( "Split selected clip", "Save a revision-safe split through the authoritative editor service.", [action], ) if re.search(r"\b(delete|remove)\b.*\b(selected\s+)?clip\b", lowered): missing = self._editor_missing(project_id, revision, selected_clip) if missing: return self._blocked( normalized, "Select a saved timeline clip first.", missing=missing ) action = EditorDeleteClipAction( id=action_id, type="editor.delete_clip", arguments=EditorDeleteClipArguments( project_id=project_id, clip_id=selected_clip, expected_revision=revision, ), reason="Remove the selected clip from the authoritative timeline.", requires_confirmation=True, destructive=True, external_side_effect=False, required_permission="projects:update", required_capability="editor.delete_clip", ) return self._plan( "Delete selected clip", "Delete the selected clip after explicit confirmation.", [action], ) duration_match = re.search( r"\b(?:last|duration|make)\b.*?(\d+(?:\.\d+)?)\s*(seconds?|secs?|s)\b", lowered, ) if duration_match and ("clip" in lowered or "image" in lowered): missing = self._editor_missing(project_id, revision, selected_clip) if missing: return self._blocked( normalized, "Select a saved timeline clip first.", missing=missing ) action = EditorSetDurationAction( id=action_id, type="editor.set_duration", arguments=EditorSetDurationArguments( project_id=project_id, clip_id=selected_clip, duration_ms=round(float(duration_match.group(1)) * 1_000), expected_revision=revision, ), reason="Set the selected clip duration.", requires_confirmation=False, destructive=False, external_side_effect=False, required_permission="projects:update", required_capability="editor.set_duration", ) return self._plan( "Update clip duration", "Save the requested duration through the authoritative editor service.", [action], ) if re.search(r"\badd\b.*\b(asset|image|video|audio)\b.*\b(timeline|editor)\b", lowered): if project_id is None or revision is None or selected_asset is None: return self._blocked( normalized, "Adding media requires a project, saved editor state, and selected asset.", missing=["project", "saved editor state", "asset"], ) action = EditorAddClipAction( id=action_id, type="editor.add_clip", arguments=EditorAddClipArguments( project_id=project_id, asset_id=selected_asset, expected_revision=revision, ), reason="Add the selected canonical asset to the timeline.", requires_confirmation=False, destructive=False, external_side_effect=False, required_permission="projects:update", required_capability="editor.add_clip", ) return self._plan( "Add asset to timeline", "Insert the selected project asset through the authoritative editor service.", [action], ) return self._blocked( normalized, "This request does not map to a currently registered Copilot action.", unsupported=["natural-language intent"], ) @staticmethod def _editor_missing(project_id, revision, selected_clip) -> list[str]: missing = [] if project_id is None: missing.append("project") if revision is None: missing.append("saved editor state") if selected_clip is None: missing.append("selected clip") return missing @staticmethod def _generation_prompt(request: str, media_word: str) -> str: stripped = re.sub( rf"(?i)^\s*(please\s+)?generate\s+(an?\s+)?{media_word}\s*(of|for|with|:)?\s*", "", request, ).strip() return stripped or request @staticmethod def _template_id(request: str) -> str | None: match = re.search( r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b", request, re.IGNORECASE, ) return match.group(0) if match else None @staticmethod def _requested_unavailable_capability(request: str, context: CopilotContext) -> str | None: requested = { "transcrib": "ai.transcribe", "upscal": "ai.upscale", "remove background": "ai.remove_background", "voice": "ai.generate_voice", "music": "ai.generate_music", "caption": "ai.transcribe", "tiktok": "ai.transcribe", "highlight": "ai.analyze", } for fragment, capability in requested.items(): if fragment in request and capability not in context.available_capabilities: return capability return None @staticmethod def _plan(intent: str, explanation: str, actions: list) -> CopilotPlan: return CopilotPlan( intent=intent, explanation=explanation, actions=actions, executable=True, requires_confirmation=any(action.requires_confirmation for action in actions), ) @staticmethod def _blocked( intent: str, explanation: str, *, missing: list[str] | None = None, unsupported: list[str] | None = None, ) -> CopilotPlan: return CopilotPlan( intent=intent[:200], explanation=explanation, actions=[], missing_information=missing or [], unsupported_capabilities=unsupported or [], executable=False, requires_confirmation=False, )