From 8eb050dfb31b62955ebdba10d526f30b6ee7001d Mon Sep 17 00:00:00 2001 From: projectmoon Date: Thu, 19 Sep 2024 20:58:31 +0000 Subject: [PATCH] OSM: better single place lookups --- CHANGELOG.md | 10 +++ osm.py | 239 +++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 232 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31af9f3..e437192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # OpenStreetMap Tool +**0.6.0:** + - Dramatically improved single location lookup (e.g. asking "where is + Landmark/City?") + - Add a catch-all handler that the model can pick if the user wants + to searh for something not added to the tool. The function will + force the LLM to tell the user what the OSM tool can find. + - Added more findable POIs: Hardware stores/home improvement centers, + electrical and lighting stores, consumer electronics stores, and + healthcare (doctors' offices, hospitals, health stores). + **0.5.1:** - Remove instruction to prioritize nodes over ways in search results. diff --git a/osm.py b/osm.py index a4318b7..13260f8 100644 --- a/osm.py +++ b/osm.py @@ -2,7 +2,7 @@ title: OpenStreetMap Tool author: projectmoon author_url: https://git.agnos.is/projectmoon/open-webui-filters -version: 0.5.1 +version: 0.6.0 license: AGPL-3.0+ required_open_webui_version: 0.3.21 """ @@ -39,6 +39,16 @@ OSM_LINK_INSTRUCTIONS = ( f"{EXAMPLE_OSM_LINK}\n\n" ) +def specific_place_instructions() -> str: + return ( + "# Result Instructions\n" + "These are search results ordered by relevance for the " + "address, place, landmark, or location the user is asking " + "about. **IMPORTANT!:** Tell the user all relevant information, " + "including address, contact information, and the OpenStreetMap link. " + "Make the map link nice human-readable markdown link." + ) + def detailed_instructions(tag_type_str: str) -> str: """ Produce detailed instructions for models good at following @@ -178,7 +188,51 @@ def all_are_none(*args) -> bool: return True -def parse_thing_address(tags: dict) -> Optional[str]: +def friendly_shop_name(shop_type: str) -> str: + """ + Make certain shop types more friendly for LLM interpretation. + """ + if shop_type == "doityourself": + return "home_improvement_center" + else: + return shop_type + +def parse_thing_address(thing: dict) -> Optional[str]: + """ + Parse address from either an Overpass result or Nominatim + result. + """ + if 'address' in thing: + # nominatim result + return parse_address_from_address_obj(thing['address']) + else: + return parse_address_from_tags(thing['tags']) + +def parse_address_from_address_obj(address) -> Optional[str]: + """Parse address from Nominatim address object.""" + house_number = get_or_none(address, "house_number") + street = get_or_none(address, "road") + city = get_or_none(address, "city") + state = get_or_none(address, "state") + postal_code = get_or_none(address, "postcode") + + # if all are none, that means we don't know the address at all. + if all_are_none(house_number, street, city, state, postal_code): + return None + + # Handle missing values to create complete-ish addresses, even if + # we have missing data. We will get either a partly complete + # address, or None if all the values are missing. + line1 = filter(None, [street, house_number]) + line2 = filter(None, [city, state, postal_code]) + line1 = " ".join(line1).strip() + line2 = " ".join(line2).strip() + full_address = filter(None, [line1, line2]) + full_address = ", ".join(full_address).strip() + return full_address if len(full_address) > 0 else None + +def parse_address_from_tags(tags: dict) -> Optional[str]: + """Parse address from Overpass tags object.""" house_number = get_or_none(tags, "addr:housenumber", "addr:house_number") street = get_or_none(tags, "addr:street") city = get_or_none(tags, "addr:city") @@ -204,6 +258,23 @@ def parse_thing_address(tags: dict) -> Optional[str]: full_address = ", ".join(full_address).strip() return full_address if len(full_address) > 0 else None +def parse_thing_amenity_type(thing: dict, tags: dict) -> Optional[dict]: + """ + Extract amenity type or other identifying category from + Nominatim or Overpass result object. + """ + if 'amenity' in tags: + return tags['amenity'] + + if thing.get('class') == 'amenity': + return thing.get('type') + + # fall back to tag categories, like shop=* + if 'shop' in tags: + return friendly_shop_name(tags['shop']) + + return None + def parse_and_validate_thing(thing: dict) -> Optional[dict]: """ Parse an OSM result (node or post-processed way) and make it @@ -215,19 +286,23 @@ def parse_and_validate_thing(thing: dict) -> Optional[dict]: # Currently we define "enough data" as at least having lat, lon, # and name. - if 'lat' not in thing or 'lon' not in thing or 'name' not in tags: + has_name = 'name' in tags or 'name' in thing + if 'lat' not in thing or 'lon' not in thing or not has_name: return None friendly_thing = {} - address: string = parse_thing_address(tags) + name: str = (tags['name'] if 'name' in tags + else thing['name'] if 'name' in thing + else str(thing['id']) if 'id' in thing + else str(thing['osm_id']) if 'osm_id' in thing + else "unknown") + + address: string = parse_thing_address(thing) distance: Optional[float] = thing['distance'] if 'distance' in thing else None - name: str = tags['name'] if 'name' in tags else str(thing['id']) + lat: Optional[float] = thing['lat'] if 'lat' in thing else None lon: Optional[float] = thing['lon'] if 'lon' in thing else None - - amenity_type: Optional[str] = ( - tags['amenity'] if 'amenity' in tags else None - ) + amenity_type: Optional[str] = parse_thing_amenity_type(thing, tags) friendly_thing['name'] = name if name else "unknown" friendly_thing['distance'] = "{:.3f}".format(distance) if distance else "unknown" @@ -242,13 +317,16 @@ def create_osm_link(lat, lon): def convert_and_validate_results( original_location: str, - things_nearby: List[dict] + things_nearby: List[dict], + sort_message: str="closeness", + use_distance: bool=True ) -> Optional[str]: """ Converts the things_nearby JSON into Markdown-ish results to (hopefully) improve model understanding of the results. Intended to stop misinterpretation of GPS coordinates when creating map - links. Also drops incomplete results. + links. Also drops incomplete results. Supports Overpass and + Nominatim results. """ entries = [] for thing in things_nearby: @@ -259,13 +337,15 @@ def convert_and_validate_results( if not friendly_thing: continue + distance = (f" - Distance from Origin: {friendly_thing['distance']} km\n" + if use_distance else "") map_link = create_osm_link(friendly_thing['lat'], friendly_thing['lon']) entry = (f"## {friendly_thing['name']}\n" f" - Latitude: {friendly_thing['lat']}\n" f" - Longitude: {friendly_thing['lon']}\n" f" - Address: {friendly_thing['address']}\n" f" - Amenity Type: {friendly_thing['amenity_type']}\n" - f" - Distance from Origin: {friendly_thing['distance']} km\n" + f"{distance}" f" - OpenStreetMap link: {map_link}\n\n" f"Raw JSON data:\n" "```json\n" @@ -279,7 +359,7 @@ def convert_and_validate_results( result_text = "\n\n".join(entries) header = ("# Search Results\n" - f"Ordered by closeness to {original_location}.") + f"Ordered by {sort_message} to {original_location}.") return f"{header}\n\n{result_text}" @@ -531,17 +611,27 @@ class Tools: self.valves = self.Valves() self.user_valves = None - def lookup_location(self, address_or_place: str) -> str: + def find_specific_place(self, address_or_place: str) -> str: """ - Looks up GPS and address details on OpenStreetMap of a given address or place. + Looks up details on OpenStreetMap of a specific address, landmark, + place, named building, or location. Used for when the user asks where + a specific unique entity (like a specific museum, or church, or shopping + center) is. :param address_or_place: The address or place to look up. - :return: Address details, if found. None if there's an error. + :return: Information about the place, if found. """ searcher = OsmSearcher(self.valves, self.user_valves) try: result = searcher.nominatim_search(address_or_place, limit=5) if result: - return str(result) + results_in_md = convert_and_validate_results( + address_or_place, result, + sort_message="importance", use_distance=False + ) + + resp = f"{specific_place_instructions()}\n\n{results_in_md}" + print(resp) + return resp else: return NO_RESULTS except Exception as e: @@ -750,3 +840,118 @@ class Tools: user_valves = __user__["valves"] if "valves" in __user__ else None tags = ["amenity=car_rental", "car:rental=yes", "rental=car", "car_rental=yes"] return do_osm_search(self.valves, user_valves, place, tags, radius=6000) + + def find_hardware_store_near_place(self, __user__: dict, place: str) -> str: + """ + Finds hardware stores, home improvement stores, and DIY stores + near given a place or address. + :param place: The name of a place or an address. City and country must be specified, if known. + :return: A list of nearby hardware/DIY stores, if found. + """ + user_valves = __user__["valves"] if "valves" in __user__ else None + tags = ["shop=doityourself", "shop=hardware", "shop=power_tools", + "shop=groundskeeping", "shop=trade"] + return do_osm_search(self.valves, user_valves, place, tags) + + def find_electrical_store_near_place(self, __user__: dict, place: str) -> str: + """ + Finds electrical stores and lighting stores near a given place + or address. These are stores that sell lighting and electrical + equipment like wires, sockets, and so forth. + :param place: The name of a place or an address. City and country must be specified, if known. + :return: A list of nearby electrical/lighting stores, if found. + """ + user_valves = __user__["valves"] if "valves" in __user__ else None + tags = ["shop=lighting", "shop=electrical"] + return do_osm_search(self.valves, user_valves, place, tags) + + def find_electronics_store_near_place(self, __user__: dict, place: str) -> str: + """ + Finds consumer electronics stores near a given place or address. + These stores sell computers, cell phones, video games, and so on. + :param place: The name of a place or an address. City and country must be specified, if known. + :return: A list of nearby electronics stores, if found. + """ + user_valves = __user__["valves"] if "valves" in __user__ else None + tags = ["shop=electronics"] + return do_osm_search(self.valves, user_valves, place, tags) + + def find_doctor_near_place(self, __user__: dict, place: str) -> str: + """ + Finds doctors near a given place or address. + :param place: The name of a place or an address. City and country must be specified, if known. + :return: A list of nearby electronics stores, if found. + """ + user_valves = __user__["valves"] if "valves" in __user__ else None + tags = ["amenity=clinic", "amenity=doctors", "healthcare=doctor"] + return do_osm_search(self.valves, user_valves, place, tags) + + def find_hospital_near_place(self, __user__: dict, place: str) -> str: + """ + Finds doctors near a given place or address. + :param place: The name of a place or an address. City and country must be specified, if known. + :return: A list of nearby electronics stores, if found. + """ + user_valves = __user__["valves"] if "valves" in __user__ else None + tags = ["healthcare=hospital", "amenity=hospital"] + return do_osm_search(self.valves, user_valves, place, tags) + + def find_pharmacy_near_place(self, __user__: dict, place: str) -> str: + """ + Finds pharmacies and health shops near a given place or address + :param place: The name of a place or an address. City and country must be specified, if known. + :return: A list of nearby electronics stores, if found. + """ + user_valves = __user__["valves"] if "valves" in __user__ else None + tags = ["amenity=pharmacy", "shop=chemist", "shop=supplements", + "shop=health_food"] + return do_osm_search(self.valves, user_valves, place, tags, radius=6000) + + # This function exists to help catch situations where the user is + # too generic in their query, or is looking for something the tool + # does not yet support. By having the model pick this function, we + # can direct it to report its capabilities and tell the user how + # to use it. It's not perfect, but it works sometimes. + def find_other_things_near_place( + self, + __user__: dict, + place: str, + category: str + ) -> str: + """ + Find shops and other places not covered by a specific + category available in the other functions. Use this if the + user is asking for a type of store or place that other + functions do not support. + + :param place: The name of a place or an address. City and country must be specified, if known. + :param category: The category of place, shop, etc to look up. + :return: A list of nearby shops. + + """ + print(f"Generic catch handler called with {category}") + resp = ( + "# No Results Found\n" + f"No results were found. There was an error. Finding {category} points of interest " + "is not yet supported. Tell the user support will come eventually! " + "Tell the user that you are only capable of finding specific " + "categories of stores, amenities, and points of interest:\n" + " - Car rentals and bike rentals\n" + " - Public transport, libraries\n" + " - Education institutions (schools and universities)\n" + " - Grocery stores, supermarkets, convenience stores\n" + " - Food and restaurants\n" + " - Accommodation\n" + " - Places of Worship\n" + " - Hardware stores and home improvement centers\n" + " - Electrical and lighting stores\n" + " - Consumer electronics stores\n" + " - Healthcare (doctors, hospitals, pharmacies, and health stores)\n" + " - Various recreational and leisure activities\n\n" + "Only mention things from the above list that you think the user " + "will be interested in, given the conversation so far. Don't mention " + "things not on the list. " + "**IMPORTANT**: Tell the user to be specific in their " + "query in their next message, so you can call the right function!") + print(resp) + return resp