Spaces:
Running
Running
| 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"], | |
| ) | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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), | |
| ) | |
| 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, | |
| ) | |