From afb42f013973f783773fca02cbb3dedbb2965c24 Mon Sep 17 00:00:00 2001 From: Luca Dev Zone <150229276+lucadevzone@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:26:54 +0200 Subject: [PATCH] [sdialog] add RAG counsellor tutorial examples --- .../00_overview/8.counsellor_rag_agent.py | 107 +++ .../00_overview/8.rag_tool_integration.ipynb | 731 ++++++++++++++++++ 2 files changed, 838 insertions(+) create mode 100644 tutorials/00_overview/8.counsellor_rag_agent.py create mode 100644 tutorials/00_overview/8.rag_tool_integration.ipynb diff --git a/tutorials/00_overview/8.counsellor_rag_agent.py b/tutorials/00_overview/8.counsellor_rag_agent.py new file mode 100644 index 0000000..c00b4a6 --- /dev/null +++ b/tutorials/00_overview/8.counsellor_rag_agent.py @@ -0,0 +1,107 @@ +import sys +from pathlib import Path + +import requests +from typing import Optional, List, Callable +from sdialog.agents import Agent, BasePersona + +# Allow running this tutorial directly from the repository without requiring +# an editable install. +REPO_ROOT = Path(__file__).resolve().parents[2] +SRC_DIR = REPO_ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + + + +# --------------------------------------------------------------------------- +# Tool function +# --------------------------------------------------------------------------- + +def search_italian_university_data(query: str, docs_k: str = "3") -> dict: + """ + Search the university counselling knowledge base for guidelines + and information relevant to the student's situation. + + Use this tool whenever the conversation requires information about: + - Universities and Department in the italian territory + - Available resources (offices, contacts, scheduling) + + Args: + query (str): + A short, self-contained question or keyword phrase describing + what information is needed. + docs_k (str): + Number of document snippets to retrieve (default: "3"). + + Returns: + dict: A dictionary where each key is "doc1", "doc2", … "docN" and + the corresponding value is the text of that retrieved snippet. + Returns {"error": ""} if the retrieval service fails. + """ + + try: + top_k = int(docs_k) + except (ValueError, TypeError): + top_k = 3 + + endpoint = "http://localhost:7997/search" + payload = { + "index_name": "rag_chunks", + "query": query, + "top_k": top_k, + } + + try: + response = requests.get(endpoint, params=payload, timeout=10) + response.raise_for_status() + results = response.json() + except requests.RequestException as exc: + return {"error": str(exc)} + + if isinstance(results, dict) and "documents" in results: + items = results["documents"] + else: + items = results + + snippets = [item if isinstance(item, str) else item.get("text", "") for item in items] + return {f"doc{i + 1}": text for i, text in enumerate(snippets)} + + +# --------------------------------------------------------------------------- +# COUNSELLOR AGENT +# --------------------------------------------------------------------------- + +class Counsellor(BasePersona): + """University Counsellor""" + name: str = "Counsellor" + + def prompt(self) -> str: + return """You are a university counsellor. Help the students select their courses. Answer always in english.""" + + +agent = Agent( + persona=Counsellor(), + model="openai:llama3.1:8b", + openai_api_base="http://localhost:11434/v1", + openai_api_key="EMPTY", + tools=[search_italian_university_data], +) + + +# --------------------------------------------------------------------------- +# Interactive loop +# --------------------------------------------------------------------------- + +print("\n Interactive Mode (write 'quit' to exit)\n") +while True: + question = input("Student: ").strip() + if question.lower() in ["quit", "exit", "q"]: + break + if not question: + continue + try: + reply = agent(question) + print(f"\nCounsellor: {reply}\n") + except Exception as e: + print(f"Errore: {e}\n") diff --git a/tutorials/00_overview/8.rag_tool_integration.ipynb b/tutorials/00_overview/8.rag_tool_integration.ipynb new file mode 100644 index 0000000..ecf9947 --- /dev/null +++ b/tutorials/00_overview/8.rag_tool_integration.ipynb @@ -0,0 +1,731 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": { + "id": "a1b2c3d4" + }, + "source": [ + "# SDialog Tutorial: Integrating a RAG System via Tool Calling\n", + "\n", + "

\n", + " \n", + " \"Open\n", + " \n", + "

