Implements the 2026-04-02 transformation spec (Phases 1-8) and fixes all critical bugs found during 5-pipeline E2E testing. Governance & Decision Intelligence: - Pipeline-specific stage order in checkpoint (replaces global STAGES list) - Provider scoring engine (lib/scoring.py) with 7-dimension weighted ranking - Decision log artifact enforced at proposal/idea stage across all 10 pipelines - Delivery promise classifier prevents silent motion-to-still downgrades - Structured shot language in scene_plan schema (camera, lens, lighting, DOF) - Variation checker and slideshow risk scorer block samey output before render - Creative intake, capability extension, and creative-intake meta skills - Final self-review artifact with 5 mandatory checks before presenting output - Source media review contract for user-supplied footage Render & Theme System: - Remotion AnimatedBackground now derives colors from playbook (no more hardcoded dark blue fintech gradient on every video) - video_compose builds custom ThemeConfig from playbook YAML colors/fonts — custom playbooks flow through to Remotion automatically - Explainer component wires theme to all child components (charts, cards, etc.) - resolveAsset() handles absolute paths on Windows/Unix via file:// URIs - RENDERER_FAMILY_MAP synced with actual Remotion compositions Critical Bug Fixes: - Windows npx subprocess: run_command() resolves .cmd wrappers via shutil.which() - Silent renderer downgrade: Remotion failure now returns explicit error with options instead of silently falling back to FFmpeg - .env inline comment parsing strips trailing # comments from API keys - concat_path UnboundLocalError in video_compose finally block - audio_mixer and showcase_card capture=True kwarg bug - Selector estimate_cost() calls fixed (_select_tool -> _select_best_tool) - asset_manifest schema expanded with provider, license, subtype fields - screen-demo subtitle_gen moved from required to optional tools - Duration drift detection in post-render final review (>25% warns)
198 lines
7.9 KiB
Python
198 lines
7.9 KiB
Python
"""Capability-level image selector that routes between generation and stock providers.
|
|
|
|
Provider discovery is automatic — any BaseTool with capability="image_generation"
|
|
is picked up from the registry. Adding a new image provider requires only creating
|
|
the tool file in tools/graphics/; no changes to this selector are needed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from tools.base_tool import BaseTool, ToolResult, ToolRuntime, ToolStability, ToolStatus, ToolTier
|
|
|
|
|
|
class ImageSelector(BaseTool):
|
|
name = "image_selector"
|
|
version = "0.2.0"
|
|
tier = ToolTier.GENERATE
|
|
capability = "image_generation"
|
|
provider = "selector"
|
|
stability = ToolStability.BETA
|
|
runtime = ToolRuntime.HYBRID
|
|
agent_skills = ["flux-best-practices", "bfl-api"]
|
|
|
|
capabilities = [
|
|
"generate_image", "search_image", "download_image",
|
|
"provider_selection", "text_to_image", "stock_image",
|
|
]
|
|
supports = {
|
|
"user_preference_routing": True,
|
|
"offline_fallback": True,
|
|
"stock_fallback": True,
|
|
}
|
|
best_for = [
|
|
"preflight routing — pick the best image provider for the task",
|
|
"switching between generated and stock images",
|
|
"automatic fallback when preferred provider is unavailable",
|
|
]
|
|
|
|
input_schema = {
|
|
"type": "object",
|
|
"required": ["prompt"],
|
|
"properties": {
|
|
"prompt": {
|
|
"type": "string",
|
|
"description": "Image description (used as prompt for generation or query for stock)",
|
|
},
|
|
"negative_prompt": {
|
|
"type": "string",
|
|
"description": "What to avoid in the generated image. Passed to providers that support it.",
|
|
},
|
|
"width": {"type": "integer", "description": "Image width in pixels"},
|
|
"height": {"type": "integer", "description": "Image height in pixels"},
|
|
"seed": {"type": "integer", "description": "Random seed for reproducibility (generation providers only)"},
|
|
"preferred_provider": {
|
|
"type": "string",
|
|
"description": "Provider name or 'auto'. Valid values are discovered at runtime from the registry.",
|
|
"default": "auto",
|
|
},
|
|
"allowed_providers": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
},
|
|
"operation": {
|
|
"type": "string",
|
|
"enum": ["generate", "rank"],
|
|
"default": "generate",
|
|
"description": "Operation mode. 'rank' returns scored provider rankings without generating.",
|
|
},
|
|
"output_path": {"type": "string"},
|
|
},
|
|
}
|
|
|
|
def _providers(self) -> list[BaseTool]:
|
|
"""Auto-discover image generation providers from the registry."""
|
|
from tools.tool_registry import registry
|
|
registry.ensure_discovered()
|
|
return [t for t in registry.get_by_capability("image_generation")
|
|
if t.name != self.name]
|
|
|
|
@property
|
|
def fallback_tools(self) -> list[str]:
|
|
"""Dynamically built from discovered providers."""
|
|
return [t.name for t in self._providers()]
|
|
|
|
@property
|
|
def provider_matrix(self) -> dict[str, dict[str, str]]:
|
|
"""Built at runtime from each provider's best_for field."""
|
|
matrix = {}
|
|
for tool in self._providers():
|
|
strength = ", ".join(tool.best_for) if tool.best_for else tool.name
|
|
matrix[tool.provider] = {"tool": tool.name, "strength": strength}
|
|
return matrix
|
|
|
|
def get_status(self) -> ToolStatus:
|
|
if any(tool.get_status() == ToolStatus.AVAILABLE for tool in self._providers()):
|
|
return ToolStatus.AVAILABLE
|
|
return ToolStatus.UNAVAILABLE
|
|
|
|
def estimate_cost(self, inputs: dict[str, Any]) -> float:
|
|
candidates = self._providers()
|
|
if not candidates:
|
|
return 0.0
|
|
tool, _ = self._select_best_tool(inputs, candidates, inputs.get("task_context", {}))
|
|
return tool.estimate_cost(inputs) if tool else 0.0
|
|
|
|
def execute(self, inputs: dict[str, Any]) -> ToolResult:
|
|
import logging
|
|
from lib.scoring import rank_providers
|
|
|
|
logger = logging.getLogger(__name__)
|
|
task_context = inputs.get("task_context", {})
|
|
candidates = self._providers()
|
|
|
|
# Rank mode — return scored provider rankings without generating
|
|
if inputs.get("operation") == "rank":
|
|
rankings = rank_providers(candidates, task_context)
|
|
return ToolResult(
|
|
success=True,
|
|
data={
|
|
"rankings": [r.to_dict() for r in rankings],
|
|
"explanation": "\n".join(r.explain() for r in rankings[:5]),
|
|
},
|
|
)
|
|
|
|
# Normal generation — use scored selection
|
|
tool, score = self._select_best_tool(inputs, candidates, task_context)
|
|
if tool is None:
|
|
return ToolResult(success=False, error="No image provider available.")
|
|
|
|
# Adapt input keys: stock tools use 'query' while generators use 'prompt'
|
|
adapted = dict(inputs)
|
|
if hasattr(tool, 'input_schema'):
|
|
props = tool.input_schema.get("properties", {})
|
|
if "query" in props and "query" not in adapted:
|
|
adapted["query"] = adapted.get("prompt", "")
|
|
|
|
# Strip selector-only keys that downstream tools don't understand
|
|
adapted.pop("preferred_provider", None)
|
|
adapted.pop("allowed_providers", None)
|
|
|
|
# Pass through generation params only to tools that accept them.
|
|
if hasattr(tool, 'input_schema'):
|
|
props = tool.input_schema.get("properties", {})
|
|
stripped = []
|
|
for passthrough_key in ("negative_prompt", "width", "height", "seed"):
|
|
if passthrough_key in adapted and passthrough_key not in props:
|
|
stripped.append(f"{passthrough_key}={adapted.pop(passthrough_key)}")
|
|
if stripped:
|
|
logger.warning(
|
|
"image_selector: stripped unsupported params for %s: %s",
|
|
tool.name, ", ".join(stripped),
|
|
)
|
|
|
|
result = tool.execute(adapted)
|
|
if result.success:
|
|
result.data.setdefault("selected_tool", tool.name)
|
|
result.data["selection_reason"] = score.explain() if score else f"Selected {tool.provider} ({tool.name})"
|
|
if score:
|
|
result.data["provider_score"] = score.to_dict()
|
|
result.data["alternatives_considered"] = [
|
|
t.name for t in candidates
|
|
if t.name != tool.name and t.get_status().value == "available"
|
|
]
|
|
return result
|
|
|
|
def _select_best_tool(
|
|
self,
|
|
inputs: dict[str, Any],
|
|
candidates: list[BaseTool],
|
|
task_context: dict[str, Any],
|
|
) -> tuple[BaseTool | None, object]:
|
|
"""Select the best provider using scored ranking."""
|
|
from lib.scoring import rank_providers
|
|
|
|
preferred = inputs.get("preferred_provider", "auto")
|
|
allowed = set(inputs.get("allowed_providers") or [])
|
|
if allowed:
|
|
candidates = [tool for tool in candidates if tool.provider in allowed]
|
|
|
|
rankings = rank_providers(candidates, task_context)
|
|
|
|
tool_by_provider: dict[str, BaseTool] = {}
|
|
for tool in candidates:
|
|
if tool.provider not in tool_by_provider and tool.get_status() == ToolStatus.AVAILABLE:
|
|
tool_by_provider[tool.provider] = tool
|
|
|
|
if preferred != "auto":
|
|
for score_item in rankings:
|
|
if score_item.provider == preferred and score_item.provider in tool_by_provider:
|
|
return tool_by_provider[score_item.provider], score_item
|
|
|
|
for score_item in rankings:
|
|
if score_item.provider in tool_by_provider:
|
|
return tool_by_provider[score_item.provider], score_item
|
|
|
|
return None, None
|