Match the router's expected signature and return type. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
116 lines
3.5 KiB
Python
116 lines
3.5 KiB
Python
"""
|
|
Recipe Service - business logic for recipe management.
|
|
"""
|
|
|
|
from typing import Optional, List, Dict, Any
|
|
import json
|
|
import yaml
|
|
|
|
|
|
class RecipeService:
|
|
"""
|
|
Service for managing recipes.
|
|
|
|
Handles recipe parsing, validation, and DAG building.
|
|
"""
|
|
|
|
def __init__(self, redis, cache):
|
|
self.redis = redis
|
|
self.cache = cache
|
|
self.recipe_prefix = "recipe:"
|
|
|
|
async def get_recipe(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get a recipe by ID (content hash)."""
|
|
# First check Redis
|
|
data = self.redis.get(f"{self.recipe_prefix}{recipe_id}")
|
|
if data:
|
|
return json.loads(data)
|
|
|
|
# Fall back to cache
|
|
path = self.cache.get_by_content_hash(recipe_id)
|
|
if path and path.exists():
|
|
with open(path) as f:
|
|
return yaml.safe_load(f)
|
|
|
|
return None
|
|
|
|
async def list_recipes(self, actor_id: str = None, offset: int = 0, limit: int = 20) -> list:
|
|
"""List available recipes with pagination."""
|
|
recipes = []
|
|
cursor = 0
|
|
|
|
while True:
|
|
cursor, keys = self.redis.scan(
|
|
cursor=cursor,
|
|
match=f"{self.recipe_prefix}*",
|
|
count=100
|
|
)
|
|
for key in keys:
|
|
data = self.redis.get(key)
|
|
if data:
|
|
recipe = json.loads(data)
|
|
# Filter by actor if specified
|
|
if actor_id is None or recipe.get("actor_id") == actor_id:
|
|
recipes.append(recipe)
|
|
if cursor == 0:
|
|
break
|
|
|
|
# Sort by name
|
|
recipes.sort(key=lambda r: r.get("name", ""))
|
|
|
|
# Paginate
|
|
return recipes[offset:offset + limit]
|
|
|
|
async def save_recipe(self, recipe_id: str, recipe_data: Dict[str, Any]) -> None:
|
|
"""Save a recipe to Redis."""
|
|
self.redis.set(f"{self.recipe_prefix}{recipe_id}", json.dumps(recipe_data))
|
|
|
|
async def delete_recipe(self, recipe_id: str) -> bool:
|
|
"""Delete a recipe."""
|
|
return self.redis.delete(f"{self.recipe_prefix}{recipe_id}") > 0
|
|
|
|
def parse_yaml(self, yaml_content: str) -> Dict[str, Any]:
|
|
"""Parse recipe YAML content."""
|
|
return yaml.safe_load(yaml_content)
|
|
|
|
def build_dag(self, recipe: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Build DAG visualization data from recipe.
|
|
|
|
Returns nodes and edges for Cytoscape.js.
|
|
"""
|
|
nodes = []
|
|
edges = []
|
|
|
|
dag = recipe.get("dag", {})
|
|
dag_nodes = dag.get("nodes", {})
|
|
output_node = dag.get("output")
|
|
|
|
for node_id, node_def in dag_nodes.items():
|
|
node_type = node_def.get("type", "EFFECT")
|
|
nodes.append({
|
|
"data": {
|
|
"id": node_id,
|
|
"label": node_id,
|
|
"nodeType": node_type,
|
|
"isOutput": node_id == output_node,
|
|
}
|
|
})
|
|
|
|
# Build edges from inputs
|
|
for input_ref in node_def.get("inputs", []):
|
|
if isinstance(input_ref, dict):
|
|
source = input_ref.get("node") or input_ref.get("input")
|
|
else:
|
|
source = input_ref
|
|
|
|
if source:
|
|
edges.append({
|
|
"data": {
|
|
"source": source,
|
|
"target": node_id,
|
|
}
|
|
})
|
|
|
|
return {"nodes": nodes, "edges": edges}
|