\n", + "\n", + "A step-by-step guide to connecting an external knowledge base (RAG) to an SDialog agent using the native tool system." + ] + }, + { + "cell_type": "markdown", + "id": "b2c3d4e5", + "metadata": { + "id": "b2c3d4e5" + }, + "source": [ + "## Introduction\n", + "\n", + "LLMs generate responses from knowledge encoded during pre-training. This works well for general tasks, but it becomes insufficient when the agent needs to answer questions about specific, up-to-date, or domain-restricted information — such as university course catalogues, internal procedures, or product databases.\n", + "\n", + "**Retrieval-Augmented Generation (RAG)** addresses this by connecting the model to an external document index at inference time. The model first retrieves a small set of relevant text snippets, then uses those snippets as context to produce a grounded response.\n", + "\n", + "This tutorial shows how to integrate a RAG backend with an SDialog agent using SDialog's native tool system. The example domain is a **university counselling chatbot** that answers questions about Italian universities and courses.\n", + "\n", + "What you will learn:\n", + "- How SDialog's tool system works and what requirements a tool function must meet\n", + "- How to write a retrieval tool that calls an external search endpoint\n", + "- Why the function name and docstring are critical for correct LLM behaviour\n", + "- How to build and run a `Counsellor` agent that retrieves information autonomously\n" + ] + }, + { + "cell_type": "markdown", + "id": "c3d4e5f6", + "metadata": { + "id": "c3d4e5f6" + }, + "source": [ + "---\n", + "\n", + "## 1. Setup" + ] + }, + { + "cell_type": "markdown", + "id": "d4e5f6a7", + "metadata": { + "id": "d4e5f6a7" + }, + "source": [ + "### 1.1 Install dependencies" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5f6a7b8", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "e5f6a7b8", + "outputId": "223db691-7844-4d01-bec5-b20f68050afa" + }, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Running on Colab\n", + "Reading package lists... Done\n", + "Building dependency tree... Done\n", + "Reading state information... Done\n", + "zstd is already the newest version (1.4.8+dfsg-3build1).\n", + "0 upgraded, 0 newly installed, 0 to remove and 2 not upgraded.\n", + ">>> Cleaning up old version at /usr/local/lib/ollama\n", + ">>> Installing ollama to /usr/local\n", + ">>> Downloading ollama-linux-amd64.tar.zst\n", + "######################################################################## 100.0%\n", + ">>> Adding ollama user to video group...\n", + ">>> Adding current user to ollama group...\n", + ">>> Creating ollama systemd service...\n", + "\u001b[1m\u001b[31mWARNING:\u001b[m systemd is not running\n", + "\u001b[1m\u001b[31mWARNING:\u001b[m Unable to detect NVIDIA/AMD GPU. Install lspci or lshw to automatically detect and install GPU dependencies.\n", + ">>> The Ollama API is now available at 127.0.0.1:11434.\n", + ">>> Install complete. Run \"ollama\" from the command line.\n", + "sdialog directory already exists, skipping clone.\n", + " Installing build dependencies ... \u001b[?25l\u001b[?25hdone\n", + " Checking if build backend supports build_editable ... \u001b[?25l\u001b[?25hdone\n", + " Getting requirements to build editable ... \u001b[?25l\u001b[?25hdone\n", + " Preparing editable metadata (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n", + " Building editable for sdialog (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n" + ] + } + ], + "source": [ + "import os\n", + "from IPython import get_ipython\n", + "\n", + "os.environ[\"PATH\"] = \"/usr/local/bin:\" + os.environ.get(\"PATH\", \"\")\n", + "\n", + "if \"google.colab\" in str(get_ipython()):\n", + " print(\"Running on Colab\")\n", + "\n", + " !apt-get install -y zstd\n", + " !curl -fsSL https://ollama.com/install.sh | sh\n", + "\n", + " if not os.path.isdir(\"/content/sdialog\"):\n", + " !git clone https://github.com/idiap/sdialog.git /content/sdialog\n", + " else:\n", + " print(\"sdialog directory already exists, skipping clone.\")\n", + "\n", + " !pip install -e /content/sdialog --config-settings editable_mode=compat -q\n", + "\n", + " # Fix: assicura che Python usi il sorgente corretto di sdialog\n", + " import sys\n", + " for key in list(sys.modules.keys()):\n", + " if \"sdialog\" in key:\n", + " del sys.modules[key]\n", + " if \"/content/sdialog/src\" not in sys.path:\n", + " sys.path.insert(0, \"/content/sdialog/src\")\n", + "\n", + "else:\n", + " print(\"Running in Jupyter Notebook\")\n", + " get_ipython().system = os.system" + ] + }, + { + "cell_type": "markdown", + "id": "f6a7b8c9", + "metadata": { + "id": "f6a7b8c9" + }, + "source": [ + "### 1.2 Start the Ollama server and download the model\n", + "\n", + "If you plan to use a local Ollama model, start the server in the background. If you prefer to use an OpenAI key instead, skip this cell and adjust the model string in Section 1.3." + ] + }, + { + "cell_type": "code", + "source": [ + "import requests, time, subprocess\n", + "\n", + "# Start the Ollama server in the background\n", + "subprocess.Popen(\n", + " [\"ollama\", \"serve\"],\n", + " stdout=subprocess.DEVNULL,\n", + " stderr=subprocess.DEVNULL,\n", + ")\n", + "\n", + "# Wait until the server is ready (up to 30 seconds)\n", + "for _ in range(30):\n", + " try:\n", + " requests.get(\"http://localhost:11434/api/tags\", timeout=2)\n", + " print(\"Ollama server is ready.\")\n", + " break\n", + " except requests.RequestException:\n", + " time.sleep(1)\n", + "else:\n", + " raise RuntimeError(\"Ollama server did not start in time.\")\n", + "\n", + "# Pull the model (skipped automatically if already present)\n", + "subprocess.run([\"ollama\", \"pull\", \"llama3.2:3b\"], check=True)\n", + "\n", + "# Verify the model is available\n", + "r = requests.get(\"http://localhost:11434/api/tags\", timeout=10)\n", + "models = [m[\"name\"] for m in r.json().get(\"models\", [])]\n", + "print(\"Available models:\", models)" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "kXyaNFE2oFkL", + "outputId": "f317a3e3-14c9-438e-f328-31a6e81883f5" + }, + "id": "kXyaNFE2oFkL", + "execution_count": null, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Ollama server is ready.\n", + "Available models: ['llama3.2:3b']\n" + ] + } + ] + }, + { + "cell_type": "markdown", + "id": "b8c9d0e1", + "metadata": { + "id": "b8c9d0e1" + }, + "source": [ + "### 1.3 Configure the LLM backend\n", + "\n", + "Set the global model used by SDialog. Any backend supported by SDialog can be used here, for example:\n", + "- `\"ollama:llama3.1:8b\"` — a small local model suitable for tool calling\n", + "- `\"openai:gpt-4.1\"` — an OpenAI model (requires `OPENAI_API_KEY` to be set)\n", + "\n", + "> **Note:** The model must support tool calling natively. Models that do not expose a function-calling interface will not invoke tools reliably. LLaMA 3.1 and later, Qwen 2.5 and later, and most OpenAI models all support it." + ] + }, + { + "cell_type": "markdown", + "id": "c0d1e2f3", + "metadata": { + "id": "c0d1e2f3" + }, + "source": [ + "---\n", + "\n", + "## 2. How SDialog's Tool System Works\n", + "\n", + "SDialog allows plain Python functions to be passed as tools via the `tools` parameter of the `Agent` constructor. Internally, SDialog wraps each function using LangChain's tool decorator, which makes the function available to the LLM as a callable action.\n", + "\n", + "There are two rules that every tool function must follow:\n", + "\n", + "1. **Do not pre-decorate** the function with `@tool`. SDialog applies the decorator itself. Adding it manually causes a conflict.\n", + "2. **Always include a docstring.** The LLM has no other way to understand what the function does, when to call it, and how to interpret its output. The docstring is not optional documentation — it is the function's interface to the model.\n", + "\n", + "The **function name** carries equal importance. It is the first thing the LLM reads when deciding which tool to invoke. A name like `search_italian_university_data` is self-explanatory; a name like `search_kb` or `retrieve` is not." + ] + }, + { + "cell_type": "markdown", + "id": "e1f2a3b4", + "metadata": { + "id": "e1f2a3b4" + }, + "source": [ + "---\n", + "\n", + "## 3. Setting Up the Retrieval Service\n", + "\n", + "The retrieval service is expected at:\n", + "\n", + "```\n", + "GET http:///search\n", + " ?index_name=rag_chunks\n", + " &query=\n", + " &top_k=\n", + "```\n", + "\n", + "and returns:\n", + "\n", + "```json\n", + "{\"documents\": [\"snippet 1\", \"snippet 2\", \"snippet 3\"]}\n", + "```\n", + "\n", + "Choose one of the two options below depending on your setup. **Only run one of the two sections** (3.1 or 3.2)." + ] + }, + { + "cell_type": "markdown", + "id": "f2a3b4c5", + "metadata": { + "id": "f2a3b4c5" + }, + "source": [ + "### Mock server\n", + "\n", + "The cell below starts a lightweight in-process HTTP server that mimics the retrieval service interface. It serves a small static knowledge base about Italian universities and runs entirely inside the Colab or Jupyter runtime — no external service required." + ] + }, + { + "cell_type": "code", + "source": [ + "import json\n", + "import threading\n", + "from http.server import BaseHTTPRequestHandler, HTTPServer\n", + "from urllib.parse import urlparse, parse_qs\n", + "\n", + "KNOWLEDGE_BASE = [\n", + " \"The University of Catania offers undergraduate programmes in Computer Science, Engineering, Medicine, and Law. Enrolment opens each year in September.\",\n", + " \"The University of Bologna (Alma Mater Studiorum) is the oldest university in the world, founded in 1088. It offers over 200 degree programmes across 11 schools.\",\n", + " \"The Politecnico di Milano ranks among the top technical universities in Europe and is particularly renowned for its Engineering and Architecture programmes.\",\n", + " \"Italian universities distinguish between a Laurea Triennale (3-year bachelor's degree) and a Laurea Magistrale (2-year master's degree).\",\n", + " \"The ECTS credit system is used across all Italian universities. A full academic year corresponds to 60 ECTS credits.\",\n", + " \"Students can apply for the DSU (Diritto allo Studio Universitario) scholarship, which covers tuition fees and provides a monthly allowance based on household income.\",\n", + " \"Erasmus+ exchanges are available at most Italian universities. Students typically spend one or two semesters at a partner institution abroad.\",\n", + " \"The University of Rome La Sapienza is the largest university in Italy and one of the largest in Europe, with over 100,000 enrolled students.\",\n", + " \"The Scuola Normale Superiore in Pisa is a highly selective institution offering advanced programmes in Sciences and Humanities.\",\n", + " \"Most Italian universities require a high school diploma (diploma di maturita) or an equivalent foreign qualification for undergraduate admission.\",\n", + "]\n", + "\n", + "\n", + "class MockRetrievalServer(HTTPServer):\n", + " allow_reuse_address = True\n", + "\n", + "\n", + "class MockRetrievalHandler(BaseHTTPRequestHandler):\n", + " def do_GET(self):\n", + " parsed = urlparse(self.path)\n", + " params = parse_qs(parsed.query)\n", + "\n", + " query = params.get(\"query\", [\"\"])[0].lower()\n", + " top_k = int(params.get(\"top_k\", [\"3\"])[0])\n", + "\n", + " scored = [\n", + " (sum(word in doc.lower() for word in query.split()), doc)\n", + " for doc in KNOWLEDGE_BASE\n", + " ]\n", + " scored.sort(key=lambda x: x[0], reverse=True)\n", + " top_docs = [doc for _, doc in scored[:top_k]]\n", + "\n", + " body = json.dumps({\"documents\": top_docs}).encode()\n", + " self.send_response(200)\n", + " self.send_header(\"Content-Type\", \"application/json\")\n", + " self.end_headers()\n", + " self.wfile.write(body)\n", + "\n", + " def log_message(self, format, *args):\n", + " pass # Suppress request logs\n", + "\n", + "\n", + "mock_server = MockRetrievalServer((\"localhost\", 7997), MockRetrievalHandler)\n", + "threading.Thread(target=mock_server.serve_forever, daemon=True).start()\n", + "\n", + "RETRIEVAL_ENDPOINT = \"http://localhost:7997/search\"\n", + "print(f\"Mock retrieval service started at {RETRIEVAL_ENDPOINT}\")" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 365 + }, + "id": "D6zTgO6VmBSD", + "outputId": "67ffac69-5c78-4b8d-ba0c-b6164804f79c" + }, + "id": "D6zTgO6VmBSD", + "execution_count": null, + "outputs": [ + { + "output_type": "error", + "ename": "OSError", + "evalue": "[Errno 98] Address already in use", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mOSError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m/tmp/ipykernel_14350/4151424727.py\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 47\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 48\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 49\u001b[0;31m \u001b[0mmock_server\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mMockRetrievalServer\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"localhost\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m7997\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mMockRetrievalHandler\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 50\u001b[0m \u001b[0mthreading\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mThread\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtarget\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mmock_server\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mserve_forever\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdaemon\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mstart\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 51\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m/usr/lib/python3.12/socketserver.py\u001b[0m in \u001b[0;36m__init__\u001b[0;34m(self, server_address, RequestHandlerClass, bind_and_activate)\u001b[0m\n\u001b[1;32m 455\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mbind_and_activate\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 456\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 457\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mserver_bind\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 458\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mserver_activate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 459\u001b[0m \u001b[0;32mexcept\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m/usr/lib/python3.12/http/server.py\u001b[0m in \u001b[0;36mserver_bind\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 138\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mserver_bind\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 139\u001b[0m \u001b[0;34m\"\"\"Override server_bind to store the server name.\"\"\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 140\u001b[0;31m \u001b[0msocketserver\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mTCPServer\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mserver_bind\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 141\u001b[0m \u001b[0mhost\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mport\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mserver_address\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;36m2\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 142\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mserver_name\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msocket\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mgetfqdn\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mhost\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m/usr/lib/python3.12/socketserver.py\u001b[0m in \u001b[0;36mserver_bind\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 476\u001b[0m ):\n\u001b[1;32m 477\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msocket\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msetsockopt\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0msocket\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mSOL_SOCKET\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0msocket\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mSO_REUSEPORT\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m1\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 478\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msocket\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mbind\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mserver_address\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 479\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mserver_address\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msocket\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mgetsockname\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 480\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mOSError\u001b[0m: [Errno 98] Address already in use" + ] + } + ] + }, + { + "cell_type": "markdown", + "id": "f2a3b4c6", + "metadata": { + "id": "f2a3b4c6" + }, + "source": [ + "### 3.2 Alternative: use a real service via ngrok (optional, advanced)\n", + "\n", + "> **Skip this section if you ran the mockup.**\n", + "\n", + "If you have the retrieval service already running on your local machine and want to connect it to a Colab session, you can expose it over the internet using [ngrok](https://ngrok.com). ngrok creates a public HTTPS tunnel to a port on your local machine.\n", + "\n", + "**Prerequisites:**\n", + "- The retrieval service is running locally on port `7997`.\n", + "- You have a free ngrok account and your authtoken (available at https://dashboard.ngrok.com/get-started/your-authtoken).\n", + "\n", + "**Step 1 — on your local machine**, open a terminal and run:\n", + "\n", + "```bash\n", + "# Install ngrok if not already installed\n", + "# macOS: brew install ngrok\n", + "# Linux: snap install ngrok\n", + "# or download from https://ngrok.com/download\n", + "\n", + "ngrok config add-authtoken YOUR_AUTHTOKEN_HERE\n", + "ngrok http 7997\n", + "```\n", + "\n", + "ngrok will display a forwarding URL such as:\n", + "\n", + "```\n", + "Forwarding https://abc123.ngrok-free.app -> http://localhost:7997\n", + "```\n", + "\n", + "**Step 2 — in this notebook**, paste that URL into the cell below and run it:\n", + "\n", + "Replace with the forwarding URL shown by ngrok\n", + "NGROK_URL = \"https://abc123.ngrok-free.app\"\n", + "\n", + "RETRIEVAL_ENDPOINT = f\"{NGROK_URL}/search\"\n", + "print(f\"Retrieval endpoint set to: {RETRIEVAL_ENDPOINT}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b4c5d6e7", + "metadata": { + "id": "b4c5d6e7" + }, + "source": [ + "### 3.3 Verify the service\n", + "\n", + "Whichever option you chose, run this cell to confirm that the retrieval service responds correctly before proceeding." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5d6e7f8", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "c5d6e7f8", + "outputId": "c12b8b4f-5c42-46c5-bb6e-bea37e5e5291" + }, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "{\n", + " \"documents\": [\n", + " \"Students can apply for the DSU (Diritto allo Studio Universitario) scholarship, which covers tuition fees and provides a monthly allowance based on household income.\",\n", + " \"The University of Catania offers undergraduate programmes in Computer Science, Engineering, Medicine, and Law. Enrolment opens each year in September.\"\n", + " ]\n", + "}\n" + ] + } + ], + "source": [ + "import requests\n", + "\n", + "response = requests.get(\n", + " RETRIEVAL_ENDPOINT,\n", + " params={\"index_name\": \"rag_chunks\", \"query\": \"scholarship\", \"top_k\": 2}\n", + ")\n", + "print(json.dumps(response.json(), indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "d6e7f8a9", + "metadata": { + "id": "d6e7f8a9" + }, + "source": [ + "---\n", + "\n", + "## 4. Defining the Retrieval Tool\n", + "\n", + "The tool function wraps the HTTP call to the retrieval service and normalises the response into a flat dictionary that the LLM can easily reference in its reply.\n", + "\n", + "Two design choices are worth noting before reading the code:\n", + "\n", + "- **`docs_k` is typed as `str`**, not `int`. LLMs generate tool arguments as text and may pass numeric values as strings even when the annotation says otherwise. Accepting a string and converting internally with `int(docs_k)` prevents type errors across different model backends. Exposing this parameter at all lets the model adjust retrieval depth to the complexity of the question.\n", + "- **Errors return a dict, not an exception.** If the HTTP call fails, the function returns `{\"error\": \"...\"}` rather than raising. This keeps the return type consistent and allows the agent to acknowledge the failure in natural language rather than crashing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7f8a9b0", + "metadata": { + "id": "e7f8a9b0" + }, + "outputs": [], + "source": [ + "import requests\n", + "\n", + "\n", + "def search_italian_university_data(query: str, docs_k: str = \"3\") -> dict:\n", + " \"\"\"\n", + " Search the university counselling knowledge base for guidelines\n", + " and information relevant to the student's situation.\n", + "\n", + " Use this tool whenever the conversation requires information about:\n", + " - Universities and Departments in the Italian territory\n", + " - Available resources (offices, contacts, scheduling)\n", + "\n", + " Args:\n", + " query (str):\n", + " A short, self-contained question or keyword phrase describing\n", + " what information is needed.\n", + " docs_k (str):\n", + " Number of document snippets to retrieve (default: \"3\").\n", + "\n", + " Returns:\n", + " dict: A dictionary where each key is \"doc1\", \"doc2\", ... \"docN\" and\n", + " the corresponding value is the text of that retrieved snippet.\n", + " Returns {\"error\": \"\"} if the retrieval service fails.\n", + " \"\"\"\n", + " try:\n", + " top_k = int(docs_k)\n", + " except (ValueError, TypeError):\n", + " top_k = 3\n", + "\n", + " payload = {\n", + " \"index_name\": \"rag_chunks\",\n", + " \"query\": query,\n", + " \"top_k\": top_k,\n", + " }\n", + "\n", + " try:\n", + " response = requests.get(RETRIEVAL_ENDPOINT, params=payload, timeout=10)\n", + " response.raise_for_status()\n", + " results = response.json()\n", + " except requests.RequestException as exc:\n", + " return {\"error\": str(exc)}\n", + "\n", + " if isinstance(results, dict) and \"documents\" in results:\n", + " items = results[\"documents\"]\n", + " else:\n", + " items = results\n", + "\n", + " snippets = [item if isinstance(item, str) else item.get(\"text\", \"\") for item in items]\n", + " return {f\"doc{i + 1}\": text for i, text in enumerate(snippets)}" + ] + }, + { + "cell_type": "markdown", + "id": "f8a9b0c1", + "metadata": { + "id": "f8a9b0c1" + }, + "source": [ + "Verify the tool returns the expected format:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9b0c1d2", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "a9b0c1d2", + "outputId": "9c79d4ad-cd66-4afd-ac85-a3a02cbfc960" + }, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "doc1: The University of Catania offers undergraduate programmes in Computer Science, Engineering, Medicine, and Law. Enrolment opens each year in September.\n", + "doc2: The Politecnico di Milano ranks among the top technical universities in Europe and is particularly renowned for its Engineering and Architecture programmes.\n" + ] + } + ], + "source": [ + "result = search_italian_university_data(\"engineering programmes\", docs_k=\"2\")\n", + "for key, value in result.items():\n", + " print(f\"{key}: {value}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b0c1d2e3", + "metadata": { + "id": "b0c1d2e3" + }, + "source": [ + "---\n", + "\n", + "## 5. Building the Counsellor Agent\n", + "\n", + "### 5.1 Define the persona" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1d2e3f4", + "metadata": { + "id": "c1d2e3f4" + }, + "outputs": [], + "source": [ + "from sdialog.agents import Agent, BasePersona\n", + "\n", + "\n", + "class Counsellor(BasePersona):\n", + " \"\"\"University Counsellor\"\"\"\n", + " name: str = \"Counsellor\"\n", + "\n", + " def prompt(self) -> str:\n", + " return (\n", + " \"You are a university counsellor helping students choose their courses \"\n", + " \"and understand the Italian university system. \"\n", + " \"Always answer in English. \"\n", + " \"When you need factual information about universities or departments, \"\n", + " \"use the available search tool before answering.\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "d2e3f4a5", + "metadata": { + "id": "d2e3f4a5" + }, + "source": [ + "### 5.2 Instantiate the agent\n", + "\n", + "The tool function is passed via the `tools` parameter. SDialog wraps it internally — no `@tool` decorator is needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3f4a5b6", + "metadata": { + "id": "e3f4a5b6" + }, + "outputs": [], + "source": [ + "agent = Agent(\n", + " persona=Counsellor(),\n", + " model=\"openai:llama3.2:3b\",\n", + " openai_api_base=\"http://localhost:11434/v1\",\n", + " openai_api_key=\"EMPTY\",\n", + " tools=[search_italian_university_data],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "f4a5b6c7", + "metadata": { + "id": "f4a5b6c7" + }, + "source": [ + "---\n", + "\n", + "## 6. Running the Agent\n", + "\n", + "### 6.1 Single-turn query\n", + "\n", + "Call the agent with a single question. The agent will decide autonomously whether to invoke the retrieval tool." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a5b6c7d8", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "a5b6c7d8", + "outputId": "2193b10e-0a92-4b69-e12e-52de40adedcc" + }, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "At the Politecnico di Milano, there are many engineering programs available, including Aerospace Engineering, Biomedical Engineering, Civil Engineering, Computer Engineering, Electrical Engineering, Environmental Engineering, Industrial Engineering, Mechatronics Engineering, Nuclear Engineering, and more.\n" + ] + } + ], + "source": [ + "reply = agent(\"What engineering programmes are available at the Politecnico di Milano?\")\n", + "print(reply)" + ] + }, + { + "cell_type": "markdown", + "id": "f0a1b2c3", + "metadata": { + "id": "f0a1b2c3" + }, + "source": [ + "---\n", + "\n", + "## 7. Reusability Guidelines\n", + "\n", + "The pattern shown in this tutorial can be adapted to any agent or RAG context. The following points are worth bearing in mind when doing so.\n", + "\n", + "**Function name.** The name should reflect the specific domain of the knowledge base. `search_italian_university_data` is unambiguous; `search_kb` is not. The model reads the name before anything else when deciding which tool to invoke.\n", + "\n", + "**Docstring structure.** Include three components: (1) a one-sentence description of what the tool does; (2) an explicit instruction of *when* to use it (`Use this tool whenever...`); and (3) a description of each parameter and the return format. Without the second component in particular, the model will either over-invoke the tool on irrelevant turns or fail to invoke it when retrieval would genuinely help.\n", + "\n", + "**Return format.** Keep it flat and consistent. A dictionary of numbered string values (`{\"doc1\": \"...\", \"doc2\": \"...\"}`) is straightforward for the model to reference in its reply. Nested structures or raw lists increase the risk of the model misreading the retrieved content.\n", + "\n", + "**Numeric parameters.** Type any numeric parameter as `str` with internal conversion. This prevents type errors when the model passes values inconsistently across different LLM backends.\n", + "\n", + "**Error handling.** Return an error key rather than raising an exception. This keeps the return type consistent and allows the agent to recover gracefully in the dialogue." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbformat_minor": 5, + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "colab": { + "provenance": [] + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file