Import L1 (celery) as l1/
This commit is contained in:
337
l1/app/services/recipe_service.py
Normal file
337
l1/app/services/recipe_service.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Recipe Service - business logic for recipe management.
|
||||
|
||||
Recipes are S-expressions stored in the content-addressed cache (and IPFS).
|
||||
The recipe ID is the content hash of the file.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple, TYPE_CHECKING
|
||||
|
||||
from artdag.sexp import compile_string, parse, serialize, CompileError, ParseError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import redis
|
||||
from cache_manager import L1CacheManager
|
||||
|
||||
from ..types import Recipe, CompiledDAG, VisualizationDAG, VisNode, VisEdge
|
||||
|
||||
|
||||
class RecipeService:
|
||||
"""
|
||||
Service for managing recipes.
|
||||
|
||||
Recipes are S-expressions stored in the content-addressed cache.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: "redis.Redis", cache: "L1CacheManager") -> None:
|
||||
# Redis kept for compatibility but not used for recipe storage
|
||||
self.redis = redis
|
||||
self.cache = cache
|
||||
|
||||
async def get_recipe(self, recipe_id: str) -> Optional[Recipe]:
|
||||
"""Get a recipe by ID (content hash)."""
|
||||
import yaml
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get from cache (content-addressed storage)
|
||||
logger.info(f"get_recipe: Looking up recipe_id={recipe_id[:16]}...")
|
||||
path = self.cache.get_by_cid(recipe_id)
|
||||
logger.info(f"get_recipe: cache.get_by_cid returned path={path}")
|
||||
if not path or not path.exists():
|
||||
logger.warning(f"get_recipe: Recipe {recipe_id[:16]}... not found in cache")
|
||||
return None
|
||||
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Detect format - check if it starts with ( after skipping comments
|
||||
def is_sexp_format(text):
|
||||
for line in text.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(';'):
|
||||
continue
|
||||
return stripped.startswith('(')
|
||||
return False
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if is_sexp_format(content):
|
||||
# Detect if this is a streaming recipe (starts with (stream ...))
|
||||
def is_streaming_recipe(text):
|
||||
for line in text.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(';'):
|
||||
continue
|
||||
return stripped.startswith('(stream')
|
||||
return False
|
||||
|
||||
if is_streaming_recipe(content):
|
||||
# Streaming recipes have different format - parse manually
|
||||
import re
|
||||
name_match = re.search(r'\(stream\s+"([^"]+)"', content)
|
||||
recipe_name = name_match.group(1) if name_match else "streaming"
|
||||
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"sexp": content,
|
||||
"format": "sexp",
|
||||
"type": "streaming",
|
||||
"dag": {"nodes": []}, # Streaming recipes don't have traditional DAG
|
||||
}
|
||||
logger.info(f"Parsed streaming recipe {recipe_id[:16]}..., name: {recipe_name}")
|
||||
else:
|
||||
# Parse traditional (recipe ...) S-expression
|
||||
try:
|
||||
compiled = compile_string(content)
|
||||
recipe_data = compiled.to_dict()
|
||||
recipe_data["sexp"] = content
|
||||
recipe_data["format"] = "sexp"
|
||||
logger.info(f"Parsed sexp recipe {recipe_id[:16]}..., keys: {list(recipe_data.keys())}")
|
||||
except (ParseError, CompileError) as e:
|
||||
logger.warning(f"Failed to parse sexp recipe {recipe_id[:16]}...: {e}")
|
||||
return {"error": str(e), "recipe_id": recipe_id}
|
||||
else:
|
||||
# Parse YAML
|
||||
try:
|
||||
recipe_data = yaml.safe_load(content)
|
||||
if not isinstance(recipe_data, dict):
|
||||
return {"error": "Invalid YAML: expected dictionary", "recipe_id": recipe_id}
|
||||
recipe_data["yaml"] = content
|
||||
recipe_data["format"] = "yaml"
|
||||
except yaml.YAMLError as e:
|
||||
return {"error": f"YAML parse error: {e}", "recipe_id": recipe_id}
|
||||
|
||||
# Add the recipe_id to the data for convenience
|
||||
recipe_data["recipe_id"] = recipe_id
|
||||
|
||||
# Get IPFS CID if available
|
||||
ipfs_cid = self.cache.get_ipfs_cid(recipe_id)
|
||||
if ipfs_cid:
|
||||
recipe_data["ipfs_cid"] = ipfs_cid
|
||||
|
||||
# Compute step_count from nodes (handle both formats)
|
||||
if recipe_data.get("format") == "sexp":
|
||||
nodes = recipe_data.get("dag", {}).get("nodes", [])
|
||||
else:
|
||||
# YAML format: nodes might be at top level or under dag
|
||||
nodes = recipe_data.get("nodes", recipe_data.get("dag", {}).get("nodes", []))
|
||||
recipe_data["step_count"] = len(nodes) if isinstance(nodes, (list, dict)) else 0
|
||||
|
||||
return recipe_data
|
||||
|
||||
async def list_recipes(self, actor_id: Optional[str] = None, offset: int = 0, limit: int = 20) -> List[Recipe]:
|
||||
"""
|
||||
List recipes owned by a user.
|
||||
|
||||
Queries item_types table for user's recipe links.
|
||||
"""
|
||||
import logging
|
||||
import database
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
recipes = []
|
||||
|
||||
if not actor_id:
|
||||
logger.warning("list_recipes called without actor_id")
|
||||
return []
|
||||
|
||||
# Get user's recipe CIDs from item_types
|
||||
user_items = await database.get_user_items(actor_id, item_type="recipe", limit=1000)
|
||||
recipe_cids = [item["cid"] for item in user_items]
|
||||
logger.info(f"Found {len(recipe_cids)} recipe CIDs for user {actor_id}")
|
||||
|
||||
for cid in recipe_cids:
|
||||
recipe = await self.get_recipe(cid)
|
||||
if recipe and not recipe.get("error"):
|
||||
recipes.append(recipe)
|
||||
elif recipe and recipe.get("error"):
|
||||
logger.warning(f"Recipe {cid[:16]}... has error: {recipe.get('error')}")
|
||||
|
||||
# Add friendly names
|
||||
from .naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
for recipe in recipes:
|
||||
recipe_id = recipe.get("recipe_id")
|
||||
if recipe_id:
|
||||
friendly = await naming.get_by_cid(actor_id, recipe_id)
|
||||
if friendly:
|
||||
recipe["friendly_name"] = friendly["friendly_name"]
|
||||
recipe["base_name"] = friendly["base_name"]
|
||||
|
||||
# Sort by name
|
||||
recipes.sort(key=lambda r: r.get("name", ""))
|
||||
|
||||
return recipes[offset:offset + limit]
|
||||
|
||||
async def upload_recipe(
|
||||
self,
|
||||
content: str,
|
||||
uploader: str,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Upload a recipe from S-expression content.
|
||||
|
||||
The recipe is stored in the cache and pinned to IPFS.
|
||||
Returns (recipe_id, error_message).
|
||||
"""
|
||||
# Validate S-expression
|
||||
try:
|
||||
compiled = compile_string(content)
|
||||
except ParseError as e:
|
||||
return None, f"Parse error: {e}"
|
||||
except CompileError as e:
|
||||
return None, f"Compile error: {e}"
|
||||
|
||||
# Write to temp file for caching
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".sexp", mode="w") as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
# Store in cache (content-addressed, auto-pins to IPFS)
|
||||
logger.info(f"upload_recipe: Storing recipe in cache from {tmp_path}")
|
||||
cached, ipfs_cid = self.cache.put(tmp_path, node_type="recipe", move=True)
|
||||
recipe_id = ipfs_cid or cached.cid # Prefer IPFS CID
|
||||
logger.info(f"upload_recipe: Stored recipe, cached.cid={cached.cid[:16]}..., ipfs_cid={ipfs_cid[:16] if ipfs_cid else None}, recipe_id={recipe_id[:16]}...")
|
||||
|
||||
# Track ownership in item_types and assign friendly name
|
||||
if uploader:
|
||||
import database
|
||||
display_name = name or compiled.name or "unnamed-recipe"
|
||||
|
||||
# Create item_types entry (ownership link)
|
||||
await database.save_item_metadata(
|
||||
cid=recipe_id,
|
||||
actor_id=uploader,
|
||||
item_type="recipe",
|
||||
description=description,
|
||||
filename=f"{display_name}.sexp",
|
||||
)
|
||||
|
||||
# Assign friendly name
|
||||
from .naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
await naming.assign_name(
|
||||
cid=recipe_id,
|
||||
actor_id=uploader,
|
||||
item_type="recipe",
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
return recipe_id, None
|
||||
|
||||
except Exception as e:
|
||||
return None, f"Failed to cache recipe: {e}"
|
||||
|
||||
async def delete_recipe(self, recipe_id: str, actor_id: str = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Remove user's ownership link to a recipe.
|
||||
|
||||
This removes the item_types entry linking the user to the recipe.
|
||||
The cached file is only deleted if no other users own it.
|
||||
Returns (success, error_message).
|
||||
"""
|
||||
import database
|
||||
|
||||
if not actor_id:
|
||||
return False, "actor_id required"
|
||||
|
||||
# Remove user's ownership link
|
||||
try:
|
||||
await database.delete_item_type(recipe_id, actor_id, "recipe")
|
||||
|
||||
# Also remove friendly name
|
||||
await database.delete_friendly_name(actor_id, recipe_id)
|
||||
|
||||
# Try to garbage collect if no one owns it anymore
|
||||
# (delete_cache_item only deletes if no item_types remain)
|
||||
await database.delete_cache_item(recipe_id)
|
||||
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, f"Failed to delete: {e}"
|
||||
|
||||
def parse_recipe(self, content: str) -> CompiledDAG:
|
||||
"""Parse recipe S-expression content."""
|
||||
compiled = compile_string(content)
|
||||
return compiled.to_dict()
|
||||
|
||||
def build_dag(self, recipe: Recipe) -> VisualizationDAG:
|
||||
"""
|
||||
Build DAG visualization data from recipe.
|
||||
|
||||
Returns nodes and edges for Cytoscape.js.
|
||||
"""
|
||||
vis_nodes: List[VisNode] = []
|
||||
edges: List[VisEdge] = []
|
||||
|
||||
dag = recipe.get("dag", {})
|
||||
dag_nodes = dag.get("nodes", [])
|
||||
output_node = dag.get("output")
|
||||
|
||||
# Handle list format (compiled S-expression)
|
||||
if isinstance(dag_nodes, list):
|
||||
for node_def in dag_nodes:
|
||||
node_id = node_def.get("id")
|
||||
node_type = node_def.get("type", "EFFECT")
|
||||
|
||||
vis_nodes.append({
|
||||
"data": {
|
||||
"id": node_id,
|
||||
"label": node_id,
|
||||
"nodeType": node_type,
|
||||
"isOutput": node_id == output_node,
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
# Handle dict format
|
||||
elif isinstance(dag_nodes, dict):
|
||||
for node_id, node_def in dag_nodes.items():
|
||||
node_type = node_def.get("type", "EFFECT")
|
||||
|
||||
vis_nodes.append({
|
||||
"data": {
|
||||
"id": node_id,
|
||||
"label": node_id,
|
||||
"nodeType": node_type,
|
||||
"isOutput": node_id == output_node,
|
||||
}
|
||||
})
|
||||
|
||||
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": vis_nodes, "edges": edges}
|
||||
Reference in New Issue
Block a user