from typing import List from fastapi import APIRouter, HTTPException from loguru import logger from api.models import ( DefaultPromptResponse, DefaultPromptUpdate, TransformationCreate, TransformationExecuteRequest, TransformationExecuteResponse, TransformationResponse, TransformationUpdate, ) from open_notebook.ai.models import Model from open_notebook.domain.transformation import DefaultPrompts, Transformation from open_notebook.exceptions import InvalidInputError, OpenNotebookError from open_notebook.graphs.transformation import graph as transformation_graph router = APIRouter() def _transformation_response(transformation: Transformation) -> TransformationResponse: return TransformationResponse( id=transformation.id or "", name=transformation.name, title=transformation.title, description=transformation.description, prompt=transformation.prompt, apply_default=transformation.apply_default, model_id=transformation.model_id, created=str(transformation.created), updated=str(transformation.updated), ) @router.get("/transformations", response_model=List[TransformationResponse]) async def get_transformations(): """Get all transformations.""" try: transformations = await Transformation.get_all(order_by="name asc") return [ _transformation_response(transformation) for transformation in transformations ] except HTTPException: raise except OpenNotebookError: raise except Exception as e: logger.error(f"Error fetching transformations: {str(e)}") raise HTTPException( status_code=500, detail=f"Error fetching transformations: {str(e)}" ) @router.post("/transformations", response_model=TransformationResponse) async def create_transformation(transformation_data: TransformationCreate): """Create a new transformation.""" try: # Reject unknown model references up front (same check as execute); # otherwise an invalid model_id is stored and only fails at run time. if transformation_data.model_id: model = await Model.get(transformation_data.model_id) if not model: raise HTTPException(status_code=404, detail="Model not found") new_transformation = Transformation( name=transformation_data.name, title=transformation_data.title, description=transformation_data.description, prompt=transformation_data.prompt, apply_default=transformation_data.apply_default, model_id=transformation_data.model_id, ) await new_transformation.save() return _transformation_response(new_transformation) except HTTPException: raise except InvalidInputError as e: raise HTTPException(status_code=400, detail=str(e)) except OpenNotebookError: raise except Exception as e: logger.error(f"Error creating transformation: {str(e)}") raise HTTPException( status_code=500, detail=f"Error creating transformation: {str(e)}" ) @router.post("/transformations/execute", response_model=TransformationExecuteResponse) async def execute_transformation(execute_request: TransformationExecuteRequest): """Execute a transformation on input text.""" try: # Validate transformation exists transformation = await Transformation.get(execute_request.transformation_id) if not transformation: raise HTTPException(status_code=404, detail="Transformation not found") model_id = execute_request.model_id or transformation.model_id # Validate explicit or transformation-specific model exists. # None is allowed so the graph can use the configured transformation default. if model_id: model = await Model.get(model_id) if not model: raise HTTPException(status_code=404, detail="Model not found") # Execute the transformation. # LangGraph accepts a partial state dict at runtime, but its typed # overloads require the full state type (langgraph typing limitation). result = await transformation_graph.ainvoke( # type: ignore[call-overload] dict( input_text=execute_request.input_text, transformation=transformation, ), config=dict(configurable={"model_id": model_id}), ) return TransformationExecuteResponse( output=result["output"], transformation_id=execute_request.transformation_id, model_id=model_id, ) except HTTPException: raise except OpenNotebookError: raise # Let global exception handlers return proper status codes except Exception as e: logger.error(f"Error executing transformation: {str(e)}") raise HTTPException( status_code=500, detail=f"Error executing transformation: {str(e)}" ) @router.get("/transformations/default-prompt", response_model=DefaultPromptResponse) async def get_default_prompt(): """Get the default transformation prompt.""" try: default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment] return DefaultPromptResponse( transformation_instructions=default_prompts.transformation_instructions or "" ) except HTTPException: raise except OpenNotebookError: raise except Exception as e: logger.error(f"Error fetching default prompt: {str(e)}") raise HTTPException( status_code=500, detail=f"Error fetching default prompt: {str(e)}" ) @router.put("/transformations/default-prompt", response_model=DefaultPromptResponse) async def update_default_prompt(prompt_update: DefaultPromptUpdate): """Update the default transformation prompt.""" try: default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment] default_prompts.transformation_instructions = ( prompt_update.transformation_instructions ) await default_prompts.update() return DefaultPromptResponse( transformation_instructions=default_prompts.transformation_instructions ) except HTTPException: raise except OpenNotebookError: raise except Exception as e: logger.error(f"Error updating default prompt: {str(e)}") raise HTTPException( status_code=500, detail=f"Error updating default prompt: {str(e)}" ) @router.get( "/transformations/{transformation_id}", response_model=TransformationResponse ) async def get_transformation(transformation_id: str): """Get a specific transformation by ID.""" try: transformation = await Transformation.get(transformation_id) if not transformation: raise HTTPException(status_code=404, detail="Transformation not found") return _transformation_response(transformation) except HTTPException: raise except OpenNotebookError: raise except Exception as e: logger.error(f"Error fetching transformation {transformation_id}: {str(e)}") raise HTTPException( status_code=500, detail=f"Error fetching transformation: {str(e)}" ) @router.put( "/transformations/{transformation_id}", response_model=TransformationResponse ) async def update_transformation( transformation_id: str, transformation_update: TransformationUpdate ): """Update a transformation.""" try: transformation = await Transformation.get(transformation_id) if not transformation: raise HTTPException(status_code=404, detail="Transformation not found") # Update only provided fields if transformation_update.name is not None: transformation.name = transformation_update.name if transformation_update.title is not None: transformation.title = transformation_update.title if transformation_update.description is not None: transformation.description = transformation_update.description if transformation_update.prompt is not None: transformation.prompt = transformation_update.prompt if transformation_update.apply_default is not None: transformation.apply_default = transformation_update.apply_default if "model_id" in transformation_update.model_fields_set: # Validate a newly supplied model reference (allow clearing to None). if transformation_update.model_id: model = await Model.get(transformation_update.model_id) if not model: raise HTTPException(status_code=404, detail="Model not found") transformation.model_id = transformation_update.model_id await transformation.save() return _transformation_response(transformation) except HTTPException: raise except InvalidInputError as e: raise HTTPException(status_code=400, detail=str(e)) except OpenNotebookError: raise except Exception as e: logger.error(f"Error updating transformation {transformation_id}: {str(e)}") raise HTTPException( status_code=500, detail=f"Error updating transformation: {str(e)}" ) @router.delete("/transformations/{transformation_id}") async def delete_transformation(transformation_id: str): """Delete a transformation.""" try: transformation = await Transformation.get(transformation_id) if not transformation: raise HTTPException(status_code=404, detail="Transformation not found") await transformation.delete() return {"message": "Transformation deleted successfully"} except HTTPException: raise except OpenNotebookError: raise except Exception as e: logger.error(f"Error deleting transformation {transformation_id}: {str(e)}") raise HTTPException( status_code=500, detail=f"Error deleting transformation: {str(e)}" )