Spaces:
Runtime error
Runtime error
| """ | |
| Worcestershire Libraries - Live Assistant (Build Small Hackathon) | |
| A small-model (<=32B) civic agent that answers real questions about | |
| Worcestershire Libraries from official data at EVERY granularity: | |
| service pages -> every page of the Hive site -> catalogue items -> the | |
| individual copies on a branch's shelf. | |
| • Live tools - catalogue, mobile library, events, branch hours/facilities | |
| • where_to_get - the full "how do I actually get this title" journey: | |
| on-shelf copy at a named branch / free reservation / BorrowBox tonight | |
| • hive_info - The Hive (Worcester) page-by-page: archives & archaeology, | |
| 800+ study spaces, room hire, BIPC, Youth Hub, 8:30am–10pm every day | |
| • Knowledge graph (GraphRAG-style) - multi-hop "which late library has a café?" | |
| • Curated detail - exactly what you need to sign up for each service | |
| • Behaviour-change layer - EAST nudges + £-saved "value receipt" (DCMS-evidenced) | |
| • Open agent traces - every answer logs its reasoning to traces.jsonl | |
| Run: HF_TOKEN=... python app.py (works with no token in "no-LLM" mode too) | |
| Model: set MODEL_ID (default Qwen/Qwen2.5-7B-Instruct, <=32B). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import time | |
| import gradio as gr | |
| import library_sources as ls | |
| import graph_rag | |
| from trace import Trace | |
| # --------------------------------------------------------------------------- # | |
| # Model (<= 32 billion params) | |
| # --------------------------------------------------------------------------- # | |
| HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN") | |
| # Sponsor & achievement models - all reachable via HF Inference API with HF_TOKEN. | |
| # achievement:llama → Llama 3.1 8B (default) | |
| # sponsor:openbmb → MiniCPM 3 4B | |
| # sponsor:nvidia → NVIDIA accelerates Llama; Llama 3.2 3B shows the small-model thesis | |
| MODELS = [ | |
| ("Llama 3.1 8B · Meta", "meta-llama/Llama-3.1-8B-Instruct"), | |
| ("MiniCPM 3 4B · OpenBMB", "openbmb/MiniCPM3-4B"), | |
| ("Qwen 2.5 7B · Alibaba", "Qwen/Qwen2.5-7B-Instruct"), | |
| ("Llama 3.2 3B · Meta (smallest)", "meta-llama/Llama-3.2-3B-Instruct"), | |
| ] | |
| MODEL_LABELS = [label for label, _ in MODELS] | |
| MODEL_IDS = {label: mid for label, mid in MODELS} | |
| DEFAULT_LABEL = MODEL_LABELS[0] # Llama 3.1 8B | |
| MODEL_ID = os.environ.get("MODEL_ID", MODEL_IDS[DEFAULT_LABEL]) # env override | |
| _clients: dict = {} | |
| def get_client(model_id: str): | |
| if model_id not in _clients: | |
| from huggingface_hub import InferenceClient | |
| _clients[model_id] = InferenceClient(model=model_id, token=HF_TOKEN, timeout=60) | |
| return _clients[model_id] | |
| def llm(messages, *, model_id: str = None, max_tokens=512, temperature=0.3, stream=False): | |
| return get_client(model_id or MODEL_ID).chat_completion( | |
| messages, max_tokens=max_tokens, temperature=temperature, stream=stream) | |
| # --------------------------------------------------------------------------- # | |
| # Tools the agent can call | |
| # --------------------------------------------------------------------------- # | |
| TOOLS = { | |
| "search_catalogue": { | |
| "desc": "Browse/search what the library holds (book, eBook, audiobook, DVD). " | |
| "args: {\"query\": \"<title / author / subject>\"}", | |
| "fn": lambda a: ls.search_catalogue(a.get("query", ""), limit=6)}, | |
| "where_to_get": { | |
| "desc": "User wants to actually GET/borrow/obtain a specific title - gives " | |
| "the exact route: which branch has it on the shelf NOW (copy-level), " | |
| "free reservation steps, or borrow tonight on BorrowBox. " | |
| "args: {\"query\": \"<title / author>\"}", | |
| "fn": lambda a: ls.where_to_get(a.get("query") or a.get("title", ""))}, | |
| "hive_info": { | |
| "desc": "The Hive - Worcester's library (joint university+public): hours " | |
| "(8:30am-10pm daily), Explore the Past archives & archaeology, study " | |
| "spaces, room hire, café, getting there, children's library, business " | |
| "support. Page-level detail. args: {\"topic\": \"<optional topic>\"}", | |
| "fn": lambda a: ls.hive_info(a.get("topic") or a.get("query"))}, | |
| "whats_new": { | |
| "desc": "Newest titles in a genre, for fun recommendations. " | |
| "args: {\"genre\": \"<genre/topic>\"}", | |
| "fn": lambda a: ls.whats_new(a.get("genre") or a.get("query"))}, | |
| "find_library": { | |
| "desc": "A branch's opening hours ('open now?', 'open tomorrow?'), address " | |
| "and facilities (toilets, parking, café, study space). Include the " | |
| "day if the user names one. args: {\"name\": \"<branch>\", " | |
| "\"day\": \"today|tomorrow|<weekday>\"}", | |
| "fn": lambda a: ls.find_library(a.get("name") or a.get("query"), | |
| when=a.get("day") or a.get("when"))}, | |
| "mobile_library": { | |
| "desc": "When/where the mobile library van visits a village. " | |
| "args: {\"place\": \"<village>\"}", | |
| "fn": lambda a: ls.mobile_library(a.get("place") or a.get("query", ""))}, | |
| "library_events": { | |
| "desc": "Upcoming events, activities, clubs and sessions. " | |
| "args: {\"query\": \"<optional keyword/place>\"}", | |
| "fn": lambda a: ls.library_events(a.get("query") or None, limit=8)}, | |
| "online_hub": { | |
| "desc": "Free-from-home digital resources - eBooks (BorrowBox), newspapers/" | |
| "magazines (PressReader), family history (Ancestry). args: {\"topic\": \"\"}", | |
| "fn": lambda a: ls.online_hub(a.get("topic") or a.get("query"))}, | |
| "libraries_unlocked": { | |
| "desc": "Extended 8am-8pm self-service access and which branches have it. " | |
| "args: {\"branch\": \"<optional>\"}", | |
| "fn": lambda a: ls.libraries_unlocked(a.get("branch") or a.get("query"))}, | |
| "printing_help": { | |
| "desc": "How to print, photocopy or scan at the library, incl. Print Your Way " | |
| "from a phone + in-library walk-up pricing. args: {\"query\": \"<optional>\"}", | |
| "fn": lambda a: ls.printing_help(query=a.get("query"))}, | |
| "membership_help": { | |
| "desc": "What you need to sign up - digital vs full vs Libraries Unlocked. " | |
| "args: {\"service\": \"<optional>\"}", | |
| "fn": lambda a: ls.membership_help(a.get("service") or a.get("query"))}, | |
| "graph_search": { | |
| "desc": "Match a library by a COMBINATION of features, e.g. 'late-opening " | |
| "with a café and parking'. args: {\"query\": \"<the request>\"}", | |
| "fn": lambda a: graph_rag.graph_search(a.get("query", ""))}, | |
| "account_and_loans": { | |
| "desc": "Renew loans, check/pay fines, make reservations, access the online " | |
| "account, borrowing periods & limits, returning items, lost cards, " | |
| "PIN reset, job clubs, and the Library Service at Home (housebound). " | |
| "args: {\"query\": \"<optional topic>\"}", | |
| "fn": lambda a: ls.account_and_loans(a.get("query") or a.get("topic"))}, | |
| "children_services": { | |
| "desc": "Children's library activities - Storytime, Rhymetime, the Summer " | |
| "Reading Challenge, holiday events and children's books. args: {}", | |
| "fn": lambda a: ls.children_services()}, | |
| } | |
| ROUTER_SYSTEM = ( | |
| "You route a Worcestershire Libraries question to exactly one tool.\nTools:\n" | |
| + "\n".join(f"- {n}: {t['desc']}" for n, t in TOOLS.items()) | |
| + "\n- none: greeting / off-topic / general 'what can you do'.\n\n" | |
| "Key distinctions:\n" | |
| "- where_to_get = 'how/where can I GET a specific title' (copy availability + borrow path)\n" | |
| "- search_catalogue = 'do you HAVE books by/about X' (search, list results)\n" | |
| "- graph_search = multi-feature branch queries ('café AND late AND parking')\n" | |
| "- find_library = single-branch hours/facilities/open-now queries\n\n" | |
| "Examples:\n" | |
| "{\"tool\":\"where_to_get\",\"args\":{\"query\":\"Wolf Hall\"}} " | |
| "← 'Can I get Wolf Hall?' / 'How do I borrow Wolf Hall?'\n" | |
| "{\"tool\":\"search_catalogue\",\"args\":{\"query\":\"Jodi Picoult\"}} " | |
| "← 'Do you have books by Jodi Picoult?'\n" | |
| "{\"tool\":\"find_library\",\"args\":{\"name\":\"Bromsgrove\",\"when\":\"today\"}} " | |
| "← 'Is Bromsgrove library open today?'\n" | |
| "{\"tool\":\"graph_search\",\"args\":{\"query\":\"café late opening parking\"}} " | |
| "← 'A late library with a café and parking'\n" | |
| "{\"tool\":\"online_hub\",\"args\":{\"topic\":\"ebooks borrowbox\"}} " | |
| "← 'How do I borrow eBooks?' / 'What free apps are there?'\n" | |
| "{\"tool\":\"mobile_library\",\"args\":{\"place\":\"Pershore\"}} " | |
| "← 'When does the mobile van visit Pershore?'\n\n" | |
| "If conversation context is provided, resolve references like 'there', 'it' " | |
| "or 'that one' from it (e.g. 'what's on there?' after a Malvern answer -> " | |
| "library_events with Malvern).\n" | |
| "Reply with ONLY JSON: {\"tool\": \"<name>\", \"args\": {...}}. No prose." | |
| ) | |
| def _content_text(content): | |
| # Gradio 6 Chatbot content can be a list of blocks rather than a string. | |
| if isinstance(content, list): | |
| return " ".join(b.get("text", "") for b in content | |
| if isinstance(b, dict)).strip() | |
| return content or "" | |
| def _history_text(history, limit=4): | |
| lines = [] | |
| for m in (history or [])[-limit:]: | |
| c = _content_text(m.get("content")) | |
| if c: | |
| lines.append(f"{m.get('role', 'user')}: {c[:200]}") | |
| return "\n".join(lines) | |
| # --------------------------------------------------------------------------- # | |
| # Routing - LLM first, deterministic keyword fallback always available | |
| # --------------------------------------------------------------------------- # | |
| _QWORDS = {"what", "whats", "when", "where", "which", "who", "how", "is", "are", | |
| "do", "does", "can", "tell", "any", "the", "this", "a", "i", "my"} | |
| # Words to drop when pulling a branch/village name out of a question, so we | |
| # don't latch onto "What" or "library" instead of "bromsgrove". | |
| _PLACE_STOP = _QWORDS | { | |
| "time", "times", "open", "opening", "opens", "close", "closing", "closed", | |
| "hours", "hour", "library", "libraries", "branch", "today", "tomorrow", | |
| "tonight", "now", "morning", "afternoon", "evening", "on", "at", "in", "to", | |
| "for", "of", "me", "you", "it", "near", "address", "parking", "toilet", | |
| "toilets", "facilities", "facility", "monday", "tuesday", "wednesday", | |
| "thursday", "friday", "saturday", "sunday", "weekend", "week", "day", "next", | |
| "mobile", "van", "visit", "visits", "come", "comes", "service", "opened", | |
| "wi-fi", "wifi", "wi", "fi", "wireless", "internet", "broadband", | |
| } | |
| def _place_from(q: str) -> str: | |
| """Branch/village name from a question - case-insensitive, stopword-filtered.""" | |
| toks = re.findall(r"[A-Za-z][A-Za-z'\-]*", q) | |
| return " ".join(w for w in toks if w.lower() not in _PLACE_STOP).strip() | |
| def keyword_route(q: str) -> tuple[str, dict]: | |
| t = q.lower() | |
| feat_hits = sum(1 for w in ("parking", "café", "cafe", "wifi", "wi-fi", "study", | |
| "toilet", "computer", "meeting room", "baby") | |
| if w in t) | |
| if (re.search(r"\b(print(?:ing)?|photocop(?:y|ies|ying)|scan(?:ning|ner)?|copier)\b", t) | |
| and not re.search(r"\blarge.?print\b|\bbig.?print\b", t)): | |
| return "printing_help", {"query": q} | |
| # Expired card / membership renewal - before generic renew check | |
| if (re.search(r"\b(library )?card (has |is |)(expired|lapsed|out of date)\b|" | |
| r"\bexpired (library )?card\b|" | |
| r"\b(my |the )?(library )?(membership|member) (has |is |)(expired|lapsed)\b", t) | |
| or (re.search(r"\brenew (my |the )?(library )?(card|membership)\b", t) | |
| and not re.search(r"\bbooks?\b|\bitems?\b|\bloans?\b", t))): | |
| return "account_and_loans", {"query": "card expired renew membership"} | |
| # Account self-service: renewals, fines, reservations, home library | |
| if re.search(r"\b(renew|renewal|due date)\b|" | |
| r"\bextend(ing)? (a |my |the |)?(loan|book|borrow|borrowing|item|items|them|it)\b|" | |
| r"\bcan (i|we) (renew|extend)\b", t): | |
| return "account_and_loans", {"query": q} | |
| if re.search(r"\b(how (long|many)\b[^?]{0,40}\b(borrow|loan|keep|renew|take out|" | |
| r"out at once)|loan period|borrowing limit|how many (books|items) can i)\b", t): | |
| return "account_and_loans", {"query": "loan limits how long how many"} | |
| # Lost / stolen library card - dedicated focus (before fines catch-all) | |
| if re.search(r"\blost (my |a |the )?(library )?card\b|" | |
| r"(my |a )?(library )?card (is |was |has been )?(lost|stolen)\b|" | |
| r"(stolen|stole).{0,20}(library )?card\b|" | |
| r"\bmy card.{0,15}(stolen|stolen|missing)\b|" | |
| r"replace (a |my |the )?(library )?card\b", t): | |
| return "account_and_loans", {"query": "lost stolen library card replacement"} | |
| if re.search(r"\b(fine|fines|overdue|late fees?|pay (a |my )?(fee|fine)|" | |
| r"fees?[ -]and[ -]charges?|lost (a |my )?book|" | |
| r"(charge|cost|cost).{0,20}(late|overdue|return)|" | |
| r"do you charge|how much.{0,20}(fine|fee|late)|replacement charge)\b", t): | |
| return "account_and_loans", {"query": q} | |
| if re.search(r"\b(my (library )?account|online account|(sign|log)?in(to)?|" | |
| r"my (library )?pin|forgot (my )?(pin|password)|" | |
| r"library card (number|login))\b", t): | |
| return "account_and_loans", {"query": q} | |
| if "cancel" in t and re.search(r"reserv|\bhold\b", t): | |
| return "account_and_loans", {"query": "cancel reservation"} | |
| if (re.search(r"\bhow (do|can) i (reserve|place a hold|request)|" | |
| r"(make|place) a (reserve|reservation|hold)\b|" | |
| r"how long.{0,30}(reserv|hold|collect|pick up)|" | |
| r"(reserv|hold).{0,30}(how long|expire|expir|time|collect|pick up|7 day|week)\b|" | |
| r"\breservation.{0,35}(expired?|missed?|not collect|if i (don'?t|can'?t|miss|forget))|" | |
| r"(if|when).{0,20}(don'?t|can'?t|miss|forget|unable).{0,20}(collect|pick up).{0,20}reservat|" | |
| r"what happens?.{0,40}reservat|" | |
| r"\b(reservation|hold) (expired?|lapsed?|gone|released)\b|" | |
| r"\bmissed (my |the )?reservation\b|\breservation.{0,15}expir", t) | |
| and not re.search(r"\b(room|space|study|seat|hive)\b", t)): | |
| return "account_and_loans", {"query": q} | |
| if re.search(r"\b(home library|library (service )?(at home|at-home|home)|" | |
| r"housebound|books? delivered|deliver (books?|the library)|" | |
| r"can.{0,12}t get to (the |a )?librar)\b", t): | |
| return "account_and_loans", {"query": q} | |
| if re.search(r"\b(update|change|amend|correct) (my )?(address|detail|contact|" | |
| r"email|phone|mobile|name)\b|" | |
| r"\bi'?ve? moved\b|" | |
| r"\bmy (address|detail) (has |have |is |)(changed|wrong|moved|updated)\b", t): | |
| return "account_and_loans", {"query": "update my account details address"} | |
| if re.search(r"\breturn(ing)? (a |my |the )?(book|item|loan)\b|" | |
| r"how (do|to|can) (i )?return\b", t): | |
| return "account_and_loans", {"query": "return book"} | |
| if (re.search(r"\b(lost|damaged|damage).{0,20}\b(book|item|dvd)\b|" | |
| r"\b(book|item|dvd).{0,15}(lost|damaged)\b", t) | |
| and "card" not in t): | |
| return "account_and_loans", {"query": "lost damaged book item"} | |
| if re.search(r"\b(ask for a book|book recommendation|personalised (book|read)|" | |
| r"suggest (me )?a book|what should i read|choose (me )?a book|" | |
| r"pick (me )?a book|recommend (me )?a book|reading suggestion)\b", t): | |
| return "account_and_loans", {"query": "ask for a book recommendation"} | |
| if re.search(r"\bbook (a )?computer\b|use (a |the )?computer|pc session|" | |
| r"computer session|book a pc\b|" | |
| r"computers?.{0,30}librar|(library|libraries).{0,15}computers?\b", t): | |
| return "account_and_loans", {"query": "book a computer"} | |
| # Self-service kiosk - how to check out / return without staff | |
| if re.search(r"\bself[- ]?service (kiosk|machine|terminal)?\b|" | |
| r"\bself[- ]?checkout\b|\bkiosk\b|" | |
| r"\b(check (out|in)).{0,20}(myself|machine|kiosk|without staff)\b|" | |
| r"\buse (the |a )?self[- ]?service\b", t): | |
| return "account_and_loans", {"query": "self service kiosk checkout borrow return"} | |
| if re.search(r"\bvolunteer(ing)?\b|work experience (at|in) (a |the )?librar\b", t): | |
| return "account_and_loans", {"query": "volunteering"} | |
| if re.search(r"\bdigital (skills?|inclusion|champion)\b|learn my way\b|" | |
| r"get online\b|learn to use (a )?computer\b", t): | |
| return "account_and_loans", {"query": "digital skills"} | |
| if re.search(r"\breading well\b|mental health books?|wellbeing books?|" | |
| r"books? (for|about|on) (mental health|wellbeing|anxiety|depression|stress|" | |
| r"grief|bereavement|worry|insomnia|sleep|chronic pain|eating disorder|" | |
| r"adhd|autism|ocd|ptsd|long[- ]term conditions?)\b|" | |
| r"\b(anxiety|depression|grief|bereavement|stress).{0,25}books?\b|" | |
| r"books?.{0,25}(anxiety|depression|grief|bereavement|stress)\b|" | |
| r"(library|have|borrow).{0,20}(mental health|wellbeing)\b", t): | |
| return "account_and_loans", {"query": "reading well"} | |
| if re.search(r"\bwarm (space|welcome|place)\b|somewhere warm\b", t): | |
| return "account_and_loans", {"query": "warm space"} | |
| if re.search(r"\bjob club\b|job[- ]?seeking|job search|cv (help|writing|advice)|" | |
| r"employment support|find work|looking for work|interview (prep|help)\b", t): | |
| return "account_and_loans", {"query": "job club cv employment"} | |
| # Book clubs / reading groups | |
| if re.search(r"\bbook (club|group)\b|reading (group|club|circle)\b|" | |
| r"\breaders'? (group|circle|club)\b", t): | |
| return "account_and_loans", {"query": "book club reading group"} | |
| # Adult learning / skills courses | |
| if re.search(r"\badult (learn|class|course|train|skill)|" | |
| r"\blearn\w* (course|class|skill|program)|\bit (course|class)|" | |
| r"\bcomputer (course|class|lesson)|\bupskill|\bfunctional skills?\b|" | |
| r"\blearn to (read|write|type)\b", t): | |
| return "account_and_loans", {"query": "adult learning course"} | |
| # Book donations | |
| if re.search(r"\bdonate (books?|items?) (to|at|for) (\w+ )?librar|" | |
| r"\bbook donation\b|give (books?|items?) (to|in to) (\w+ )?librar|" | |
| r"\bdrop off books?\b|second[- ]?hand books? (to|for|at) (\w+ )?librar|" | |
| r"\bbring books? (in|to) (the |a )?librar", t): | |
| return "account_and_loans", {"query": "donate books"} | |
| # Teen / young adult library services - before children_services to avoid misrouting | |
| if re.search(r"\bteen(s|age|agers?)?\b|young adult\b|ya (books?|fiction|section|reading)\b|" | |
| r"\byoung (people|person)\b.{0,30}librar|librar.{0,30}\b(teen|young adult)\b|" | |
| r"what.{0,30}\b(teens?|teenager|young adult|young people)\b.{0,30}librar|" | |
| r"librar.{0,30}\b(teens?|teenager|young (adult|people))\b", t): | |
| return "account_and_loans", {"query": "teen young adult services library"} | |
| # Library app - before catalogue catch-all | |
| if re.search(r"\blibrary app\b|app for (the )?librar|librar.{0,20}\bapp\b|" | |
| r"\bmobile app.{0,20}librar|librar.{0,20}(on my phone|on (a |my )?phone)\b|" | |
| r"\bdownload.{0,20}librar|librar.{0,20}download\b", t): | |
| return "account_and_loans", {"query": "library app mobile borrowbox"} | |
| # Community noticeboards and local signposting | |
| if re.search(r"\bnoticeboard\b|notice board\b|community (notice|information|hub|board)\b|" | |
| r"\blocal groups?\b.{0,30}librar|librar.{0,30}\blocal (group|service|charity)\b|" | |
| r"\badvertise.{0,20}librar|librar.{0,20}\badvertise\b|" | |
| r"\bdisplay (a )?leaflet\b|food bank.{0,25}librar|librar.{0,25}food bank\b|" | |
| r"\bsignpost\b", t): | |
| return "account_and_loans", {"query": "community noticeboard local services"} | |
| # Multilingual resources - books and content in other languages | |
| if re.search(r"\b(books?|resources?|content|material).{0,20}(in |language|foreign|" | |
| r"arabic|polish|urdu|hindi|punjabi|spanish|french|portuguese|chinese|" | |
| r"somali|romanian|bulgarian|russian|turkish|farsi|persian)\b|" | |
| r"\b(arabic|polish|urdu|hindi|punjabi|spanish|french|portuguese|" | |
| r"chinese|somali|romanian|russian).{0,20}books?\b|" | |
| r"\bmultilingual\b|\bnon[- ]english\b|" | |
| r"\bcommunity language\b|\besol\b|english as a second language\b|" | |
| r"books?.{0,15}other language|other language.{0,15}books?", t): | |
| return "account_and_loans", {"query": "multilingual books other languages"} | |
| # Early years / pre-school - check BEFORE school-visits to avoid misrouting | |
| if re.search(r"\bearly years?\b|get school ready\b|" | |
| r"\bpre[- ]?school (library|books?|session|activit)\b|" | |
| r"\bschool ready\b", t): | |
| return "children_services", {} | |
| # School and group visits to libraries | |
| if re.search(r"\bschool (visit|trip|group|class|tour)\b|" | |
| r"\b(teachers?|class(es)?).{0,45}librar|" | |
| r"\bgroup (visit|booking).{0,40}librar|" | |
| r"\barrange.{0,20}(school|class|group).{0,25}librar", t): | |
| return "account_and_loans", {"query": "school visit group visit"} | |
| # Children's services - Storytime, Rhymetime, Summer Reading Challenge, early years (not Hive-specific) | |
| if (re.search(r"\b(storytime|story time|rhymetime|rhyme time|bounce and rhyme|" | |
| r"summer reading|reading challenge|summer challenge|" | |
| r"children'?s? (librar|service|activit|" | |
| r"event|book|section)|kids'? (activit|event|club|book)|toddler|" | |
| r"under[- ]?5s?|baby (rhyme|group)|for (kids|children|toddlers))", t) | |
| and not re.search(r"\bhive\b", t)): | |
| return "children_services", {} | |
| # Business support / BIPC - based at The Hive | |
| if re.search(r"\bbipc\b|business (advice|support|centre|center)|" | |
| r"intellectual property|start (a |my )?business\b", t): | |
| return "hive_info", {"topic": "business"} | |
| # Library closures, bank holidays, planned refurbishments - before find_library | |
| if re.search(r"\b(bank holidays?|public holidays?|" | |
| r"library closing dates?|closing dates? \d{4}|\d{4} closing dates?|" | |
| r"library clos(?:ed|ure|ures)|2026 clos(?:ing|ure|ed)|" | |
| r"when (is|are) (the |a |worcestershire )?librar.{0,25}clos|" | |
| r"closed (on|over|for|at) (christmas|easter|bank holidays?|good friday)|" | |
| r"planned (?:clos|refurb)|temporary (?:clos|shut)|refurb(?:ishment)?)\b", t): | |
| return "account_and_loans", {"query": "library closures bank holidays closing dates"} | |
| # General building accessibility - not asking "which library" but "are they accessible" | |
| if (re.search(r"\b(wheelchair|step[- ]free|hearing loop|induction loop|" | |
| r"accessible (toilet|entrance|parking|building)|disabled (access|parking)|" | |
| r"disability access|accessible (librar|facilit))\b", t) | |
| and not re.search(r"\bwhich\b|\bwhere\b|\bnear\b|\bfind\b|\blocal\b", t)): | |
| return "account_and_loans", {"query": "building accessibility wheelchair hearing loop"} | |
| # Study / quiet space - match a branch by facility via the graph | |
| if (re.search(r"\b(where can i study|somewhere to study|a (quiet|study) " | |
| r"(space|spot|area|room)|need (a )?(quiet|study) (space|spot|room|area)|" | |
| r"quiet (place|spot|space) to (study|work|read))\b", t) | |
| and not re.search(r"\bhive\b", t)): | |
| return "graph_search", {"query": q} | |
| # Accessibility - find branches by facility via the graph | |
| if (re.search(r"\b(wheelchair|accessible|accessibility|disabled|disability|" | |
| r"step[- ]free|hearing loop|induction loop|dyslexia|" | |
| r"visual impair|sight impair|partially sighted)\b", t) | |
| and re.search(r"\blibrar", t)): | |
| return "graph_search", {"query": q} | |
| # Youth Hub / Careers Hub at The Hive - route even without 'hive' keyword | |
| if re.search(r"\b(youth hub|careers hub|career hub|careers centre|careers center)\b|" | |
| r"\bhive.{0,25}(career|youth|young people)\b|" | |
| r"\b(career|youth).{0,25}\bhive\b", t): | |
| return "hive_info", {"topic": "youth hub careers"} | |
| # Non-Hive meeting room hire - Hive-specific queries fall through to hive_info below | |
| if (re.search(r"\bhire (a |the )?(meeting )?room\b|(meeting )?room (for )?hire\b|" | |
| r"book a (meeting )?room\b|meeting room hire\b", t) | |
| and not re.search(r"\bhive\b", t)): | |
| return "account_and_loans", {"query": "room hire meeting room"} | |
| # The Hive / its extended offer (archives, archaeology, study spaces, Worcester city) | |
| if re.search(r"\b(the )?hive\b|archiv|archaeolog|explore the past|" | |
| r"worcester city librar|book a (space|study)\b", t): | |
| topic = re.sub(r"\b(the|hive|at|in|about|tell|me|what|can|i|do|you|" | |
| r"know|is|are|there)\b", " ", t) | |
| return "hive_info", {"topic": topic.strip("?.") or None} | |
| # "how do I actually GET it" - the full per-copy journey | |
| if (re.search(r"\b(where|how) (can|do|could|would) i (get|borrow|find|read|" | |
| r"listen to)\b|\bget hold of\b|\bi want to (read|borrow)\b|" | |
| r"\bis.{3,60} (available|in stock|on the shelf)\b|" | |
| r"\b(nearest|closest) copy\b", t) | |
| and not re.search(r"\bcard|member|join|sign?up\b", t)): | |
| q2 = re.sub(r"\b(where|how|can|do|could|would|i|get|borrow|find|read|" | |
| r"listen|to|hold|of|want|is|available|in stock|on the shelf|" | |
| r"a copy of|the book|nearest|closest|copy)\b", " ", t) | |
| q2 = q2.strip("?.") | |
| if q2: | |
| return "where_to_get", {"query": q2} | |
| if (re.search(r"\b(which|what) librar|librar(y|ies) (with|that has)\b", t) | |
| or feat_hits >= 2 or "overall" in t): | |
| return "graph_search", {"query": q} | |
| if re.search(r"\b(mobile library|mobile van|the van|comes to|visit)\b", t): | |
| place = _place_from(q) | |
| return "mobile_library", {"place": place} | |
| # Wi-Fi connection instructions - "how to connect" queries; "is there wifi at X" still → find_library | |
| if re.search(r"\bwi-?fi\b|\bwireless internet\b", t) and \ | |
| re.search(r"\b(connect(ing)?|connection|password|log?in|sign?in|" | |
| r"how (do|can|to)|guest|setup|set up|access)\b", t): | |
| return "account_and_loans", {"query": "wifi how to connect password access"} | |
| # General library contact - direct enquiry about how to reach the service | |
| if re.search(r"\bhow (do|can) i (contact|reach|call|speak to|get in touch with|" | |
| r"ring|get through to) (the |a |my |worcestershire )?librar|" | |
| r"\bwhat.{0,10}(library|libraries).{0,15}(phone|telephone|contact) number\b|" | |
| r"\b(library|libraries) (helpline|enquiry line|central number|main number)\b|" | |
| r"\bgeneral (library |libraries )?(enquiry|enquiries|contact|phone)\b", t): | |
| return "account_and_loans", {"query": "contact library phone number enquiries general"} | |
| if re.search(r"\b(open|opening|hours|close|closing|toilet|parking|address|" | |
| r"facilit|where is|near me|study space|wi-?fi|wireless|phone number|" | |
| r"telephone|contact (details?|number)|email address|" | |
| r"bank holiday|public holiday|christmas|easter|good friday|new year'?s)\b", t): | |
| return "find_library", {"name": _place_from(q), "when": q} | |
| if re.search(r"\b(unlocked|8pm|after hours|after work|out of hours|" | |
| r"evening access|open late|get in (early|late)|" | |
| r"24[- ]?hour (access|library)|extended (access|hours?)|" | |
| r"access (the )?librar.{0,20}(outside|beyond|after|before) (hours?|closing|opening)|" | |
| r"librar.{0,20}(outside|beyond|after) (hours?|opening|closing)|" | |
| r"librar.{0,25}(when|while).{0,15}(closed|unstaffed|no staff))\b", t): | |
| return "libraries_unlocked", {} | |
| # Accessible reading formats - general (Braille, print disability, accessible editions) | |
| if re.search(r"\baccessible? (read(?:s|ing)?|books?|formats?|edition)\b|" | |
| r"\baccessible reading\b|" | |
| r"\bbraille\b|\bprint.?disabilit\b|" | |
| r"\b(visual|sight).{0,12}impair.{0,25}\b(books?|reading|formats?)\b", t): | |
| return "account_and_loans", {"query": "accessible formats reading"} | |
| # Inter-library loans - items not held in the Worcestershire network | |
| if re.search(r"\binter[- ]?library (loan|borrow|request|service)?\b|" | |
| r"\bill (request|loan|service)?\b|" | |
| r"\b(borrow|get|obtain|request|order).{0,35}(another|different|other) librar|" | |
| r"\bfrom (another|a different|other) librar", t): | |
| return "account_and_loans", {"query": "inter-library loan another library service"} | |
| # Talking books / accessible audio - route to BorrowBox via online_hub | |
| if re.search(r"\b(talking books?|daisy (format|books?|reader)\b|rnib\b|" | |
| r"listening books? (service|online)?\b|" | |
| r"audiobooks?.{0,25}(visual|blind|sight|impair))", t): | |
| return "online_hub", {"topic": "audiobooks borrowbox accessible reading"} | |
| # Large print - route straight to catalogue with format hint | |
| if re.search(r"\blarge[- ]?print\b|big[- ]?print books?\b", t): | |
| title = re.sub(r"\b(large[- ]?print|big[- ]?print|books?|do you have|" | |
| r"are there|have you got|any|available|at (the |a )?librar\w*)\b", | |
| " ", t).strip("?.") | |
| return "search_catalogue", {"query": f"large print {title}".strip()} | |
| platform = re.search(r"\b(borrowbox|pressreader|ancestry|espacenet|ebsco|oxford|oed|" | |
| r"theory test|bfi|cobra|digital library|online (library )?hub|" | |
| r"encyclopaedia|encyclopedia|family (history|tree)|" | |
| r"genealog(y|ical)|patents?|national biography|census|" | |
| r"consumer advice|product review|best buy)\b", t) | |
| media = re.search(r"\b(ebooks?|e-books?|audiobooks?|emagazines?)\b", t) | |
| online_ctx = re.search(r"\b(online|free|digital|from home|at home|on my phone|" | |
| r"app|stream(ing)?|download)\b", t) | |
| if (platform or re.search(r"\bnewspapers?|magazines?\b", t) | |
| or (media and online_ctx) or re.search(r"read\b.*\bfree", t)): | |
| return "online_hub", {"topic": q} | |
| # Children / family joining - specific routing before generic membership catch-all | |
| if re.search(r"\b(baby|infant|child|children|kid|kids|toddler|junior|teen|" | |
| r"teenager|youth|family)\b.{0,40}" | |
| r"\b(join|member|membership|card|sign.?up|register)\b|" | |
| r"\b(join|member|membership|card|sign.?up)\b.{0,40}" | |
| r"\b(baby|infant|child|children|kid|kids|toddler|junior|teen|" | |
| r"teenager|youth|family)\b|" | |
| r"\bcan (my|a|the) (baby|child|kid|teen|teenager|infant|toddler|junior|" | |
| r"young person) (join|borrow|use the library|get a card|sign up)\b|" | |
| r"\b(children'?s?|family|baby|junior|under.?16|under.?18).{0,20}" | |
| r"(library card|membership|member)\b", t): | |
| return "account_and_loans", {"query": "child family join library membership card"} | |
| # Suggest a book purchase - before catalogue catch-all | |
| if re.search(r"\b(suggest|request|ask).{0,25}(library|you|council|them).{0,20}" | |
| r"(buys?|stocks?|orders?|purchases?|gets?)\b|" | |
| r"\bask (the |a )?library to (buys?|gets?|stocks?|orders?|purchases?)\b|" | |
| r"\b(buys?|gets?|stocks?|orders?|purchases?) (this|that|it|a copy) for the library\b|" | |
| r"\bhow (do|can) i suggest (a |the )?library.{0,30}(buys?|stocks?|orders?|gets?)\b|" | |
| r"\bcould (the|a) library (buys?|gets?|orders?|stocks?)\b|" | |
| r"\bwish.?list.{0,20}librar\b", t): | |
| return "account_and_loans", {"query": "suggest purchase buy book library stock"} | |
| if re.search(r"\b(member|membership|join|library card|sign?up|what do i need)\b", t): | |
| return "membership_help", {"service": q} | |
| # Dementia-friendly / memory activities - route to events with a focused query | |
| if re.search(r"\bdementia\b|dementia[- ]?friendly\b|memory (cafe|group|session|club|" | |
| r"activities?|support)\b|alzheimer\b|forget(ting|fulness)\b.{0,30}activit", t): | |
| return "library_events", {"query": "dementia memory"} | |
| if re.search(r"\b(event|events|what'?s on|whats on|activit|class|club|session|" | |
| r"group|happening|this week)\b", t): | |
| m = [w for w in re.findall(r"\b([A-Z][a-z]+(?:[ -][A-Z][a-z]+)*)\b", q) | |
| if w.lower() not in _QWORDS] | |
| return "library_events", {"query": (m[-1] if m else "")} | |
| if re.search(r"\b(new|newest|latest|recommend|hot take|just in|good read|" | |
| r"suggestion)\b", t): | |
| return "whats_new", {"genre": re.sub(r"\b(new|newest|latest|recommend|any|" | |
| r"good|some|me|a|books?)\b", " ", t).strip()} | |
| if re.search(r"\b(book|books|read|novel|author|catalog|catalogue|borrow|dvd|" | |
| r"have you got|do you have)\b", t): | |
| q2 = re.sub(r"\b(do you have|have you got|any|the book|a copy of|books?|" | |
| r"by|in stock|available)\b", " ", t) | |
| return "search_catalogue", {"query": q2.strip("?.") or q} | |
| # last resort: a bare title or "<Title> by <Author>" -> catalogue | |
| if re.search(r"\bby [A-Z]", q) or len(re.findall(r"\b[A-Z][a-z]+", q)) >= 2: | |
| return "search_catalogue", {"query": q} | |
| return "none", {} | |
| def route(q: str, history=None, model_id: str = None) -> tuple[str, dict, str, int]: | |
| t0 = time.time() | |
| if HF_TOKEN: | |
| try: | |
| user = q | |
| ctx = _history_text(history) | |
| if ctx: | |
| user = f"Conversation so far:\n{ctx}\n\nRoute this latest question: {q}" | |
| out = llm([{"role": "system", "content": ROUTER_SYSTEM}, | |
| {"role": "user", "content": user}], | |
| model_id=model_id, max_tokens=120, temperature=0.0) | |
| m = re.search(r"\{.*\}", out.choices[0].message.content, re.S) | |
| if m: | |
| data = json.loads(m.group(0)) | |
| tool = data.get("tool", "none") | |
| if tool in TOOLS or tool == "none": | |
| return tool, data.get("args", {}) or {}, "llm", _ms(t0) | |
| except Exception: | |
| pass | |
| tool, args = keyword_route(q) | |
| return tool, args, "keyword", _ms(t0) | |
| def _ms(t0): return int((time.time() - t0) * 1000) | |
| # --------------------------------------------------------------------------- # | |
| # Render live tool results -> markdown (with eligibility woven in) | |
| # --------------------------------------------------------------------------- # | |
| def render_catalogue(r): | |
| if r.get("error"): | |
| return f"_Couldn't reach the catalogue: {r['error']}_" | |
| if not r["items"]: | |
| return (f"I searched for **{r['query']}** but found nothing - try fewer or " | |
| f"different words.\n\nNot in stock? [Ask us to buy it]" | |
| f"({ls.ASK_FOR_A_BOOK_URL}) - staff review suggestions.\n\n" | |
| f"[Search the catalogue]({r['search_url']})") | |
| out = [f"Found ~**{r.get('total_hint', r['count'])}** matches for **{r['query']}** " | |
| f"- top {r['count']}:\n"] | |
| for it in r["items"]: | |
| meta = " · ".join(b for b in (it["author"], it["format"], it["year"]) if b) | |
| tag = " _(borrow online)_" if it["digital"] else "" | |
| link = f" - [details]({it['detail_url']})" if it["detail_url"] else "" | |
| out.append(f"- {it['icon']} **{it['title']}** - {meta}{tag}{link}") | |
| out.append(f"\n**To borrow:** {ls.ELIGIBILITY['borrow_physical']} " | |
| f"eBooks/audio need free [digital membership]({ls.JOIN_URL}).") | |
| out.append(f"[Full results]({r['search_url']})") | |
| return "\n".join(out) | |
| def render_whats_new(r): | |
| if not r["items"]: | |
| return "I couldn't pull new titles just now - try a specific genre." | |
| out = [f"**Newest '{r['genre']}' in the catalogue:**\n"] | |
| for it in r["items"]: | |
| meta = " · ".join(b for b in (it["author"], it["year"]) if b) | |
| out.append(f"- {it['icon']} **{it['title']}** - {meta}") | |
| out.append(f"\n**To borrow:** {ls.ELIGIBILITY['borrow_physical']}") | |
| out.append(f"[See more]({r['search_url']})") | |
| return "\n".join(out) | |
| def render_find_library(r): | |
| if r.get("error"): | |
| s = ", ".join(r.get("suggestions", [])[:6]) | |
| return f"{r['error']} Did you mean: {s}?\n\n[All libraries]({r['page_url']})" | |
| if "branches" in r: # list mode | |
| out = ["**Worcestershire libraries:**\n"] | |
| for b in r["branches"][:25]: | |
| name = f"[{b['name']}]({b['url']})" if b.get("url") else b["name"] | |
| out.append(f"- **{name}** - {b['address']}") | |
| return "\n".join(out) | |
| day_label = r.get("day_label", f"today ({r.get('today','')})") | |
| heading = day_label[0].upper() + day_label[1:] | |
| if r.get("is_today", True): | |
| badge = "**Open now**" if r["open_now"] else "**Closed now**" | |
| head = f"**{r['name']}** - {badge} ({r['status']})" | |
| else: | |
| head = f"**{r['name']}** - **{heading}:** {r['status']}" | |
| closed_txt = ("closed (staffed) - but open for self-service below" | |
| if r.get("unlocked") else "closed") | |
| out = [head, f"{r['address']}\n", | |
| f"**{heading}:** {r.get('staffed') or closed_txt}"] | |
| if r.get("unlocked"): | |
| out.append(f"**Libraries Unlocked self-service:** {r['unlocked']} - " | |
| f"{ls.ELIGIBILITY['unlocked']} " | |
| f"[How to get access]({ls.UNLOCKED_URL})") | |
| if r.get("facilities"): | |
| out.append(f"\n**Facilities:** {', '.join(r['facilities'])}") | |
| out.append(f"\n{ls.ELIGIBILITY['visit']}") | |
| out.append(f"_Bank holidays & special closures: [2026 closing dates]({ls.CLOSING_DATES_URL})_") | |
| out.append(f"[Branch page]({r['page_url']})") | |
| return "\n".join(out) | |
| def render_mobile(r): | |
| if r.get("no_village"): | |
| villages = ", ".join(x.title() for x in (r.get("example_villages") or [])[:6]) | |
| return ( | |
| f"**Mobile library - which village?**\n\n" | |
| f"{r['guidance']}\n\n" | |
| f"_Example stops:_ {villages}…\n\n" | |
| f"Try: _\"When does the mobile library visit Abberley?\"_\n" | |
| f"{r['email']}\n" | |
| f"[Full timetable - all villages]({r['page_url']})" | |
| ) | |
| if r.get("error"): | |
| s = ", ".join(x.title() for x in (r.get("suggestions") or [])[:6]) | |
| extra = f"\n\n_Similar stops:_ {s}" if s else "" | |
| return ( | |
| f"{r['error']}{extra}\n\n" | |
| f"Try asking: _\"When does the mobile library visit [your village]?\"_\n" | |
| f"[All stops]({r.get('page_url', ls.MOBILE_INDEX)})" | |
| ) | |
| out = [f"**Mobile library - {r['village']}**", f"Runs: **{r['date_of_operation']}**\n"] | |
| for s in r["stops"]: | |
| out.append(f"- `{s['time']}` - {s['location']}") | |
| out.append(f"\n{ls.ELIGIBILITY['mobile']}") | |
| out.append(f"Enquiries: {r['email']} · [Timetable]({r['page_url']})") | |
| return "\n".join(out) | |
| def render_events(r): | |
| if not r["events"]: | |
| return f"No matching events found.\n\n[All events]({r['page_url']})" | |
| out = [f"**{r['count']} upcoming events:**\n"] | |
| for e in r["events"]: | |
| when = " · ".join(b for b in (e.get("next_date"), e["when"], e["time"]) if b) | |
| loc = f" @ {e['location']}" if e["location"] else "" | |
| out.append(f"- **[{e['name']}]({e['url']})** - {when}{loc}") | |
| out.append(f"\n{ls.ELIGIBILITY['events']}\n[Full listing]({r['page_url']})") | |
| return "\n".join(out) | |
| def render_online_hub(r): | |
| out = ["**Free online - with your library card:**\n"] | |
| for it in r["items"][:5]: | |
| name = f"[{it['name']}]({it['url']})" if it.get("url") else it["name"] | |
| out.append(f"**{name}** - {it['summary']}") | |
| if it.get("what_you_need"): | |
| out.append(f" - **What you need:** {it['what_you_need']}") | |
| if it.get("access"): | |
| out.append(" - **How:** " + " → ".join(it["access"])) | |
| if it.get("limits"): | |
| out.append(f" - {it['limits']}") | |
| if it.get("titles"): | |
| out.append(f" - _Includes:_ {', '.join(it['titles'][:6])}…") | |
| out.append("") | |
| out.append(f"[Online library hub]({r['page_url']})") | |
| return "\n".join(out) | |
| def render_unlocked(r): | |
| out = ["**Libraries Unlocked** - use the library 8am–8pm, Mon–Sat, even " | |
| "when it's unstaffed.", | |
| f"\n**What you need:** {r['what_you_need']} " | |
| f"Not a member yet? [Join online]({ls.JOIN_URL}) first."] | |
| if r.get("unlocks"): | |
| out.append(f"\n{r['unlocks']}") | |
| else: | |
| out.append(f"\n**Branches:** {', '.join(r['branches'])}.") | |
| if r.get("branch_match"): | |
| out.append(f"\nYes - **{r['branch_match']}** has Libraries Unlocked.") | |
| elif r.get("branch_match") is None and "branch_match" in r: | |
| out.append("\nThat branch isn't on the Libraries Unlocked list yet.") | |
| if r.get("get_started"): | |
| out.append("\n**How to get started:**") | |
| out += [f"{i}. {s}" for i, s in enumerate(r["get_started"], 1)] | |
| out.append(f"\n[Libraries Unlocked]({r['page_url']})") | |
| return "\n".join(out) | |
| def render_membership(r): | |
| out = ["**What you need to sign up:**\n"] | |
| for tier in r["tiers"]: | |
| name = (f"[{tier['tier']}]({tier['url']})" if tier.get("url") | |
| else tier["tier"]) | |
| out.append(f"**{name}** - {tier['what_you_need']}") | |
| out.append(f" - _Unlocks:_ {tier['unlocks']}\n") | |
| if r.get("need"): | |
| out.append(f"For your question: **{r['need']}**") | |
| out.append(f"\n[Join the library]({r['page_url']})") | |
| return "\n".join(out) | |
| def render_printing(r): | |
| focus = r.get("focus", "print") | |
| page = r["page_url"] | |
| if focus == "photocopy" and r.get("in_library_photocopy"): | |
| pc = r["in_library_photocopy"] | |
| steps = "\n".join(f"{i}. {s}" for i, s in enumerate(pc["how_to"], 1)) | |
| price = "\n".join(f"- {k}: {v}" for k, v in pc["pricing"].items()) | |
| return (f"**In-library photocopying**\n\n{pc['summary']}\n\n" | |
| f"**How to photocopy:**\n{steps}\n\n**Prices:**\n{price}\n\n" | |
| f"_{pc['note']}_\n\n[Printing & photocopying]({page})") | |
| if focus == "scanning" and r.get("scanning"): | |
| sc = r["scanning"] | |
| steps = "\n".join(f"{i}. {s}" for i, s in enumerate(sc["how_to"], 1)) | |
| return (f"**Document scanning at the library**\n\n{sc['summary']}\n\n" | |
| f"**How to scan:**\n{steps}\n\n_{sc['note']}_\n\n" | |
| f"[Printing & photocopying]({page})") | |
| # Default: Print Your Way (remote/mobile printing) | |
| steps = "\n".join(f"{i}. {s}" for i, s in enumerate(r["steps"], 1)) | |
| price = "\n".join(f"- {k}: {v}" for k, v in r["pricing"].items()) | |
| pc_note = "" | |
| if r.get("in_library_photocopy"): | |
| pc_note = ("\n\n_Need to photocopy? Walk-up photocopying is also available at " | |
| "most branches - no account needed, same prices._") | |
| return (f"**Print Your Way**\n\n{r['summary']}\n\n**Devices:** " | |
| f"{r['device_requirements']}\n\n**How to print:**\n{steps}\n\n" | |
| f"**Prices:**\n{price}{pc_note}\n\n[Printing page]({page})") | |
| def render_graph(r): | |
| _frag = {"caf": "a café", "meeting": "meeting rooms", "wi-fi": "free Wi-Fi", | |
| "study": "study space", "parking": "parking", "computer": "public computers", | |
| "toilet": "toilets", "baby": "baby changing", "accessible": "accessible toilet", | |
| "wheelchair": "wheelchair access", "printing": "printing", "self": "self-service"} | |
| if r["kind"] == "branch_filter": | |
| feats = [] | |
| if r["late"]: | |
| feats.append("open late (8am–8pm)") | |
| feats += [_frag.get(f, f) for f in r["wanted_facilities"]] | |
| if r["area"]: | |
| feats.append(f"in {r['area']}") | |
| crit = ", ".join(feats) or "your criteria" | |
| if not r["branches"]: | |
| return (f"No library currently matches **{crit}** in our data. " | |
| "Try fewer features.\n\n[All libraries](" + r["page_url"] + ")") | |
| out = [f"Libraries matching **{crit}**:\n"] | |
| for b in r["branches"]: | |
| lu = " · open to 8pm" if b["libraries_unlocked"] else "" | |
| name = f"[{b['name']}]({b['url']})" if b.get("url") else b["name"] | |
| addr = f" - {b['address']}" if b.get("address") else "" | |
| out.append(f"- **{name}**{lu}{addr} - {', '.join(b['facilities'])}") | |
| out.append("\nAsk me _\"is it open now?\"_ about any of these for " | |
| "live hours.") | |
| return "\n".join(out) | |
| if r["kind"] == "entity": | |
| if not r["entities"]: | |
| return "I couldn't find that in the knowledge graph - try rephrasing." | |
| out = [] | |
| for e in r["entities"][:3]: | |
| out.append(f"**{e['label']}** ({e['type']}) - {e.get('summary','')[:160]}") | |
| if e.get("what_you_need"): | |
| out.append(f" - {e['what_you_need']}") | |
| rel = ", ".join(f"{x['rel'].lower().replace('_',' ')} {x['label']}" | |
| for x in e["related"][:4]) | |
| if rel: | |
| out.append(f" - _linked to:_ {rel}") | |
| if e.get("url"): | |
| out.append(f" - [more]({e['url']})") | |
| return "\n".join(out) | |
| # global | |
| out = ["**Across the whole service:**\n"] | |
| for c in r["communities"]: | |
| out.append(f"- **{c['title']}** - {c['report'][:200]}") | |
| return "\n".join(out) | |
| def render_where_to_get(r): | |
| if not r.get("found"): | |
| rt = r["routes"][0] | |
| return (f"I couldn't find **{r['query']}** in the catalogue. {rt['advice']}\n\n" | |
| f"[Try the catalogue yourself]({r['search_url']})") | |
| out = [f"**{r['best_title']}**" + (f" - {r['author']}" if r["author"] else "") | |
| + f" · held as: {', '.join(r['formats_held'])}\n"] | |
| for rt in r["routes"]: | |
| if rt["route"] == "digital": | |
| link = ("[Open it in BorrowBox]" if rt["direct_link"] | |
| else "[Open BorrowBox (Worcestershire)]") | |
| out.append(f"**Borrow the {rt['format']} free tonight** - " | |
| f"{link}({rt['url']})") | |
| out += [f" {i}. {s}" for i, s in enumerate(rt["steps"], 1)] | |
| out.append(f" - {rt['need']}") | |
| elif rt["route"] == "shelf": | |
| out.append(f"**{rt['format']} - on the shelf right now at:**") | |
| for c in rt["copies"]: | |
| call = f" · shelf mark `{c['call_number']}`" if c["call_number"] else "" | |
| out.append(f" - **{c['branch']}**{call} - _{c['status']}_") | |
| out.append(f" - {rt['need']}") | |
| out.append(f" - [Item page / all copies]({rt['url']})") | |
| elif rt["route"] == "reserve": | |
| out.append(f"**{rt['format']} - no copy on a shelf just now: " | |
| f"reserve it free.**") | |
| if rt.get("copies"): | |
| spots = "; ".join(f"{c['branch']}: {c['status']}" | |
| for c in rt["copies"][:5]) | |
| out.append(f" - Current copies - {spots}") | |
| elif not rt.get("availability_parsed"): | |
| out.append(" - _(I couldn't read live copy status this time - " | |
| "the item page below shows it.)_") | |
| out += [f" {i}. {s}" for i, s in enumerate(rt["steps"], 1)] | |
| out.append(f" - {rt['need']}") | |
| out.append(f" - [Reserve here]({rt['url']})") | |
| else: | |
| out.append(f"{rt.get('advice', '')}") | |
| if r.get("other_matches"): | |
| out.append(f"\n_Similar in the catalogue:_ {' · '.join(r['other_matches'])}") | |
| out.append(f"\n[Full search results]({r['search_url']})") | |
| return "\n".join(out) | |
| def render_hive(r): | |
| hours = r.get("opening_hours", "") | |
| out = [f"**{r['name']}** - Worcester's library, run jointly by the " | |
| f"University of Worcester and Worcestershire County Council."] | |
| if hours: | |
| out.append(f"**{hours}**") | |
| if r.get("kind") == "overview": | |
| out.append(f"{r.get('address', '')}\n") | |
| out.append("**More than a branch:**") | |
| for c in r.get("capabilities", [])[:9]: | |
| if isinstance(c, dict): | |
| src = f" - [source]({c['source']})" if c.get("source") else "" | |
| out.append(f"- **{c.get('capability', '')}** - " | |
| f"{c.get('detail', '')[:160]}{src}") | |
| out.append(f"\n{ls.ELIGIBILITY['hive_visit']}") | |
| else: | |
| for p in r.get("pages", []): | |
| out.append(f"\n**[{p['title']}]({p['url']})** - {p['summary'][:180]}") | |
| for o in p.get("offerings", [])[:6]: | |
| out.append(f" - {o}") | |
| for n in p.get("what_you_need", [])[:2]: | |
| out.append(f" - {n}") | |
| d = p.get("details", {}) | |
| for k, label in (("hours", ""), ("prices", ""), | |
| ("location_in_building", ""), ("contact", "")): | |
| if d.get(k): | |
| out.append(f" - {label} {d[k][:140]}") | |
| if p.get("notes"): | |
| out.append(f" - _{p['notes']}_") | |
| for c in r.get("capabilities", [])[:3]: | |
| if isinstance(c, dict): | |
| out.append(f"\n**{c.get('capability', '')}** - " | |
| f"{c.get('detail', '')[:160]}") | |
| if not r.get("pages") and not r.get("capabilities"): | |
| out.append("\nI don't have a Hive page on that - try the council " | |
| f"pages, or browse [thehiveworcester.org]({r['page_url']}).") | |
| as_of = (r.get("as_of") or "")[:10] | |
| out.append(f"\n<sub>From the Hive's own pages (crawled {as_of}). For live " | |
| "events, membership and prices the council site is the " | |
| "authority - ask me and I'll check it live.</sub>") | |
| out.append(f"[thehiveworcester.org]({r['page_url']})") | |
| return "\n".join(out) | |
| def render_account_and_loans(r): | |
| focus = r.get("focus", "general") | |
| out = [] | |
| if focus == "computer" and r.get("computer_booking"): | |
| cb = r["computer_booking"] | |
| out = ["**Public computers at Worcestershire libraries:**\n", cb["summary"], | |
| f"\n**What you need:** {cb['what_you_need']}"] | |
| for step in cb.get("how_to", []): | |
| out.append(f"- {step}") | |
| out.append(f"\n[Book a computer session]({cb['url']})") | |
| return "\n".join(out) | |
| if focus == "volunteer" and r.get("volunteering"): | |
| vol = r["volunteering"] | |
| out = ["**Volunteering at Worcestershire Libraries:**\n", vol["summary"], | |
| f"\n**How to get involved:** {vol['what_you_need']}"] | |
| if vol.get("also_see"): | |
| out.append(f"\n{vol['also_see']}") | |
| out.append(f"\n[Discover volunteering opportunities]({vol['url']})") | |
| return "\n".join(out) | |
| if focus == "digital_skills" and r.get("digital_skills"): | |
| ds = r["digital_skills"] | |
| out = ["**Free digital skills support at your library:**\n", ds["summary"], | |
| f"\n**Entry:** {ds['what_you_need']}"] | |
| for svc in ds.get("services", []): | |
| out.append(f"- {svc}") | |
| out.append(f"\n[Digital Inclusion - Helping You Online]({ds['url']})") | |
| return "\n".join(out) | |
| if focus == "reading_well" and r.get("reading_well"): | |
| rw = r["reading_well"] | |
| out = ["**Reading Well - free wellbeing books at your library:**\n", | |
| rw["summary"], f"\n**What you need:** {rw['what_you_need']}"] | |
| for col in rw.get("collections", []): | |
| out.append(f"- {col}") | |
| out.append(f"\n[Reading Well collections]({rw['url']})") | |
| return "\n".join(out) | |
| if focus == "room_hire" and r.get("room_hire_data"): | |
| rh = r["room_hire_data"] | |
| out = ["**Meeting rooms for hire at Worcestershire libraries:**\n", | |
| rh["summary"], f"\n**What you need:** {rh['what_you_need']}"] | |
| for step in rh.get("how_to", []): | |
| out.append(f"- {step}") | |
| if rh.get("also_see"): | |
| out.append(f"\n{rh['also_see']}") | |
| out.append(f"\n[Hire a library meeting room]({rh['url']})") | |
| return "\n".join(out) | |
| if focus == "warm_space" and r.get("warm_space"): | |
| ws = r["warm_space"] | |
| out = ["**Warm, welcoming spaces - free at every library:**\n", ws["summary"], | |
| f"\n**Entry:** {ws['what_you_need']}"] | |
| if ws.get("also_see"): | |
| out.append(f"\n{ws['also_see']}") | |
| out.append(f"\n[Warm Welcome]({ws['url']})") | |
| return "\n".join(out) | |
| if focus == "adult_learning" and r.get("adult_learning"): | |
| al = r["adult_learning"] | |
| out = ["**Adult learning & skills - free at Worcestershire Libraries:**\n", | |
| al["summary"], f"\n**What you need:** {al['what_you_need']}\n"] | |
| for i, step in enumerate(al["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| out.append(f"\n_{al['also_see']}_") | |
| out.append(f"\n[Learn, Upskill and Find Work]({al['url']})") | |
| return "\n".join(out) | |
| if focus == "book_clubs_reading" and r.get("book_clubs_reading"): | |
| bc = r["book_clubs_reading"] | |
| out = ["**Book clubs & reading groups:**\n", bc["summary"], | |
| f"\n**What you need:** {bc['what_you_need']}\n"] | |
| for i, step in enumerate(bc["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| if bc.get("also_see"): | |
| out.append(f"\n_{bc['also_see']}_") | |
| out.append(f"\n[Library events & activities]({bc['url']})") | |
| return "\n".join(out) | |
| if focus == "donations" and r.get("donations"): | |
| d = r["donations"] | |
| out = ["**Donating books to the library:**\n", d["summary"], | |
| f"\n**Please note:** {d['what_you_need']}\n"] | |
| for i, step in enumerate(d["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| out.append(f"\n[Worcestershire Libraries]({d['url']})") | |
| return "\n".join(out) | |
| if focus == "school_visits" and r.get("school_visits"): | |
| sv = r["school_visits"] | |
| out = ["**School & group visits to Worcestershire Libraries:**\n", sv["summary"], | |
| f"\n**What to do:** {sv['what_you_need']}\n"] | |
| for i, step in enumerate(sv["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| if sv.get("also_see"): | |
| out.append(f"\n_{sv['also_see']}_") | |
| out.append(f"\n[Children's library services]({sv['url']})") | |
| return "\n".join(out) | |
| if focus == "accessible_formats" and r.get("accessible_formats"): | |
| af = r["accessible_formats"] | |
| out = ["**Accessible reading at Worcestershire Libraries:**\n", af["summary"], | |
| f"\n**What you need:** {af['what_you_need']}\n"] | |
| for opt in af.get("options", []): | |
| out.append(f"- {opt}") | |
| if af.get("also_see"): | |
| out.append(f"\n_{af['also_see']}_") | |
| if af.get("catalogue_tip"): | |
| out.append(f"\n_{af['catalogue_tip']}_") | |
| out.append(f"\n[Read & Discover]({af['url']})") | |
| return "\n".join(out) | |
| if focus == "update_details" and r.get("update_details"): | |
| ud = r["update_details"] | |
| out = ["**Update your library account details:**\n", ud["summary"], "\n"] | |
| for step in ud.get("how_to", []): | |
| out.append(f"- {step}") | |
| if ud.get("also_see"): | |
| out.append(f"\n_{ud['also_see']}_") | |
| out.append(f"\n[Library account login]({ud['url']})") | |
| return "\n".join(out) | |
| if focus == "lost_card" and r.get("lost_card"): | |
| lc = r["lost_card"] | |
| out = ["**Lost or stolen library card:**\n", lc["summary"], "\n"] | |
| for i, step in enumerate(lc["what_to_do"], 1): | |
| out.append(f"{i}. {step}") | |
| if lc.get("also_see"): | |
| out.append(f"\n_{lc['also_see']}_") | |
| out.append(f"\n[Fees and charges]({lc['url']})") | |
| return "\n".join(out) | |
| if focus == "card_expired" and r.get("card_expired"): | |
| ce = r["card_expired"] | |
| out = ["**Library card or membership expired:**\n", ce["summary"], "\n"] | |
| out.append(f"**What you need:** {ce['what_you_need']}\n") | |
| for i, step in enumerate(ce["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| if ce.get("also_see"): | |
| out.append(f"\n_{ce['also_see']}_") | |
| out.append(f"\n[Join or renew membership]({ce['url']})") | |
| return "\n".join(out) | |
| if focus == "wifi" and r.get("wifi_access"): | |
| wa = r["wifi_access"] | |
| out = ["**Free Wi-Fi at Worcestershire Libraries:**\n", wa["summary"], "\n"] | |
| out.append(f"**What you need:** {wa['what_you_need']}\n") | |
| out.append("**How to connect:**") | |
| for step in wa["how_to"]: | |
| out.append(f"- {step}") | |
| if wa.get("also_see"): | |
| out.append(f"\n_{wa['also_see']}_") | |
| out.append(f"\n[Worcestershire Libraries]({wa['url']})") | |
| return "\n".join(out) | |
| if focus == "self_service" and r.get("self_service"): | |
| ss = r["self_service"] | |
| out = ["**Self-service kiosks at Worcestershire Libraries:**\n", ss["summary"], "\n"] | |
| out.append(f"**What you need:** {ss['what_you_need']}\n") | |
| out.append("**To borrow (check out):**") | |
| for step in ss["how_to_borrow"]: | |
| out.append(f"- {step}") | |
| out.append("\n**To return (check in):**") | |
| for step in ss["how_to_return"]: | |
| out.append(f"- {step}") | |
| if ss.get("also_see"): | |
| out.append(f"\n_{ss['also_see']}_") | |
| out.append(f"\n[Your library account]({ss['url']})") | |
| return "\n".join(out) | |
| if focus == "ill" and r.get("ill_service"): | |
| ill = r["ill_service"] | |
| out = ["**Inter-library loans - getting a book from another library service:**\n", | |
| ill["summary"], f"\n**What you need:** {ill['what_you_need']}\n"] | |
| for i, step in enumerate(ill["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| if ill.get("also_see"): | |
| out.append(f"\n_{ill['also_see']}_") | |
| out.append(f"\n[Worcestershire Libraries]({ill['url']})") | |
| return "\n".join(out) | |
| if focus == "child_membership" and r.get("child_membership"): | |
| cm = r["child_membership"] | |
| out = ["**Children's library membership - free at any age:**\n", cm["summary"], "\n"] | |
| out.append(f"**What you need:** {cm['what_you_need']}\n") | |
| out.append("**How to join:**") | |
| for i, step in enumerate(cm["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| if cm.get("also_see"): | |
| out.append(f"\n_{cm['also_see']}_") | |
| out.append(f"\n[Join the library - free]({cm['url']})") | |
| return "\n".join(out) | |
| if focus == "suggest_purchase" and r.get("suggest_purchase"): | |
| sp = r["suggest_purchase"] | |
| out = ["**Suggest a book for the library to buy:**\n", sp["summary"], "\n"] | |
| out.append(f"**What you need:** {sp['what_you_need']}\n") | |
| for i, step in enumerate(sp["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| if sp.get("also_see"): | |
| out.append(f"\n_{sp['also_see']}_") | |
| out.append(f"\n[Ask for a Book / suggest a purchase]({sp['url']})") | |
| return "\n".join(out) | |
| if focus == "library_contact" and r.get("library_contact"): | |
| lc = r["library_contact"] | |
| out = ["**Contacting Worcestershire Libraries:**\n", lc["summary"], "\n"] | |
| for step in lc["how_to"]: | |
| out.append(f"- {step}") | |
| if lc.get("also_see"): | |
| out.append(f"\n_{lc['also_see']}_") | |
| out.append(f"\n[Worcestershire Libraries]({lc['url']})") | |
| return "\n".join(out) | |
| if focus == "library_closures" and r.get("library_closures"): | |
| cl = r["library_closures"] | |
| out = ["**Library closures & bank holidays:**\n", cl["summary"], "\n", | |
| f"_{cl['bank_holiday_note']}_\n", | |
| "**How to check for closures:**"] | |
| for step in cl["how_to_check"]: | |
| out.append(f"- {step}") | |
| out.append(f"\n[2026 library closing dates]({cl['url']})") | |
| return "\n".join(out) | |
| if focus == "building_accessibility" and r.get("building_accessibility"): | |
| ba = r["building_accessibility"] | |
| out = ["**Accessibility at Worcestershire Libraries:**\n", ba["summary"], "\n", | |
| "**Features available at most branches:**"] | |
| for feat in ba["common_features"]: | |
| out.append(f"- {feat}") | |
| out.append(f"\n_{ba['note']}_") | |
| out.append(f"\n_{ba['accessible_formats_tip']}_") | |
| out.append(f"\n[Find a library]({ba['url']})") | |
| return "\n".join(out) | |
| if focus == "teen_services" and r.get("teen_services"): | |
| ts = r["teen_services"] | |
| out = ["**Library services for teenagers & young adults:**\n", ts["summary"], "\n"] | |
| for item in ts["highlights"]: | |
| out.append(f"- {item}") | |
| out.append(f"\n**What you need:** {ts['what_you_need']}") | |
| out.append(f"\n[Library events & activities]({ts['events_url']})") | |
| return "\n".join(out) | |
| if focus == "community_information" and r.get("community_information"): | |
| ci = r["community_information"] | |
| out = ["**Libraries as community hubs:**\n", ci["summary"], "\n"] | |
| for item in ci["what_we_offer"]: | |
| out.append(f"- {item}") | |
| out.append(f"\n**For organisations:** {ci['for_organisations']}") | |
| out.append(f"\n_{ci['note']}_") | |
| out.append(f"\n[Worcestershire Libraries]({ci['url']})") | |
| return "\n".join(out) | |
| if focus == "multilingual" and r.get("multilingual"): | |
| ml = r["multilingual"] | |
| out = ["**Multilingual & community language resources:**\n", ml["summary"], "\n"] | |
| for item in ml["what_we_offer"]: | |
| out.append(f"- {item}") | |
| out.append(f"\n{ml['joining']}") | |
| out.append(f"\n[Online Library Hub]({ml['url']})") | |
| return "\n".join(out) | |
| if focus == "library_app" and r.get("library_app"): | |
| la = r["library_app"] | |
| out = ["**Library apps & mobile access:**\n", la["summary"], "\n"] | |
| for app in la["apps"]: | |
| out.append(f"- {app}") | |
| out.append(f"\n[Online Library Hub]({la['url']})") | |
| return "\n".join(out) | |
| if focus == "loan_limits": | |
| ll = r["loan_limits"] | |
| out = ["**Borrowing periods & limits:**\n", ll["summary"], | |
| f"\n- **Physical items:** {ll['loan_period']}", | |
| f"- **Digital (BorrowBox):** {ll['digital']}", | |
| f"- **Renewals:** {ll.get('renewals', r['renewals']['summary'])}", | |
| f"\n[Membership & borrowing]({ll['url']})"] | |
| return "\n".join(out) | |
| if focus == "pin_reset": | |
| pr = r["pin_reset"] | |
| out = ["**Forgotten your PIN?**\n", f"_{pr['summary']}_\n"] | |
| for step in pr["how_to"]: | |
| out.append(f"- {step}") | |
| out += [f"\n_{pr['note']}_", | |
| f"\n[Login to your library account]({pr['url']})"] | |
| return "\n".join(out) | |
| if focus == "job_clubs" and r.get("job_clubs"): | |
| jc = r["job_clubs"] | |
| out = ["**Library Job Clubs - free employment support:**\n", jc["summary"], | |
| f"\n**What you need:** {jc['what_you_need']}\n"] | |
| for i, step in enumerate(jc["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| out.append(f"\n_{jc['also_see']}_") | |
| out.append(f"\n[Job Clubs]({jc['url']})") | |
| return "\n".join(out) | |
| if focus == "home" and r.get("home_library"): | |
| h = r["home_library"] | |
| out += [ | |
| "**Library Service at Home**\n", | |
| h["summary"], | |
| f"\n**What you need:** {h['what_you_need']}", | |
| f"**Contact:** {h['contact']}", | |
| f"\n{h['also_see']}", | |
| f"\n[Library Service at Home]({h['url']})", | |
| ] | |
| return "\n".join(out) | |
| if focus == "returning": | |
| ret = r["returning"] | |
| out += ["**Returning library items:**\n", f"_{ret['summary']}_\n"] | |
| for m in ret["methods"]: | |
| out.append(f"- {m}") | |
| out += [f"\n_{ret['note']}_", | |
| f"\n[Your library account]({ret['url']})"] | |
| return "\n".join(out) | |
| if focus == "lost_item": | |
| li = r["lost_item"] | |
| out += ["**Lost or damaged library item:**\n", f"_{li['summary']}_\n"] | |
| for step in li["what_to_do"]: | |
| out.append(f"- {step}") | |
| out.append(f"\n[Fees and charges]({li['url']})") | |
| return "\n".join(out) | |
| if focus == "ask_book" and r.get("ask_book"): | |
| ab = r["ask_book"] | |
| out += ["**Ask for a Book - free personalised recommendations:**\n", | |
| ab["summary"], f"\n**What you need:** {ab['what_you_need']}\n"] | |
| for i, step in enumerate(ab["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| out.append(f"\n[Ask for a Book]({ab['url']})") | |
| return "\n".join(out) | |
| if focus == "renewals": | |
| ren = r["renewals"] | |
| out += ["**Renewing your loans:**\n", f"_{ren['summary']}_\n"] | |
| for m in ren["methods"]: | |
| out.append(f"- {m}") | |
| out += [f"\n_{ren['note']}_", | |
| f"\n[Renew a loan online]({ren['url']})"] | |
| elif focus == "fines": | |
| f_data = r["fines"] | |
| out += ["**Library fees and charges:**\n", f_data["summary"], "\n", | |
| "**Types of charge:**"] | |
| for charge in f_data.get("charges_breakdown", []): | |
| out.append(f"- {charge}") | |
| out += [f"\n**How to pay:** {f_data['how_to_pay']}", | |
| f"\n_{f_data['note']}_", | |
| f"\n[Fees and charges]({f_data['url']})"] | |
| elif focus == "reservations": | |
| res = r["reservations"] | |
| out += ["**Reserving items:**\n", | |
| f"_{res['summary']}_ **{res['cost']}**\n"] | |
| for i, step in enumerate(res["how_to"], 1): | |
| out.append(f"{i}. {step}") | |
| if res.get("hold_duration"): | |
| out.append(f"\n**Hold period:** {res['hold_duration']}") | |
| if res.get("if_missed"): | |
| out.append(f"\n_{res['if_missed']}_") | |
| if res.get("how_to_cancel"): | |
| out.append(f"\n**To cancel a reservation:** {res['how_to_cancel']}") | |
| out.append(f"\n[Reserve library books]({res['url']})") | |
| elif focus == "account": | |
| acc = r["account"] | |
| out += ["**Your library account:**\n", acc["summary"], | |
| f"\n**What you need:** {acc['what_you_need']}"] | |
| if acc.get("pin_reset"): | |
| out.append(f"\n**Forgotten your PIN?** {acc['pin_reset']}") | |
| out.append(f"\n{acc['how_to']}") | |
| else: # general | |
| acc, ren = r["account"], r["renewals"] | |
| f_data, res = r["fines"], r["reservations"] | |
| out += [ | |
| "**Your library account - manage it online:**\n", | |
| f"**[Sign in to your account]({acc['url']})** - {acc['summary']} " | |
| f"_{acc['what_you_need']}_", | |
| f"**[Renew loans]({ren['url']})** - {ren['summary']}", | |
| f"**[Reserve items]({res['url']})** - {res['summary']} {res['cost']}", | |
| f"**[Fees & charges]({f_data['url']})** - {f_data['summary']}", | |
| f"\n[Your library membership]({r['page_url']})", | |
| ] | |
| if r.get("home_library") and focus!= "home": | |
| h = r["home_library"] | |
| out.append(f"\n_Can't get to a library?_ **[Library Service at Home]" | |
| f"({h['url']})** - volunteer-run home delivery of books.") | |
| return "\n".join(out) | |
| def render_children(r): | |
| out = ["**Children's library services in Worcestershire**\n", r["summary"], | |
| f"\n**What you need:** {r['what_you_need']}\n", "**What's available:**"] | |
| for item in r["highlights"]: | |
| out.append(f"- {item}") | |
| out += [f"\n[Events & activities - what's on near you]({r['events_url']})", | |
| f"[Children's eBooks & audiobooks on BorrowBox]({r['borrowbox_url']})", | |
| f"[Join the library free]({r['join_url']})"] | |
| return "\n".join(out) | |
| RENDER = { | |
| "search_catalogue": render_catalogue, "whats_new": render_whats_new, | |
| "where_to_get": render_where_to_get, "hive_info": render_hive, | |
| "find_library": render_find_library, "mobile_library": render_mobile, | |
| "library_events": render_events, "online_hub": render_online_hub, | |
| "libraries_unlocked": render_unlocked, "membership_help": render_membership, | |
| "printing_help": render_printing, "graph_search": render_graph, | |
| "account_and_loans": render_account_and_loans, | |
| "children_services": render_children, | |
| } | |
| # --------------------------------------------------------------------------- # | |
| # Behaviour-change layer - £-saved value receipt + EAST nudges + chips | |
| # --------------------------------------------------------------------------- # | |
| def value_receipt(tool, raw): | |
| if tool in ("search_catalogue", "whats_new") and raw.get("items"): | |
| return "_Borrowing instead of buying ≈ **£9–£20 saved** per title._" | |
| if tool == "where_to_get" and raw.get("found"): | |
| return "_Getting it from the library instead of buying ≈ **£9–£20 saved**._" | |
| if tool == "online_hub": | |
| items = raw.get("items", []) | |
| if items: | |
| n = items[0].get("name", "").lower() | |
| if "pressreader" in n: | |
| return "_PressReader is typically **~£9.99/month** - you pay nothing with a library card._" | |
| if "theory test" in n: | |
| return "_Theory Test Pro costs **£29.99** to buy - included in your free library card._" | |
| if "which" in n: | |
| return "_Which? costs **£10.99/month** to subscribe - not a penny with a library card._" | |
| if "borrowbox" in n or "ebook" in n or "audiobook" in n: | |
| return "_eBooks and audiobooks run **£9–£15 each** to buy - £0 with a library card._" | |
| return ("_A newspaper or eBook subscription runs **~£8–£12/month** - " | |
| "yours at no cost with a library card._") | |
| if tool == "printing_help": | |
| return "_Far cheaper than a high-street print shop._" | |
| return "" | |
| # (EAST: Easy=chips, Attractive=value, Social/Timely=nudge) | |
| NUDGES = { | |
| "search_catalogue": ("No time to visit? Many titles are free on **BorrowBox** tonight.", | |
| ["Is {title} on BorrowBox?", "Reserve & collect - how?", "Hot takes on new books"]), | |
| "whats_new": ("Reserve it free and collect at your branch.", | |
| ["More like this", "Is {title} an eBook?", "What's on this week?"]), | |
| "find_library": (f"Want in before/after staffed hours? " | |
| f"[**Libraries Unlocked**]({ls.UNLOCKED_URL}) = 8am–8pm.", | |
| ["Tell me about Libraries Unlocked", "What's on at {place}?", "How do I join?"]), | |
| "mobile_library": ("Housebound? The **Home Library Service** brings books to your door.", | |
| ["Home Library Service", "How do I join?", "Find my nearest library"]), | |
| "library_events": ("Most events are free - just turn up.", | |
| ["Children's events", "Do I need to book?", "Find my nearest library"]), | |
| "online_hub": ("Worth **~£10/month** in subscriptions - £0 with a library card. Set up tonight.", | |
| ["How do I sign up?", "What newspapers are there?", "BorrowBox limits"]), | |
| "libraries_unlocked": ("It's free - just a quick one-off induction.", | |
| ["How do I join the library?", "Is Malvern library open now?", | |
| "What's on this week?"]), | |
| "printing_help": ("No printer at home? Print from your phone, collect within 24h.", | |
| ["Printing prices", "Find my nearest library", "How do I join?"]), | |
| "membership_help": ("Digital membership is instant - no card needed.", | |
| ["Set up digital membership", "What's the difference?", "What can I borrow online?"]), | |
| "graph_search": ("Tell me what matters (late, café, study space) and I'll match a branch.", | |
| ["Late-opening + café", "Find a book", "What's on this week?"]), | |
| "where_to_get": ("Reservations are free - collect at any branch, or the van.", | |
| ["Is it on BorrowBox?", "Which branch is nearest?", "How do I join?"]), | |
| "hive_info": ("The Hive is open 8:30am–10pm every single day - and anyone can walk in.", | |
| ["The Hive's archives", "Hire a room at the Hive", "Is the Hive open now?"]), | |
| "account_and_loans": ( | |
| "Renewing is quickest online - no queue, no trip to the library.", | |
| ["How do I renew my books?", "How do I make a reservation?", "Can you suggest a book for me?"]), | |
| "children_services": ( | |
| "Most children's sessions are free and need no booking - just turn up.", | |
| ["What's on this week?", "Children's eBooks", "Find my nearest library"]), | |
| } | |
| HELP_CHIPS = ["How do I get Wolf Hall?", "What's at The Hive?", | |
| "What's on this week?"] | |
| # --------------------------------------------------------------------------- # | |
| # Synthesis | |
| # --------------------------------------------------------------------------- # | |
| SYNTH_SYSTEM = ( | |
| "You are the Worcestershire Libraries assistant. Answer ONLY from the LIVE " | |
| "DATA provided - never invent titles, times, prices, stops or facilities. Warm, " | |
| "concise, British English. Keep the markdown links and the /lines from the " | |
| "data. Lead with what the person CAN do: if a service meets their need in a " | |
| "different way (e.g. they can't print AT home, but can send a job from home " | |
| "and collect it at any branch), open with that - never with a bare 'no'. " | |
| "Keep any provenance footnote (e.g. 'From the Hive's own pages') intact. " | |
| "If the data doesn't answer it, say so and point to the source link.") | |
| def synthesize_stream(question, rendered, model_id: str = None): | |
| if not HF_TOKEN: | |
| yield rendered | |
| return | |
| try: | |
| stream = llm([{"role": "system", "content": SYNTH_SYSTEM}, | |
| {"role": "user", "content": f"Question: {question}\n\n" | |
| f"LIVE DATA:\n{rendered}"}], | |
| model_id=model_id, max_tokens=650, temperature=0.3, stream=True) | |
| acc = "" | |
| for chunk in stream: | |
| d = chunk.choices[0].delta.content or "" | |
| if d: | |
| acc += d | |
| yield acc | |
| if not acc.strip(): | |
| yield rendered | |
| except Exception: | |
| yield rendered | |
| HELP = ( | |
| "I'm the **Worcestershire Libraries assistant**. I check the council site " | |
| "and catalogue *live* and can help you:\n\n" | |
| "- **Get a specific book** - which branch has it on the shelf *right now*, " | |
| "borrow the eBook tonight, or request it from another library service\n" | |
| "- **Account & loans** - renew books online, reserve or cancel items, " | |
| "return items, pay fines, lost or stolen card, lost/damaged item\n" | |
| "- **Lost, stolen or expired library card** - how to report it, get a replacement, renew membership\n" | |
| "- **Library Wi-Fi** - free, no password needed, how to connect\n" | |
| "- **Self-service kiosks** - how to borrow and return items without staff\n" | |
| "- **Inter-library loans** - requesting items not held in Worcestershire\n" | |
| "- **Children & family membership** - join at any age, free, under-16 needs a parent\n" | |
| "- **Suggest a purchase** - ask the library to buy a specific title\n" | |
| "- **Contact the library** - central phone number and how to get in touch\n" | |
| "- **Ask for a Book** - free personalised reading recommendations from a librarian\n" | |
| "- **Branch hours, 'open now?', facilities** - toilets, parking, study space\n" | |
| "- **The Hive** (Worcester) - archives & archaeology, study spaces, room " | |
| "hire, open 8:30am–10pm daily\n" | |
| "- **Mobile library** times for your village\n" | |
| "- **Library Service at Home** - free home delivery for those who can't visit\n" | |
| "- **What's on** this week\n" | |
| "- **Free online** - newspapers (PressReader), eBooks (BorrowBox), family history (Ancestry), " | |
| "the OED, Oxford Reference, BFI films and more\n" | |
| "- **Computers & digital skills** - free public computers, Wi-Fi, and digital skills support\n" | |
| "- **Printing, photocopying & scanning** - Print Your Way from your phone, " | |
| "or walk-up copying/scanning in-branch\n" | |
| "- **Library closures & bank holidays** - 2026 closing dates and planned closures\n" | |
| "- **Building accessibility** - step-free, hearing loops, accessible toilets\n" | |
| "- **Room hire** - meeting rooms at libraries across the county\n" | |
| "- **Reading Well** - free curated books for mental health and wellbeing\n" | |
| "- **Book clubs & reading groups** - at various branches, free to join\n" | |
| "- **Volunteering, job clubs & adult learning** - free employment support " | |
| "and skills courses\n" | |
| "- **Children's services** - Storytime, Bounce and Rhyme, the Summer Reading Challenge\n" | |
| "- **Teens & young adults** - YA fiction, free eBooks/audiobooks via BorrowBox, " | |
| "teen volunteering, Libraries Unlocked for 15+\n" | |
| "- **School & group visits** - arrange a class visit or library tour\n" | |
| "- **Accessible formats** - large print, eAudiobooks, talking newspapers, " | |
| "Braille and specialist services\n" | |
| "- **Library apps** - BorrowBox, PressReader, and your account on mobile\n" | |
| "- **Warm spaces** - free, no membership needed\n" | |
| "- **Multilingual resources** - books and digital content in 60+ languages\n" | |
| "- **Community noticeboards** - local groups, signposting and community info\n\n" | |
| "_Answers come from official sources - the council site and catalogue " | |
| "checked live, plus every page of the Hive's own site - and each answer " | |
| "names its source._") | |
| # --------------------------------------------------------------------------- # | |
| # Chat handler - yields (answer_text, chips_or_None) | |
| # --------------------------------------------------------------------------- # | |
| # Strip em-dashes and emoji from anything shown in the UI. The model itself | |
| # emits em-dashes/emoji and live council/catalogue text can too, so static file | |
| # cleanup is not enough -- this guarantees a clean UI whatever the source. | |
| # Typographic arrows (→ ← in the trace) and · ● £ … are deliberately kept. | |
| _EMOJI_RE = re.compile( | |
| "(?:[\U0001F000-\U0001FAFF☀-➿⬀-⯿]" | |
| "|[←-⇿⌀-⏿™ℹⓂ〰〽㊗㊙](?=️))" | |
| "[️\U0001F3FB-\U0001F3FF⃣]*" | |
| ) | |
| def _strip_decor(s: str) -> str: | |
| if not s: | |
| return s | |
| s = s.replace("—", "-") # em-dash -> hyphen | |
| s = _EMOJI_RE.sub("", s) # emoji clusters | |
| s = re.sub("[️⃣]", "", s) # orphan modifiers | |
| s = re.sub(r"(?<=\S) +", " ", s) # collapse interior double spaces | |
| return s | |
| def respond(message, history, model_label: str = None): | |
| model_id = MODEL_IDS.get(model_label) or MODEL_ID | |
| message = (message or "").strip() | |
| if not message: | |
| yield HELP, HELP_CHIPS | |
| return | |
| tr = Trace(message, model_id) | |
| tool, args, how, rms = route(message, history, model_id=model_id) | |
| tr.set_route(tool, args, how, rms) | |
| if tool == "none": | |
| tr.finish(HELP, []).save() | |
| yield HELP, HELP_CHIPS | |
| return | |
| t1 = time.time() | |
| try: | |
| raw = TOOLS[tool]["fn"](args) | |
| except Exception as e: | |
| tr.step("tool_call", name=tool, ok=False, error=str(e)).finish("", []).save() | |
| yield (f"Sorry - I couldn't reach the library source just now. " | |
| f"Please try again in a moment."), None | |
| return | |
| rendered = RENDER[tool](raw) | |
| source = raw.get("page_url") or raw.get("search_url") or "" | |
| tr.step("tool_call", name=tool, source=source, ms=_ms(t1), ok=True) | |
| t2 = time.time() | |
| answer = "" | |
| for partial in synthesize_stream(message, rendered, model_id=model_id): | |
| answer = _strip_decor(partial) | |
| yield answer, None | |
| tr.step("synthesis", model=model_id, ms=_ms(t2)) | |
| # behaviour-change extras + provenance + open trace | |
| value = value_receipt(tool, raw) | |
| nudge, chips = NUDGES.get(tool, ("", [])) | |
| # Chips carry concrete names, not pronouns, so the follow-up routes | |
| # correctly even in no-LLM mode. A chip whose slot we can't fill is dropped. | |
| slots = {} | |
| if raw.get("name"): | |
| slots["place"] = re.sub(r"\s+Library$", "", raw["name"]) | |
| if raw.get("items"): | |
| t = (raw["items"][0].get("title") or "").strip() | |
| if t: | |
| slots["title"] = t if len(t) <= 30 else t[:29] + "…" | |
| def _fill(c): | |
| try: | |
| return c.format(**slots) | |
| except (KeyError, IndexError): | |
| return None | |
| chips = [c for c in map(_fill, chips) if c] | |
| checked = raw.get("checked", "") | |
| label = ("page-level KB" if tool == "hive_info" and raw.get("as_of") | |
| else "**live**") | |
| footer = (f"\n\n<sub>Checked {label}" | |
| + (f" · {checked}" if checked else "") | |
| + f" · tool `{tool}` ({how})" | |
| + (f" · [source]({source})" if source else "") + "</sub>") | |
| tr.finish(answer, [source]).save() | |
| final = answer | |
| if value: | |
| final += f"\n\n{value}" | |
| if nudge: | |
| final += f"\n\n{nudge}" | |
| final += footer + tr.to_markdown() | |
| yield final, chips | |
| # --------------------------------------------------------------------------- # | |
| # UI | |
| # --------------------------------------------------------------------------- # | |
| CSS = """ | |
| :root { --wcc:#0a7d78; --wcc-dark:#075a56; } | |
| .gradio-container { max-width: 940px!important; margin: auto!important; | |
| font-family:'Segoe UI', system-ui, sans-serif; } | |
| #hero { background:linear-gradient(135deg,var(--wcc),var(--wcc-dark)); color:#fff; | |
| padding:22px 26px; border-radius:16px; margin-bottom:12px; | |
| box-shadow:0 6px 20px rgba(7,90,86,.25); } | |
| #hero h1 { margin:0; font-size:1.5rem; } | |
| #hero p { margin:.4rem 0 0; opacity:.92; font-size:.93rem; } | |
| #hero.live { display:inline-block; background:#fff; color:var(--wcc-dark); | |
| font-weight:700; font-size:.7rem; padding:2px 9px; border-radius:999px; | |
| margin-bottom:8px; text-transform:uppercase; letter-spacing:.5px; } | |
| footer { visibility:hidden; } | |
| .prose a { color:var(--wcc-dark); font-weight:600; } | |
| """ | |
| def build_demo(): | |
| with gr.Blocks(title="Worcestershire Libraries - Live Assistant") as demo: | |
| gr.HTML("<div id='hero'><span class='live'>● live data</span>" | |
| "<h1>Worcestershire Libraries - Live Assistant</h1>" | |
| "<p>Exactly where to get the book you want, everything The Hive " | |
| "offers, mobile library, events, printing and what's free online - " | |
| "from the council site, catalogue & every Hive page, with exactly " | |
| "what you need to sign up.</p></div>") | |
| chat = gr.Chatbot(height=460, show_label=False, | |
| placeholder="Ask me anything about your local library…", | |
| elem_classes=["prose"]) | |
| with gr.Row(): | |
| box = gr.Textbox(placeholder="e.g. When does the mobile library visit Abberley?", | |
| show_label=False, scale=8, autofocus=True) | |
| send = gr.Button("Ask", variant="primary", scale=1) | |
| with gr.Row(): | |
| chips = [gr.Button(visible=False, size="sm", variant="secondary") | |
| for _ in range(3)] | |
| gr.Examples( | |
| ["How do I get Wolf Hall by Hilary Mantel?", | |
| "Can you suggest a book for me?", | |
| "How do I renew my library books?", | |
| "How do I reserve a book?", | |
| "How do I cancel a reservation?", | |
| "How do I return library books?", | |
| "Do you have Harry Potter audiobooks?", | |
| "What can I do at The Hive?", | |
| "Is Malvern library open now?", | |
| "Is there free Wi-Fi at the library?", | |
| "A late-opening library with a café and meeting rooms", | |
| "When does the mobile library visit Abberley?", | |
| "Can I read newspapers for free?", | |
| "Can I watch old British TV programmes for free?", | |
| "I can't get to the library - can books be delivered?", | |
| "Can I book a computer at the library?", | |
| "How do I access the Oxford English Dictionary?", | |
| "Are there books to help with mental health?", | |
| "How do I join the Summer Reading Challenge?", | |
| "Can I hire a meeting room at my local library?", | |
| "Can I volunteer at the library?", | |
| "I need help getting online - can the library help?", | |
| "I forgot my library PIN - what do I do?", | |
| "Are there adult learning courses at the library?", | |
| "Is there a book club at my local library?", | |
| "Can I donate books to the library?", | |
| "Do you have large print books?", | |
| "Can I arrange a school visit to the library?", | |
| "Do you have books for people with visual impairments?", | |
| "How do I change my address on my library account?", | |
| "Is there a Youth Hub at The Hive?", | |
| "I've lost my library card - what do I do?", | |
| "Is the library open on bank holidays?", | |
| "Are there any dementia-friendly activities at the library?", | |
| "Do you have activities for pre-school children?", | |
| "My library card has expired - how do I renew it?", | |
| "How do I connect to the library Wi-Fi?", | |
| "Can you get a book from another library service?", | |
| "How do I use the self-service machine to borrow books?", | |
| "Can my baby join the library?", | |
| "How do I suggest the library buys a specific book?", | |
| "How do I contact the library service?", | |
| "Can I photocopy at the library?", | |
| "Are Worcestershire libraries closed on bank holidays?", | |
| "Do the libraries have wheelchair access?", | |
| "Are there books for anxiety or depression?", | |
| "What's at the library for teenagers?", | |
| "Is there a library app I can use on my phone?", | |
| "Can I access the library outside opening hours?", | |
| "What are the library fines for overdue books?", | |
| "How long is a reservation held before it expires?", | |
| "Do you have books in Polish or Urdu?", | |
| "Can local groups display notices at the library?"], | |
| inputs=box, label="Try one") | |
| with gr.Accordion("Model (Llama / MiniCPM / Qwen)", open=True): | |
| model_dd = gr.Dropdown( | |
| choices=MODEL_LABELS, | |
| value=DEFAULT_LABEL, | |
| label="Small model", | |
| info=("Switch between sponsor & achievement models - all run via " | |
| "HF Inference API. Keyword routing works without any model."), | |
| interactive=bool(HF_TOKEN), | |
| ) | |
| if not HF_TOKEN: | |
| gr.Markdown("> No `HF_TOKEN` set - **no-LLM / offgrid mode**: " | |
| "keyword routing returns the raw live data (fully working, " | |
| "no external AI API). Add an `HF_TOKEN` secret for " | |
| "conversational phrasing and model switching.") | |
| def hide3(): | |
| return tuple(gr.update(visible=False) for _ in range(3)) | |
| def show3(sugg): | |
| sugg = (sugg or []) + ["", "", ""] | |
| return tuple(gr.update(value=sugg[i], visible=bool(sugg[i])) | |
| for i in range(3)) | |
| def user_turn(msg, hist): | |
| if not (msg or "").strip(): | |
| return "", hist or [], *hide3() | |
| return "", (hist or []) + [{"role": "user", "content": msg}], *hide3() | |
| def chip_turn(label, hist): | |
| return "", (hist or []) + [{"role": "user", "content": label}], *hide3() | |
| def bot_turn(hist, model_label): | |
| if not hist or hist[-1]["role"]!= "user": | |
| yield hist, *hide3() | |
| return | |
| msg = _content_text(hist[-1]["content"]) | |
| hist = hist + [{"role": "assistant", "content": ""}] | |
| final_chips = [] | |
| for text, ch in respond(msg, hist[:-1], model_label): | |
| hist[-1]["content"] = text | |
| if ch is not None: | |
| final_chips = ch | |
| yield hist, gr.update(), gr.update(), gr.update() | |
| yield hist, *show3(final_chips) | |
| outs = [chat, *chips] | |
| box.submit(user_turn, [box, chat], [box, chat, *chips], queue=False).then( | |
| bot_turn, [chat, model_dd], outs) | |
| send.click(user_turn, [box, chat], [box, chat, *chips], queue=False).then( | |
| bot_turn, [chat, model_dd], outs) | |
| for c in chips: | |
| c.click(chip_turn, [c, chat], [box, chat, *chips], queue=False).then( | |
| bot_turn, [chat, model_dd], outs) | |
| return demo | |
| if __name__ == "__main__": | |
| build_demo().queue().launch(server_name="0.0.0.0", server_port=7860, | |
| theme=gr.themes.Soft(primary_hue="teal"), | |
| css=CSS) | |