├── requirements.txt ├── assets └── supercatform.png ├── plugin.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── main.yml ├── super_cat_form_events.py ├── prompts.py ├── .gitignore ├── super_cat_form_agent.py ├── README.md └── super_cat_form.py /requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/supercatform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucagobbi/super-cat-form/HEAD/assets/supercatform.png -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SuperCatForm", 3 | "version": "0.4.2", 4 | "description": "Supercharge your conversational forms with enhanced capabilities, real-time interactions, and a sleek user experience.", 5 | "author_name": "Luca Gobbi", 6 | "author_url": "https://github.com/lucagobbi", 7 | "plugin_url": "https://github.com/lucagobbi/super-cat-form", 8 | "tags": "cat, form, tools, conversational", 9 | "thumb": "https://raw.githubusercontent.com/lucagobbi/super-cat-form/main/assets/supercatform.png" 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | tags: 12 | - "*.*.*" 13 | 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | 18 | env: 19 | PLUGIN_JSON: "0.0.1" 20 | TAG_EXISTS: false 21 | PLUGIN_NAME: "supercatform" 22 | 23 | jobs: 24 | release: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | - name: Get plugin version 30 | run: | 31 | echo 'PLUGIN_JSON<> $GITHUB_ENV 32 | cat ./plugin.json >> $GITHUB_ENV 33 | echo 'EOF' >> $GITHUB_ENV 34 | - name: Publish tag 35 | if: env.TAG_EXISTS == false 36 | uses: rickstaa/action-create-tag@v1 37 | with: 38 | tag: "${{fromJson(env.PLUGIN_JSON).version}}" 39 | tag_exists_error: false 40 | message: "Latest release" 41 | - name: Zip release 42 | uses: TheDoctor0/zip-release@0.7.1 43 | with: 44 | type: 'zip' 45 | filename: '${{env.PLUGIN_NAME}}.zip' 46 | exclusions: '*.git* setup.py' 47 | directory: '.' 48 | path: '.' 49 | - name: Upload release 50 | uses: ncipollo/release-action@v1.12.0 51 | with: 52 | tag: "${{fromJson(env.PLUGIN_JSON).version}}" 53 | artifacts: '${{env.PLUGIN_NAME}}.zip' 54 | allowUpdates: true 55 | replacesArtifacts: true 56 | body: | 57 | ${{ github.event.head_commit.message }} 58 | token: ${{ secrets.GITHUB_TOKEN }} 59 | -------------------------------------------------------------------------------- /super_cat_form_events.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from datetime import datetime 3 | from typing import Dict, Any, List, Callable 4 | 5 | from pydantic import BaseModel 6 | 7 | from cat.log import log 8 | 9 | 10 | class FormEvent(Enum): 11 | 12 | # Lifecycle events 13 | FORM_INITIALIZED = "form_initialized" 14 | FORM_SUBMITTED = "form_submitted" 15 | FORM_CLOSED = "form_closed" 16 | 17 | # Extraction events 18 | EXTRACTION_STARTED = "extraction_started" 19 | EXTRACTION_COMPLETED = "extraction_completed" 20 | 21 | # Validation events 22 | VALIDATION_STARTED = "validation_started" 23 | VALIDATION_COMPLETED = "validation_completed" 24 | 25 | FIELD_UPDATED = "field_updated" 26 | 27 | # Tool events 28 | TOOL_STARTED = "tool_started" 29 | TOOL_EXECUTED = "tool_executed" 30 | TOOL_FAILED = "tool_failed" 31 | 32 | 33 | class FormEventContext(BaseModel): 34 | timestamp: datetime 35 | form_id: str 36 | event: FormEvent 37 | data: Dict[str, Any] 38 | 39 | 40 | class FormEventManager: 41 | def __init__(self): 42 | self._handlers: Dict[FormEvent, List[Callable[[FormEventContext], None]]] = { 43 | event: [] for event in FormEvent 44 | } 45 | 46 | def on(self, event: FormEvent, handler: Callable[[FormEventContext], None]): 47 | """Register an event handler""" 48 | self._handlers[event].append(handler) 49 | 50 | def emit(self, event: FormEvent, data: Dict[str, Any], form_id: str): 51 | """Emit an event to all registered handlers""" 52 | context = FormEventContext( 53 | timestamp=datetime.now(), 54 | form_id=form_id, 55 | event=event, 56 | data=data 57 | ) 58 | 59 | for handler in self._handlers[event]: 60 | try: 61 | handler(context) 62 | except Exception as e: 63 | log.error(f"Error in event handler: {str(e)}") -------------------------------------------------------------------------------- /prompts.py: -------------------------------------------------------------------------------- 1 | DEFAULT_NER_PROMPT = """ 2 | You are an advanced AI assistant specializing in information extraction and structured data formatting. 3 | Your task is to extract relevant information from a given text and format it according to a specified JSON structure. 4 | This extraction is part of a conversational form-filling process. 5 | 6 | Here are the key components for this task: 7 | 8 | 1. Chat History: 9 | 10 | {chat_history} 11 | 12 | 13 | 2.Form Description: 14 | 15 | {form_description} 16 | 17 | 18 | 3. Format Instructions (JSON schema): 19 | 20 | {format_instructions} 21 | 22 | 23 | Remember: 24 | - The extraction is part of an ongoing conversation, so consider any contextual information that might be relevant. 25 | - Only include information that is explicitly stated or can be directly inferred from the input text. 26 | - If a required field in the JSON schema cannot be filled based on the available information, use null or an appropriate default value as specified in the format instructions. 27 | - Ensure that the output JSON is valid and matches the structure defined in the format instructions. 28 | 29 | """ 30 | 31 | DEFAULT_TOOL_PROMPT = """ 32 | Create a JSON with the correct "action" and "action_input" for form compilation assistance. 33 | 34 | Current form data: {form_data} 35 | 36 | Available actions: {tools} 37 | 38 | CORE RULES: 39 | 1. Use specific tools ONLY when explicitly requested by user 40 | 2. Use "form_completion" for: 41 | - Any form filling or ordering intention 42 | - Direct responses to form questions 43 | 3. Default to "no_action" for: 44 | - When no action needed 45 | 46 | {examples} 47 | 48 | Response Format: 49 | {{ 50 | "action": str, // One of [{tool_names}, "no_action", "form_completion"] 51 | "action_input": str | null // Per action description 52 | }} 53 | """ 54 | 55 | DEFAULT_TOOL_EXAMPLES = """ 56 | Examples: 57 | "What's on the menu?" → "get_menu" (explicit menu request) 58 | "I want to order pizza" → "form_completion" (ordering intention) 59 | "Hi there" → "no_action" (greeting) 60 | """ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | .idea/ 153 | 154 | # Cheshire Cat Plugin settings 155 | settings.json 156 | -------------------------------------------------------------------------------- /super_cat_form_agent.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import traceback 3 | import inspect 4 | 5 | from langchain.prompts import ChatPromptTemplate 6 | from langchain_core.prompts.chat import SystemMessagePromptTemplate 7 | from langchain_core.runnables import RunnableConfig, RunnableLambda 8 | 9 | from cat.plugins.super_cat_form.super_cat_form_events import FormEvent 10 | from cat.agents import BaseAgent, AgentOutput 11 | from cat.looking_glass.output_parser import ChooseProcedureOutputParser, LLMAction 12 | from cat.looking_glass.callbacks import ModelInteractionHandler 13 | from cat.log import log 14 | from cat import utils 15 | 16 | class SuperCatFormAgent(BaseAgent): 17 | """Agent that executes form-based tools based on conversation context.""" 18 | 19 | def __init__(self, form_instance): 20 | self.form_tools = form_instance.get_form_tools() 21 | self.form_instance = form_instance 22 | 23 | def execute(self, stray) -> AgentOutput: 24 | if not self.form_tools: 25 | return AgentOutput() 26 | try: 27 | return self._process_tools(stray) 28 | except Exception as e: 29 | log.error(f"Error in agent execution: {str(e)}") 30 | traceback.print_exc() 31 | return AgentOutput() 32 | 33 | def _process_tools(self, stray) -> AgentOutput: 34 | 35 | llm_action = self._execute_tool_selection_chain(stray, self.form_instance.tool_prompt) 36 | 37 | log.debug(f"Selected tool: {llm_action}") 38 | 39 | result = self._execute_tool(llm_action) 40 | 41 | return result 42 | 43 | def _execute_tool_selection_chain(self, stray, prompt_template: str) -> LLMAction: 44 | """Execute the LLM chain to select appropriate tool.""" 45 | prompt_vars = self._prepare_prompt_variables() 46 | 47 | prompt_vars, prompt_template = utils.match_prompt_variables( 48 | prompt_vars, 49 | prompt_template 50 | ) 51 | 52 | prompt = ChatPromptTemplate( 53 | messages=[ 54 | SystemMessagePromptTemplate.from_template(template=prompt_template), 55 | *(stray.langchainfy_chat_history()), 56 | ] 57 | ) 58 | 59 | chain = ( 60 | prompt 61 | | RunnableLambda(lambda x: utils.langchain_log_prompt(x, "TOOL PROMPT")) 62 | | stray._llm 63 | | RunnableLambda(lambda x: utils.langchain_log_output(x, "TOOL PROMPT OUTPUT")) 64 | | ChooseProcedureOutputParser() 65 | ) 66 | 67 | return chain.invoke( 68 | prompt_vars, 69 | config=RunnableConfig( 70 | callbacks=[ModelInteractionHandler(stray, self.__class__.__name__)] 71 | ) 72 | ) 73 | 74 | def _execute_tool(self, llm_action: LLMAction) -> AgentOutput: 75 | """Execute the selected tool and return results.""" 76 | 77 | if not llm_action.action or llm_action.action in ["no_action", "form_completion"]: 78 | return AgentOutput(output="") 79 | 80 | chosen_procedure = self.form_tools.get(llm_action.action) 81 | 82 | if not chosen_procedure: 83 | log.error(f"Unknown tool: {llm_action.action}") 84 | return AgentOutput(output="") 85 | 86 | try: 87 | self.form_instance.events.emit( 88 | event=FormEvent.TOOL_STARTED, 89 | data={ 90 | "tool": llm_action.action, 91 | "tool_input": llm_action.action_input 92 | }, 93 | form_id=self.form_instance.name 94 | ) 95 | bound_method = chosen_procedure.__get__(self.form_instance, self.form_instance.__class__) 96 | sig = inspect.signature(chosen_procedure) 97 | params = list(sig.parameters.keys()) 98 | tool_output = ( 99 | bound_method() if len(params) == 1 else bound_method(llm_action.action_input) 100 | ) 101 | self.form_instance.events.emit( 102 | event=FormEvent.TOOL_EXECUTED, 103 | data={ 104 | "tool": llm_action.action, 105 | "tool_input": llm_action.action_input, 106 | "tool_output": tool_output 107 | }, 108 | form_id=self.form_instance.name 109 | ) 110 | return AgentOutput( 111 | output=str(tool_output), 112 | return_direct=chosen_procedure._return_direct, 113 | intermediate_steps=[ 114 | ((llm_action.action, llm_action.action_input), tool_output) 115 | ] 116 | ) 117 | except Exception as e: 118 | self.form_instance.events.emit( 119 | event=FormEvent.TOOL_FAILED, 120 | data={ 121 | "tool": llm_action.action, 122 | "tool_input": llm_action.action_input, 123 | "error": str(e) 124 | }, 125 | form_id=self.form_instance.name 126 | ) 127 | log.error( 128 | f"Error executing form tool `{chosen_procedure.__name__}`: {str(e)}" 129 | ) 130 | traceback.print_exc() 131 | 132 | return AgentOutput(output="") 133 | 134 | def _generate_examples(self) -> str: 135 | default_examples = self.form_instance.default_examples 136 | 137 | if default_examples: 138 | return default_examples 139 | 140 | default_examples = "Examples:\n" + "\n".join( 141 | f"{k}: {v._examples}" 142 | for k, v in self.form_tools.items() 143 | ) 144 | 145 | return default_examples 146 | 147 | 148 | def _prepare_prompt_variables(self) -> Dict[str, str]: 149 | """Prepare variables for the prompt template.""" 150 | return { 151 | "form_data": str(self.form_instance.form_data), 152 | "tools": "\n".join( 153 | f'- "{tool.__name__}": {tool.__doc__.strip()}' 154 | for tool in self.form_tools.values() 155 | ), 156 | "tool_names": '"' + '", "'.join(self.form_tools.keys()) + '"', 157 | "examples": self._generate_examples() 158 | } 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SuperCatForm 2 | 3 | 4 | 5 | [![awesome plugin](https://custom-icon-badges.demolab.com/static/v1?label=&message=awesome+plugin&color=383938&style=for-the-badge&logo=cheshire_cat_ai)](https://) 6 | 7 | SuperCatForm is a powerful, strongly opinionated, and flexible Cheshire Cat plugin that supercharges your conversational forms. Built as an enhancement to the original CatForm, it introduces advanced tools and capabilities for creating more dynamic and responsive user interactions. 8 | 9 | ## Features 10 | 11 | - **Tool calling during form execution**: SuperCatForm enables real-time interactions by allowing tools to be called during form execution. For instance, a restaurant order form can fetch the latest menu, a travel booking form can check live flight availability, or a shopping assistant form can retrieve daily discounts. This turns forms from simple data collectors into smart conversational agents. 12 | 13 | - **Nested fields support**: Effortlessly manage complex data structures for richer user interactions with nested fields and data structures. 14 | 15 | - **Full Pydantic validation support**: Ensure that validation rules are applied both in the extraction phase (i.e. inserted in the extraction prompt) and during validation phase. 16 | 17 | - **Form Events**: Hook into various stages of the form lifecycle to execute custom logic. For example, you can trigger actions when form extraction is completed, or when the form is submitted. 18 | 19 | - **JSON schema support**: Streamline form validation and consistency with schema-based definitions. 20 | 21 | - **Nested forms**: Create nested forms that can be embedded within other forms, enabling a more complex and interactive user experience. 22 | 23 | 24 | ## Usage 25 | 26 | 1. Install the SuperCatForm in your Cheshire Cat instance from the registry. 27 | 2. Create a new plugin. 28 | 3. Create a form class as you would do with traditional `CatForm`. 29 | 4. Define your model class leveraging all the power of Pydantic. 30 | 5. Replace the `@form` decorator with `@super_cat_form`. 31 | 6. Hook into form events using the `events` attribute. 32 | 7. Add useful methods to the class and mark them with `@form_tool`. 33 | 8. Have fun! 34 | 35 | 36 | ```python 37 | from typing import Literal, List 38 | from pydantic import BaseModel, Field 39 | from datetime import datetime 40 | 41 | from cat.plugins.super_cat_form.super_cat_form import SuperCatForm, form_tool, super_cat_form 42 | from cat.plugins.super_cat_form.super_cat_form_events import FormEvent, FormEventContext 43 | 44 | 45 | class Address(BaseModel): 46 | street: str 47 | city: str 48 | 49 | 50 | class Pizza(BaseModel): 51 | pizza_type: str = Field(description="Type of pizza") 52 | size: Literal["standard", "large"] = Field(default="standard") 53 | extra_toppings: List[str] = Field(default_factory=list) 54 | 55 | 56 | class PizzaOrder(BaseModel): 57 | pizzas: List[Pizza] 58 | address: Address 59 | due_date: datetime = Field(description="Datetime when the pizza should be delivered - format YYYY-MM-DD HH:MM") 60 | 61 | 62 | 63 | @super_cat_form 64 | class PizzaForm(SuperCatForm): 65 | description = "Pizza Order" 66 | model_class = PizzaOrder 67 | start_examples = [ 68 | "order a pizza!", 69 | "I want pizza" 70 | ] 71 | stop_examples = [ 72 | "stop pizza order", 73 | "not hungry anymore", 74 | ] 75 | ask_confirm = False 76 | 77 | def __init__(self, *args, **kwargs): 78 | super().__init__(*args, **kwargs) 79 | self.events.on( 80 | FormEvent.EXTRACTION_COMPLETED, 81 | self.hawaiian_is_not_a_real_pizza 82 | ) 83 | 84 | def hawaiian_is_not_a_real_pizza(self, context: FormEventContext): 85 | ordered_pizzas = context.data.get("pizzas", []) 86 | for pizza in ordered_pizzas: 87 | if pizza["pizza_type"] == "Hawaiian": 88 | self.cat.send_ws_message("Dude... really?", msg_type="chat") 89 | 90 | @form_tool(return_direct=True) 91 | def get_menu(self): 92 | """Useful to get the menu. User may ask: what is the menu? Input is always None.""" 93 | return ["Margherita", "Pepperoni", "Hawaiian"] 94 | 95 | @form_tool(return_direct=True) 96 | def ask_for_daily_promotions(self): 97 | """Useful to get any daily promotions. User may ask: what are the daily promotions? Input is always None.""" 98 | if datetime.now().weekday() == 0: 99 | return "Free delivery" 100 | elif datetime.now().weekday() == 4: 101 | return "Free Pepperoni" 102 | 103 | def submit(self, form_data): 104 | 105 | form_result = self.form_data_validated 106 | 107 | if form_result is None: 108 | return { 109 | "output": "Invalid form data" 110 | } 111 | 112 | return { 113 | "output": f"Ok! {form_result.pizzas} will be delivered to {form_result.address} on {form_result.due_date.strftime('%A, %B %d, %Y at %H:%M')}" 114 | } 115 | 116 | 117 | ``` 118 | 119 | ## Advanced Configuration 🔧 120 | 121 | ### Fresh Start 122 | 123 | You can optionally clear the conversation history when starting a form by setting `fresh_start = True` 124 | 125 | ```python 126 | 127 | @super_cat_form 128 | class YourForm(SuperCatForm): 129 | ... 130 | fresh_start = True 131 | 132 | ``` 133 | 134 | ### Force Activation 135 | 136 | You can optionally force the activation of a form by setting `force_activate = True`. 137 | This will use the Cat `before_cat_reads_message` hook to force the activation of this form if there are not active form set in working memory. 138 | 139 | ```python 140 | 141 | @super_cat_form 142 | class YourForm(SuperCatForm): 143 | ... 144 | force_activate = True 145 | 146 | ``` 147 | 148 | This is particularly useful when you want a clean start for a specific form without the context of previous conversations. 149 | 150 | ### Custom Prompts 151 | 152 | You can customize the prompts used for extraction and tool selection by overriding the default values in your 153 | `SuperCatForm` class. Like this: 154 | 155 | ```python 156 | 157 | @super_cat_form 158 | class MedicalDiagnosticForm(SuperCatForm): 159 | # Custom NER prompt with domain-specific instructions 160 | ner_prompt = """You are a medical assistant extracting patient data. 161 | 162 | Current form state: {form_data} 163 | 164 | Extract following entities: 165 | - Symptoms 166 | - Medical history 167 | - Allergies 168 | 169 | {format_instructions} 170 | """ 171 | 172 | # Custom tool selection prompt 173 | tool_prompt = """You are a medical diagnostic assistant. Available tools: 174 | 175 | {tools} 176 | 177 | Use tools when you need additional information. 178 | 179 | {examples} 180 | """ 181 | 182 | ``` 183 | 184 |
185 | 186 | Default NER Prompt 187 | 188 | 189 | 190 | ``` 191 | You are an advanced AI assistant specializing in information extraction and structured data formatting. 192 | Your task is to extract relevant information from a given text and format it according to a specified JSON structure. 193 | This extraction is part of a conversational form-filling process. 194 | 195 | Here are the key components for this task: 196 | 197 | 1. Chat History: 198 | 199 | {chat_history} 200 | 201 | 202 | 2.Form Description: 203 | 204 | {form_description} 205 | 206 | 207 | 3. Format Instructions (JSON schema): 208 | 209 | {format_instructions} 210 | 211 | 212 | Remember: 213 | - The extraction is part of an ongoing conversation, so consider any contextual information that might be relevant. 214 | - Only include information that is explicitly stated or can be directly inferred from the input text. 215 | - If a required field in the JSON schema cannot be filled based on the available information, use null or an appropriate default value as specified in the format instructions. 216 | - Ensure that the output JSON is valid and matches the structure defined in the format instructions. 217 | ``` 218 |
219 | 220 | 221 |
222 | 223 | Default Tool Prompt 224 | 225 | 226 | 227 | ``` 228 | Create a JSON with the correct "action" and "action_input" for form compilation assistance. 229 | 230 | Current form data: {form_data} 231 | 232 | Available actions: {tools} 233 | 234 | CORE RULES: 235 | 1. Use specific tools ONLY when explicitly requested by user 236 | 2. Default to "no_action" for: 237 | - Any form filling or ordering intention 238 | - Direct responses to form questions 239 | - When no action needed 240 | 241 | {examples} 242 | 243 | Response Format: 244 | {{ 245 | "action": str, // One of [{tool_names}, "no_action"] 246 | "action_input": str | null // Per action description 247 | }} 248 | ``` 249 |
250 | 251 | ### Tool Examples 252 | 253 | It is often recommended to provide examples for the usage of tools in the tool prompt. 254 | 255 | You can either override the `default_examples` attribute on your `SuperCatForm` class: 256 | 257 | ```python 258 | 259 | @super_cat_form 260 | class HotelBookingForm(SuperCatForm): 261 | default_examples = """ 262 | Examples: 263 | "Are pets allowed?" → "are_pets_allowed" (explicit pets request) 264 | """ 265 | 266 | @form_tool 267 | def are_pets_allowed(self): 268 | """Useful to check if pets are allowed. User may ask: are pets allowed? Input is always None.""" 269 | return True 270 | ``` 271 | 272 | Or you can use the `examples` parameter on the `@form_tool` decorator, be sure to deactivate the default examples by setting `default_examples = None` on your `SuperCatForm` class: 273 | 274 | ```python 275 | 276 | @super_cat_form 277 | class HotelBookingForm(SuperCatForm): 278 | default_examples = None 279 | 280 | @form_tool(examples=["Are pets allowed?"]) 281 | def are_pets_allowed(self): 282 | """Useful to check if pets are allowed. User may ask: are pets allowed? Input is always None.""" 283 | return True 284 | ``` 285 | 286 |
287 | 288 | Default Examples Prompt 289 | 290 | 291 | 292 | ``` 293 | Examples: 294 | "What's on the menu?" → "get_menu" (explicit menu request) 295 | "I want to order pizza" → "form_completion" (ordering intention) 296 | "Hi there" → "no_action" (greeting) 297 | ``` 298 |
299 | 300 | 301 | 302 | ## Form Events 303 | 304 | You can hook into various stages of the form lifecycle to execute custom logic. For example, you can trigger actions when form extraction is completed, or when the form is submitted. 305 | 306 | Events supported: 307 | 308 | ```python 309 | class FormEvent(Enum): 310 | 311 | # Lifecycle events 312 | FORM_INITIALIZED = "form_initialized" 313 | FORM_SUBMITTED = "form_submitted" 314 | FORM_CLOSED = "form_closed" 315 | 316 | # Extraction events 317 | EXTRACTION_STARTED = "extraction_started" 318 | EXTRACTION_COMPLETED = "extraction_completed" 319 | 320 | # Validation events 321 | VALIDATION_STARTED = "validation_started" 322 | VALIDATION_COMPLETED = "validation_completed" 323 | 324 | FIELD_UPDATED = "field_updated" 325 | 326 | # Tool events 327 | TOOL_STARTED = "tool_started" 328 | TOOL_EXECUTED = "tool_executed" 329 | TOOL_FAILED = "tool_failed" 330 | ``` 331 | 332 | A form event handler is a regular python function that takes a `FormEventContext` as input: 333 | 334 | ```python 335 | 336 | class FormEventContext(BaseModel): 337 | timestamp: datetime # Event occurrence time 338 | form_id: str # Form identifier 339 | event: FormEvent # Event type 340 | data: Dict[str, Any] # Event-specific payload 341 | 342 | ``` 343 | 344 | ## Nested Forms 345 | 346 | SuperCatForm now supports creating multi-step workflows through nested forms. This allows you to break complex interactions into simpler, reusable components. 347 | 348 | - forms can launch other forms and automatically return when a sub-form completes 349 | - each form focuses on a specific part of data collection 350 | - launch sub-forms using the familiar form tool system 351 | - child forms can access and update parent form data 352 | 353 | ### How to Use Nested Forms 354 | 355 | Create your sub-form class as a standard SuperCatForm. 356 | 357 | > Remember to remove the decorator `@super_cat_form` from the sub-form class if you don't want it to be triggered as a 358 | > regular form outside of its parent form. 359 | 360 | In your parent form, add a form tool that uses start_sub_form() to launch the sub-form 361 | When the sub-form completes, it automatically returns to the parent form 362 | 363 | ```python 364 | from typing import List 365 | from pydantic import BaseModel, Field 366 | 367 | from cat.plugins.super_cat_form.super_cat_form import SuperCatForm, form_tool, super_cat_form 368 | from cat.log import log 369 | 370 | # ============= ADDRESS SUB-FORM ============= 371 | 372 | class AddressModel(BaseModel): 373 | street: str = Field(description="Street address") 374 | city: str = Field(description="City name") 375 | zip_code: str = Field(description="Postal/ZIP code") 376 | 377 | class AddressForm(SuperCatForm): 378 | """Address collection form that can be launched from other forms""" 379 | name = "AddressForm" 380 | description = "Collect address information" 381 | model_class = AddressModel 382 | start_examples = [ 383 | "I want to enter an address", 384 | "Let me add my address" 385 | ] 386 | stop_examples = [ 387 | "cancel address entry", 388 | "stop address form" 389 | ] 390 | 391 | def submit(self, form_data): 392 | # Access the parent form and update its data with the collected address 393 | self.parent_form.form_data['address'] = form_data 394 | return { 395 | "output": f"Address saved: {form_data['street']}, {form_data['city']}, {form_data['zip_code']}" 396 | } 397 | 398 | # ============= MAIN ORDER FORM ============= 399 | 400 | class OrderItem(BaseModel): 401 | name: str = Field(description="Item name") 402 | quantity: int = Field(description="Number of items", gt=0) 403 | 404 | class OrderModel(BaseModel): 405 | customer_name: str = Field(description="Customer's full name") 406 | items: List[OrderItem] = Field(description="Items to order") 407 | address: dict = Field(description="Customer's address") 408 | 409 | @super_cat_form 410 | class OrderForm(SuperCatForm): 411 | name = "OrderForm" 412 | description = "Process customer orders" 413 | model_class = OrderModel 414 | start_examples = [ 415 | "I want to place an order", 416 | "Order some items" 417 | ] 418 | stop_examples = [ 419 | "cancel my order", 420 | "stop ordering" 421 | ] 422 | 423 | @form_tool(return_direct=True, examples=["I need to add my address", "Enter my address"]) 424 | def collect_address(self): 425 | """Collects the customer's address. User may ask: collect my address, enter delivery address""" 426 | return self.start_sub_form(AddressForm) 427 | 428 | def submit(self, form_data): 429 | items_summary = ", ".join([f"{item['quantity']} x {item['name']}" for item in form_data['items']]) 430 | address = form_data.get('address', {}) 431 | address_str = f"{address.get('street', '')}, {address.get('city', '')}, {address.get('zip_code', '')}" 432 | 433 | return { 434 | "output": f"Order placed for {form_data['customer_name']}. Items: {items_summary}. Shipping to: {address_str}" 435 | } 436 | ``` 437 | -------------------------------------------------------------------------------- /super_cat_form.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import wraps 3 | from typing import Dict, Optional, Type 4 | from pydantic import BaseModel, ValidationError 5 | 6 | from langchain_core.output_parsers import JsonOutputParser 7 | from langchain_core.prompts import PromptTemplate 8 | from langchain_core.messages import HumanMessage 9 | from langchain_core.runnables import RunnableConfig, RunnableLambda 10 | from langchain_core.prompts import ChatPromptTemplate 11 | from langchain_core.output_parsers.string import StrOutputParser 12 | 13 | from cat.looking_glass.callbacks import NewTokenHandler 14 | from cat.experimental.form import form, CatForm, CatFormState 15 | from cat.plugins.super_cat_form.super_cat_form_agent import SuperCatFormAgent 16 | from cat.plugins.super_cat_form.super_cat_form_events import FormEventManager, FormEvent, FormEventContext 17 | from cat.plugins.super_cat_form import prompts 18 | from cat.log import log 19 | from cat import utils 20 | 21 | from cat.looking_glass.callbacks import ModelInteractionHandler 22 | from cat.mad_hatter.decorators import hook 23 | 24 | 25 | def form_tool(func=None, *, return_direct=False, examples=None): 26 | 27 | if examples is None: 28 | examples = [] 29 | 30 | if func is None: 31 | return lambda f: form_tool(f, return_direct=return_direct, examples=examples) 32 | 33 | @wraps(func) 34 | def wrapper(self, *args, **kwargs): 35 | return func(self, *args, **kwargs) 36 | 37 | wrapper._is_form_tool = True 38 | wrapper._return_direct = return_direct 39 | wrapper._examples = examples 40 | return wrapper 41 | 42 | 43 | class SuperCatForm(CatForm): 44 | """ 45 | SuperCatForm is the CatForm class that extends the functionality of the original CatForm class. 46 | """ 47 | ner_prompt = prompts.DEFAULT_NER_PROMPT 48 | tool_prompt = prompts.DEFAULT_TOOL_PROMPT 49 | default_examples = prompts.DEFAULT_TOOL_EXAMPLES 50 | 51 | # Track the form that started this form (if any) 52 | parent_form = None 53 | 54 | # Flag for cleaning up conversation history - each form is a completely new conversation 55 | fresh_start = False 56 | 57 | # Flag for forcing activation of the form despite the provided triggers 58 | force_activate = False 59 | 60 | def __init__(self, cat): 61 | super().__init__(cat) 62 | 63 | if self.fresh_start: 64 | self.cat.working_memory.history = self.cat.working_memory.history[-1:] 65 | 66 | self.tool_agent = SuperCatFormAgent(self) 67 | self.events = FormEventManager() 68 | self._setup_default_handlers() 69 | # This hack to ensure backward compatibility with version pre-1.8.0 70 | self._legacy_version = 'model' in inspect.signature(super().validate).parameters 71 | self.events.emit( 72 | FormEvent.FORM_INITIALIZED, 73 | data={}, 74 | form_id=self.name 75 | ) 76 | self.cat.llm = self.super_llm 77 | 78 | def super_llm(self, prompt: str | ChatPromptTemplate, params: dict = None, stream: bool = False) -> str: 79 | 80 | callbacks = [] 81 | if stream: 82 | callbacks.append(NewTokenHandler(self.cat)) 83 | 84 | caller = utils.get_caller_info() 85 | callbacks.append(ModelInteractionHandler(self.cat, caller or "StrayCat")) 86 | 87 | if isinstance(prompt, str): 88 | prompt = ChatPromptTemplate( 89 | messages=[ 90 | # Use HumanMessage instead of SystemMessage for wide-range compatibility 91 | HumanMessage(content=prompt) 92 | ] 93 | ) 94 | 95 | chain = ( 96 | prompt 97 | | RunnableLambda(lambda x: utils.langchain_log_prompt(x, f"{caller} prompt")) 98 | | self.cat._llm 99 | | RunnableLambda(lambda x: utils.langchain_log_output(x, f"{caller} prompt output")) 100 | | StrOutputParser() 101 | ) 102 | 103 | output = chain.invoke( 104 | params or {}, 105 | config=RunnableConfig(callbacks=callbacks) 106 | ) 107 | 108 | return output 109 | 110 | def _setup_default_handlers(self): 111 | """Setup default event handlers for logging""" 112 | for event in FormEvent: 113 | self.events.on(event, self._log_event) 114 | 115 | # Add handler for form exit to restore previous form 116 | self.events.on(FormEvent.FORM_CLOSED, self._restore_parent_form) 117 | self.events.on(FormEvent.FORM_SUBMITTED, self._restore_parent_form) 118 | 119 | def _restore_parent_form(self, *args, **kwargs): 120 | """Restore parent form when this form is closed or submitted""" 121 | if self.parent_form is not None: 122 | self.cat.working_memory.active_form = self.parent_form 123 | log.debug(f"Restored previous form: {self.parent_form.name}") 124 | 125 | def _log_event(self, event: FormEventContext): 126 | log.debug(f"Form {self.name}: {event.event.name} - {event.data}") 127 | 128 | def _get_validated_form_data(self) -> Optional[BaseModel]: 129 | """ 130 | Safely attempts to get validated form data. 131 | Returns None if the form is incomplete or invalid. 132 | 133 | Returns: 134 | Optional[BaseModel]: Validated Pydantic model if successful, None otherwise 135 | """ 136 | try: 137 | return self.model_getter()(**self._model) 138 | except ValidationError: 139 | return None 140 | 141 | @classmethod 142 | def get_form_tools(cls): 143 | """ 144 | Get all methods of the class that are decorated with @form_tool. 145 | """ 146 | form_tools = {} 147 | for name, func in inspect.getmembers(cls): 148 | if inspect.isfunction(func) or inspect.ismethod(func): 149 | if getattr(func, '_is_form_tool', False): 150 | form_tools[name] = func 151 | return form_tools 152 | 153 | def update(self): 154 | """ 155 | Version-compatible update method that works with both old and new CatForm versions. 156 | Ensures _model is always a dictionary. 157 | """ 158 | 159 | old_model = self._model.copy() if self._model is not None else {} 160 | 161 | # Extract and sanitize new data 162 | json_details = self.extract() 163 | json_details = self.sanitize(json_details) 164 | merged_model = old_model | json_details 165 | 166 | if self._legacy_version: 167 | # old version: validate returns the updated model 168 | validated_model = self.validate(merged_model) 169 | # ensure we never set None as the model 170 | self._model = validated_model if validated_model is not None else {} 171 | else: 172 | # new version: set model first, then validate 173 | self._model = merged_model 174 | self.validate() 175 | 176 | # ensure self._model is never None 177 | if self._model is None: 178 | self._model = {} 179 | 180 | # emit events for updated fields 181 | updated_fields = { 182 | k: v for k, v in self._model.items() 183 | if k not in old_model or old_model[k] != v 184 | } 185 | 186 | if updated_fields: 187 | self.events.emit( 188 | FormEvent.FIELD_UPDATED, 189 | { 190 | "fields": updated_fields, 191 | "old_values": {k: old_model.get(k) for k in updated_fields} 192 | }, 193 | self.name 194 | ) 195 | 196 | def sanitize(self, model: Dict) -> Dict: 197 | """ 198 | Sanitize the model while preserving nested structures. 199 | Only removes explicitly null values. 200 | 201 | Args: 202 | model: Dictionary containing form data 203 | 204 | Returns: 205 | Dict: Sanitized form data 206 | """ 207 | 208 | if "$defs" in model: 209 | del model["$defs"] 210 | 211 | def _sanitize_nested(data): 212 | if isinstance(data, dict): 213 | return { 214 | k: _sanitize_nested(v) 215 | for k, v in data.items() 216 | if v not in ("None", "null", "lower-case", "unknown", "missing") 217 | } 218 | return data 219 | 220 | return _sanitize_nested(model) 221 | 222 | def validate(self, model=None): 223 | """ 224 | Override the validate method to properly handle nested structures 225 | while preserving partial data. 226 | """ 227 | self.events.emit( 228 | FormEvent.VALIDATION_STARTED, 229 | {"model": self._model}, 230 | self.name 231 | ) 232 | 233 | self._missing_fields = [] 234 | self._errors = [] 235 | 236 | try: 237 | if self._legacy_version and model is not None: 238 | validated_model = self.model_getter()(**model).model_dump(mode="json") 239 | self._state = CatFormState.COMPLETE 240 | return validated_model 241 | else: 242 | # New version: validate self._model 243 | self.model_getter()(**self._model) 244 | self._state = CatFormState.COMPLETE 245 | 246 | 247 | except ValidationError as e: 248 | for error in e.errors(): 249 | field_path = '.'.join(str(loc) for loc in error['loc']) 250 | if error['type'] == 'missing': 251 | self._missing_fields.append(field_path) 252 | else: 253 | self._errors.append(f'{field_path}: {error["msg"]}') 254 | 255 | self._state = CatFormState.INCOMPLETE 256 | 257 | if self._legacy_version and model is not None: 258 | return model 259 | finally: 260 | self.events.emit( 261 | FormEvent.VALIDATION_COMPLETED, 262 | { 263 | "model": self._model, 264 | "missing_fields": self._missing_fields, 265 | "errors": self._errors 266 | }, 267 | self.name 268 | ) 269 | 270 | def extract(self): 271 | """ 272 | Override the extract method to include NER with LangChain JsonOutputParser 273 | """ 274 | try: 275 | self.events.emit( 276 | FormEvent.EXTRACTION_STARTED, 277 | data={ 278 | "chat_history": self.cat.stringify_chat_history(), 279 | "form_data": self.form_data 280 | }, 281 | form_id=self.name 282 | ) 283 | prompt_params = { 284 | "chat_history": self.cat.stringify_chat_history(), 285 | "form_description": f"{self.name} - {self.description}" 286 | } 287 | parser = JsonOutputParser(pydantic_object=self.model_getter()) 288 | prompt = PromptTemplate( 289 | template=self.ner_prompt, 290 | input_variables=list(prompt_params.keys()), 291 | partial_variables={"format_instructions": 292 | parser.get_format_instructions()}, 293 | ) 294 | chain = prompt | self.cat._llm | parser 295 | output_model = chain.invoke(prompt_params) 296 | self.events.emit( 297 | FormEvent.EXTRACTION_COMPLETED, 298 | data=output_model, 299 | form_id=self.name 300 | ) 301 | except Exception as e: 302 | output_model = {} 303 | log.error(e) 304 | 305 | return output_model 306 | 307 | def start_sub_form(self, form_class): 308 | """ 309 | Create and activate a new form, saving this form as the parent form 310 | 311 | Args: 312 | form_class: The form class to instantiate 313 | 314 | Returns: 315 | str: The initial message from the new form 316 | """ 317 | # Create the new form instance 318 | new_form = form_class(self.cat) 319 | 320 | # Set the parent form reference 321 | new_form.parent_form = self 322 | 323 | # Activate the new form 324 | self.cat.working_memory.active_form = new_form 325 | 326 | log.debug(f"Started sub-form: {new_form.name} from parent: {self.name}") 327 | 328 | # Return the first message of the new form 329 | return new_form.next()["output"] 330 | 331 | def next(self): 332 | 333 | if self._state == CatFormState.WAIT_CONFIRM: 334 | if self.confirm(): 335 | self._handle_form_submission() 336 | else: 337 | if self.check_exit_intent(): 338 | self._state = CatFormState.CLOSED 339 | self.events.emit( 340 | FormEvent.FORM_CLOSED, 341 | { 342 | "form_data": self.form_data 343 | }, 344 | self.name 345 | ) 346 | else: 347 | self._state = CatFormState.INCOMPLETE 348 | 349 | if self.check_exit_intent() and not self._state == CatFormState.CLOSED: 350 | self._state = CatFormState.CLOSED 351 | self.events.emit( 352 | FormEvent.FORM_CLOSED, 353 | { 354 | "form_data": self.form_data 355 | }, 356 | self.name 357 | ) 358 | 359 | if self._state == CatFormState.INCOMPLETE: 360 | 361 | # Execute agent if form tools are present 362 | if len(self.get_form_tools()) > 0: 363 | agent_output = self.tool_agent.execute(self.cat) 364 | if agent_output.output: 365 | if agent_output.return_direct: 366 | return {"output": agent_output.output} 367 | self.update() 368 | else: 369 | self.update() 370 | 371 | if self._state == CatFormState.COMPLETE: 372 | if self.ask_confirm: 373 | self._state = CatFormState.WAIT_CONFIRM 374 | else: 375 | return self._handle_form_submission() 376 | 377 | return self.message() 378 | 379 | def _handle_form_submission(self): 380 | """Handle form submission and event emission""" 381 | self._state = CatFormState.CLOSED 382 | self.events.emit( 383 | FormEvent.FORM_SUBMITTED, 384 | { 385 | "form_data": self.form_data 386 | }, 387 | self.name 388 | ) 389 | return self.submit(self._model) 390 | 391 | def model_getter(self) -> Type[BaseModel]: 392 | """ 393 | Override for backward compatibility with older CatForm versions where model_getter 394 | might not be implemented. This method simply returns model_class, which maintains 395 | identical functionality while ensuring the method exists in legacy scenarios (pre 1.8.0). 396 | """ 397 | return self.model_class 398 | 399 | @property 400 | def form_data(self) -> Dict: 401 | return self._model 402 | 403 | @property 404 | def form_data_validated(self) -> Optional[BaseModel]: 405 | return self._get_validated_form_data() 406 | 407 | 408 | def super_cat_form(form: SuperCatForm) -> SuperCatForm: 409 | """ 410 | Decorator to mark a class as a SuperCatForm. 411 | """ 412 | form._autopilot = True 413 | if form.name is None: 414 | form.name = form.__name__ 415 | 416 | if form.triggers_map is None: 417 | form.triggers_map = { 418 | "start_example": form.start_examples, 419 | "description": [f"{form.name}: {form.description}"], 420 | } 421 | 422 | return form 423 | 424 | 425 | @hook 426 | def before_cat_reads_message(user_message_json, cat): 427 | for form in cat.mad_hatter.forms: 428 | if form.force_activate and cat.working_memory.active_form is None: 429 | log.info(f"Forcing form activation: {form.name}") 430 | new_form_instance = form(cat) 431 | cat.working_memory.active_form = new_form_instance 432 | return user_message_json 433 | --------------------------------------------------------------------------------