diff --git a/example/assets/js/App.tsx b/example/assets/js/App.tsx index 904c47e..7bd7044 100644 --- a/example/assets/js/App.tsx +++ b/example/assets/js/App.tsx @@ -3,7 +3,6 @@ import "@mantine/notifications/styles.css"; import React, { useEffect, useState } from "react"; import { - Button, Container, createTheme, List, @@ -19,8 +18,9 @@ import { IconXboxX, IconMovie, IconChecklist, + IconPlane, } from "@tabler/icons-react"; -import { Chat } from "@/components"; +import { Chat, TourGuide } from "@/components"; import { createBrowserRouter, Link, RouterProvider } from "react-router-dom"; import { ApiError, @@ -75,7 +75,7 @@ const ExampleIndex = () => { message: ( <> You must be logged in to engage with the examples. Please{" "} - + log in {" "} to continue. @@ -139,6 +139,15 @@ const ExampleIndex = () => { > HTMX demo (no React) + + + + } + > + Tour Guide Assistant + ); @@ -198,6 +207,14 @@ const router = createBrowserRouter([ ), }, + { + path: "/tour-guide", + element: ( + + + + ), + }, { path: "/admin", element: ( diff --git a/example/assets/js/components/Chat/Chat.tsx b/example/assets/js/components/Chat/Chat.tsx index a871912..be6fa6c 100644 --- a/example/assets/js/components/Chat/Chat.tsx +++ b/example/assets/js/components/Chat/Chat.tsx @@ -1,7 +1,6 @@ import { ActionIcon, Avatar, - Box, Button, Container, Group, @@ -31,6 +30,7 @@ import { useMessageList, useThreadList, } from "django-ai-assistant-client"; +import { Link } from "react-router-dom"; function ChatMessage({ message, @@ -175,8 +175,15 @@ export function Chat({ assistantId }: { assistantId: string }) { notifications.show({ title: "Login Required", - message: - "You must be logged in to engage with the examples. Please log in to continue.", + message: ( + <> + You must be logged in to engage with the examples. Please{" "} + + log in + {" "} + to continue. + + ), color: "red", autoClose: 5000, withCloseButton: true, diff --git a/example/assets/js/components/TourGuide/TourGuide.tsx b/example/assets/js/components/TourGuide/TourGuide.tsx new file mode 100644 index 0000000..1baecc1 --- /dev/null +++ b/example/assets/js/components/TourGuide/TourGuide.tsx @@ -0,0 +1,112 @@ +import "@mantine/core/styles.css"; +import { + Container, + TextInput, + Button, + LoadingOverlay, + Group, +} from "@mantine/core"; +import { useEffect, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { Link } from "react-router-dom"; + +export function TourGuide() { + const [showLoginNotification, setShowLoginNotification] = + useState(false); + const [latitude, setLatitude] = useState(""); + const [longitude, setLongitude] = useState(""); + const [attractions, setAttractions] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + navigator.geolocation.getCurrentPosition( + (position: any) => { + setLatitude(position.coords.latitude); + setLongitude(position.coords.longitude); + }, + (error) => console.log(error) + ); + }, []); + + async function findAttractions() { + if (!latitude || !longitude) { + return; + } + + setLoading(true); + const response = await fetch(`/tour-guide/?coordinate=${latitude},${longitude}`); + const data = await response.json(); + if (data.error) { + setShowLoginNotification(true); + } else { + setAttractions(data.nearby_attractions); + } + setLoading(false) + } + + useEffect(() => { + if (!showLoginNotification) return; + + notifications.show({ + title: "Login Required", + message: ( + <> + You must be logged in to engage with the examples. Please{" "} + + log in + {" "} + to continue. + + ), + color: "red", + autoClose: 5000, + withCloseButton: true, + }); + }, [showLoginNotification]); + + return ( + + + + setLatitude(e.target.value)} + /> + setLongitude(e.target.value)} + /> + + + {loading ?

Loading

: null} +
+ {attractions.map((item, i) => ( +
+

+ {item.attraction_url ? ( + + {item.attraction_name} + + ) : ( + item.attraction_name + )} +

+ {item.attraction_description} + +
+ ))} +
+
+ ); +} diff --git a/example/assets/js/components/index.ts b/example/assets/js/components/index.ts index 86bd4fa..c50b84e 100644 --- a/example/assets/js/components/index.ts +++ b/example/assets/js/components/index.ts @@ -1,2 +1,3 @@ export { ThreadsNav } from "./ThreadsNav/ThreadsNav"; export { Chat } from "./Chat/Chat"; +export { TourGuide } from "./TourGuide/TourGuide"; diff --git a/example/demo/urls.py b/example/demo/urls.py index fed2660..20eaedc 100644 --- a/example/demo/urls.py +++ b/example/demo/urls.py @@ -11,6 +11,11 @@ views.AIAssistantChatThreadView.as_view(), name="chat_thread", ), + path( + "tour-guide/", + views.TourGuideAssistantView.as_view(), + name="tour_guide", + ), # Catch all for react app: path("", views.react_index, {"resource": ""}), path("", views.react_index), diff --git a/example/demo/views.py b/example/demo/views.py index 053b821..74c2a1c 100644 --- a/example/demo/views.py +++ b/example/demo/views.py @@ -1,8 +1,13 @@ +import json + from django.contrib import messages +from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.views import View from django.views.generic.base import TemplateView from pydantic import ValidationError +from tour_guide.ai_assistants import TourGuideAIAssistant from weather.ai_assistants import WeatherAIAssistant from django_ai_assistant.api.schemas import ( @@ -102,3 +107,19 @@ def post(self, request, *args, **kwargs): request=request, ) return redirect("chat_thread", thread_id=thread_id) + + +class TourGuideAssistantView(View): + def get(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return JsonResponse({"error": "You must be logged in to use this feature."}, status=401) + + coordinates = request.GET.get("coordinate") + + if not coordinates: + return JsonResponse({}) + + a = TourGuideAIAssistant() + data = a.run(f"My coordinates are: ({coordinates})") + + return JsonResponse(json.loads(data)) diff --git a/example/example/settings.py b/example/example/settings.py index fc08b26..dadb501 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -34,6 +34,7 @@ "movies", "rag", "issue_tracker", + "tour_guide", ] MIDDLEWARE = [ diff --git a/example/pnpm-lock.yaml b/example/pnpm-lock.yaml index d390fd4..c5518f5 100644 --- a/example/pnpm-lock.yaml +++ b/example/pnpm-lock.yaml @@ -1361,8 +1361,8 @@ packages: peerDependencies: postcss: ^8.1.0 - axios@1.7.2: - resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==} + axios@1.7.5: + resolution: {integrity: sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==} babel-loader@9.1.3: resolution: {integrity: sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw==} @@ -4944,7 +4944,7 @@ snapshots: postcss: 8.4.38 postcss-value-parser: 4.2.0 - axios@1.7.2: + axios@1.7.5: dependencies: follow-redirects: 1.15.6 form-data: 4.0.0 @@ -5253,7 +5253,7 @@ snapshots: django-ai-assistant-client@0.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - axios: 1.7.2 + axios: 1.7.5 cookie: 0.6.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) diff --git a/example/tour_guide/__init__.py b/example/tour_guide/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/tour_guide/ai_assistants.py b/example/tour_guide/ai_assistants.py new file mode 100644 index 0000000..51297ed --- /dev/null +++ b/example/tour_guide/ai_assistants.py @@ -0,0 +1,70 @@ +import json + +from django.utils import timezone + +from django_ai_assistant import AIAssistant, method_tool +from tour_guide.integrations import fetch_points_of_interest + + +def _tour_guide_example_json(): + return json.dumps( + { + "nearby_attractions": [ + { + "attraction_name": f"", + "attraction_description": f"", + "attraction_url": f"", + } + for i in range(1, 6) + ] + }, + indent=2, + ).translate( # Necessary due to ChatPromptTemplate + str.maketrans( + { + "{": "{{", + "}": "}}", + } + ) + ) + + +class TourGuideAIAssistant(AIAssistant): + id = "tour_guide_assistant" # noqa: A003 + name = "Tour Guide Assistant" + instructions = ( + "You are a tour guide assistant that offers information about nearby attractions. " + "The application will capture the user coordinates, and should provide a list of nearby attractions. " + "Use the available tools to suggest nearby attractions to the user. " + "You don't need to include all the found items, only include attractions that are relevant for a tourist. " + "Select the top 10 best attractions for a tourist, if there are less then 10 relevant items only return these. " + "Order items by the most relevant to the least relevant. " + "If there are no relevant attractions nearby, just keep the list empty. " + "Your response will be integrated with a frontend web application therefore it's critical that " + "it only contains a valid JSON. DON'T include '```json' in your response. " + "The JSON should be formatted according to the following structure: \n" + f"\n\n{_tour_guide_example_json()}\n\n\n" + "In the 'attraction_name' field provide the name of the attraction in english. " + "In the 'attraction_description' field generate an overview about the attraction with the most important information, " + "curiosities and interesting facts. " + "Only include a value for the 'attraction_url' field if you find a real value in the provided data otherwise keep it empty. " + ) + model = "gpt-4o" + + def get_instructions(self): + # Warning: this will use the server's timezone + # See: https://docs.djangoproject.com/en/5.0/topics/i18n/timezones/#default-time-zone-and-current-time-zone + # In a real application, you should use the user's timezone + current_date_str = timezone.now().date().isoformat() + + return f"Today is: {current_date_str}. {self.instructions}" + + @method_tool + def get_nearby_attractions_from_api(self, latitude: float, longitude: float) -> dict: + """Find nearby attractions based on user's current location.""" + return fetch_points_of_interest( + latitude=latitude, + longitude=longitude, + tags=["tourism", "leisure", "place", "building"], + radius=500, + ) diff --git a/example/tour_guide/apps.py b/example/tour_guide/apps.py new file mode 100644 index 0000000..60a8f92 --- /dev/null +++ b/example/tour_guide/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TourGuideConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "tour_guide" diff --git a/example/tour_guide/integrations.py b/example/tour_guide/integrations.py new file mode 100644 index 0000000..6357b8a --- /dev/null +++ b/example/tour_guide/integrations.py @@ -0,0 +1,45 @@ +from typing import List + +import requests + + +def fetch_points_of_interest( + latitude: float, longitude: float, tags: List[str], radius: int = 500 +) -> dict: + """ + Fetch points of interest from OpenStreetMap using Overpass API. + + :param latitude: Latitude of the center point. + :param longitude: Longitude of the center point. + :param radius: Radius in meters to search for POIs around the center point. + :param tags: A list of OpenStreetMap tags to filter the POIs (e.g., ["amenity", "tourism"]). + :return: A list of POIs with their details. + """ + # Base URL for the Overpass API + overpass_url = "http://overpass-api.de/api/interpreter" + + # Construct the Overpass QL (query language) query + pois_query = "".join( + [ + ( + f"node[{tag}](around:{radius},{latitude},{longitude});" + f"way[{tag}](around:{radius},{latitude},{longitude});" + ) + for tag in tags + ] + ) + + query = f""" + [out:json]; + ( + {pois_query} + ); + out tags; + """ + + response = requests.get(overpass_url, params={"data": query}, timeout=10) + + response.raise_for_status() + + data = response.json() + return data["elements"] diff --git a/example/tour_guide/migrations/__init__.py b/example/tour_guide/migrations/__init__.py new file mode 100644 index 0000000..e69de29