MediaRouter / app /copilot /planner.py
basyx's picture
Upload 340 files
3493993 verified
Raw
History Blame Contribute Delete
25.9 kB
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,
)