Spaces:
Runtime error
Runtime error
Sync from GitHub
Browse files- FIELD_NOTES.md +99 -0
- IDEAS.md +82 -0
- LICENSE +21 -0
- PLAN.md +100 -0
- README.md +74 -29
- SUBMISSION.md +122 -0
- app.py +572 -735
- build_kb.py +303 -0
- graph_build.py +209 -0
- graph_rag.py +170 -0
- index.html +167 -0
- library_graph.json +0 -0
- library_kb.json +1879 -0
- library_sources.py +716 -0
- requirements.txt +4 -3
- server.py +47 -0
- trace.py +88 -0
FIELD_NOTES.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# π Field Notes β building a live library assistant on a small model
|
| 2 |
+
|
| 3 |
+
*Build Small Hackathon Β· what we built, and what we learned doing it.*
|
| 4 |
+
|
| 5 |
+
## The problem came from a real person
|
| 6 |
+
|
| 7 |
+
We didn't start with a model β we started with **Jack**, who manages library
|
| 8 |
+
resources in Worcester. His complaint, almost verbatim: *"the library doesn't shout
|
| 9 |
+
loudly enough about everything it offers."* That's not a marketing gap, it's an
|
| 10 |
+
**information gap** β and it turns out the UK government agrees. DCMS's 2024β25
|
| 11 |
+
research into library non-users found the single biggest fixable barrier is exactly
|
| 12 |
+
this: people **don't know the breadth** of what libraries do.
|
| 13 |
+
|
| 14 |
+
So the brief wrote itself: not "a chatbot for a library", but *a way to make the
|
| 15 |
+
library's own scattered, public information findable and inviting* β on a small model,
|
| 16 |
+
because the hackathon said β€32B, and because honestly, you don't need a giant model
|
| 17 |
+
to do this well.
|
| 18 |
+
|
| 19 |
+
## The honest small-model bet
|
| 20 |
+
|
| 21 |
+
Our central design decision: **the intelligence lives in the retrieval, not the model.**
|
| 22 |
+
A 7β32B model is more than enough to *route* a question and *phrase* a grounded answer
|
| 23 |
+
β if you give it good, live, structured data. So we spent most of our effort on the
|
| 24 |
+
data and the graph, and let the model be small and swappable. The payoff: an honest fit
|
| 25 |
+
with the brief, and a **no-LLM fallback** that still answers from raw live data, so a
|
| 26 |
+
flaky endpoint never breaks the demo.
|
| 27 |
+
|
| 28 |
+
## What we learned mining the council's data
|
| 29 |
+
|
| 30 |
+
**1. The catalogue had a hidden clean API.** The Worcestershire catalogue runs on
|
| 31 |
+
SirsiDynix Enterprise β usually painful to scrape (JS-heavy). But probing it, we found
|
| 32 |
+
an undocumented **Atom feed** (`/client/rss/hitlist/wcc/qu=β¦`) that returns clean XML
|
| 33 |
+
with title, author, format, year and ISBN per result. One lucky find turned a scraping
|
| 34 |
+
nightmare into a five-line parser.
|
| 35 |
+
|
| 36 |
+
**2. "What you need to sign up" is the part everyone gets wrong.** Eligibility *varies
|
| 37 |
+
wildly* and is buried: digital membership is instant-by-postcode; full membership needs
|
| 38 |
+
a card; Print Your Way needs a PaperCut top-up; Libraries Unlocked needs an in-person
|
| 39 |
+
induction; PressReader needs a 30-day re-verify. We made eligibility a **first-class
|
| 40 |
+
field** on every answer. This is the bit users actually get stuck on.
|
| 41 |
+
|
| 42 |
+
**3. Crawlers lie unless you audit them.** Our first KB looked great until we audited it:
|
| 43 |
+
11 of 17 online resources had inherited **boilerplate bleed** β a related-links block
|
| 44 |
+
("Digital library membership: access free eBooksβ¦") had been scraped as if it were each
|
| 45 |
+
resource's *own* access rule. So the bot would've told someone they could use Ancestry
|
| 46 |
+
from home (they can't β it's in-branch). We curated all 17 by hand. Lesson: **a confident
|
| 47 |
+
wrong answer is worse than no answer**, especially for a public service.
|
| 48 |
+
|
| 49 |
+
**4. Half the "services" were last summer's posters.** "World Book Day", "STEAMfest",
|
| 50 |
+
"Summer Reading Challenge" β dated campaign pages masquerading as standing services. We
|
| 51 |
+
added seasonal tagging so the assistant doesn't present a one-off as something you can do
|
| 52 |
+
today.
|
| 53 |
+
|
| 54 |
+
**5. Hours tables are not one shape.** The 11 Libraries-Unlocked branches use a
|
| 55 |
+
structured 3-column table (early-unlocked / core-staffed / late-unlocked); the community
|
| 56 |
+
libraries use plain "Monday: 9:30am to 5:00pm" text. We needed both parsers to get to
|
| 57 |
+
100% hours coverage and a working "open now?" check.
|
| 58 |
+
|
| 59 |
+
## Why a knowledge graph (GraphRAG), not just RAG
|
| 60 |
+
|
| 61 |
+
Flat retrieval answers "what are the opening hours of Malvern?" fine. It *can't* answer
|
| 62 |
+
*"a late-opening library with a cafΓ© and meeting rooms"* β that's a join across three
|
| 63 |
+
facts. So we built a **GraphRAG-style** graph (inspired by microsoft/graphrag,
|
| 64 |
+
markitdown, IBM Docling): 320 nodes (branches, services, resources, facilities, areas,
|
| 65 |
+
memberships, 154 villages), 465 typed edges (HAS_FACILITY, OFFERS, REQUIRES, LOCATED_IN),
|
| 66 |
+
and community summaries. Because our source KB is already structured, we build the graph
|
| 67 |
+
**deterministically** β no per-node LLM calls, no API cost, fully reproducible. The
|
| 68 |
+
multi-hop query above traverses `BranchβOFFERSβLibraries Unlocked` and
|
| 69 |
+
`BranchβHAS_FACILITYβFacility` in one shot and lands on Malvern.
|
| 70 |
+
|
| 71 |
+
## Turning awareness into action (the EAST layer)
|
| 72 |
+
|
| 73 |
+
Knowing the DCMS barriers, we engineered against the Behavioural Insights Team's **EAST**
|
| 74 |
+
framework: **Easy** (quick-reply chips, exact sign-up steps), **Attractive** (a Β£-saved
|
| 75 |
+
"value receipt" β money-saving is DCMS's strongest reframe), **Social/Timely** (one
|
| 76 |
+
contextual "did you know?" nudge at the moment of contact). The nudges aren't decoration
|
| 77 |
+
β they're the mechanism that attacks the #1 barrier (awareness of breadth), and the
|
| 78 |
+
trace log tells Jack which ones actually convert.
|
| 79 |
+
|
| 80 |
+
## What surprised us
|
| 81 |
+
|
| 82 |
+
- A **single Atom endpoint** saved the hardest integration.
|
| 83 |
+
- The boring win β **"what you need to sign up"** β is probably the most *useful* feature.
|
| 84 |
+
- Building the graph from already-structured data made GraphRAG **cheap and reliable**,
|
| 85 |
+
not the expensive thing people assume.
|
| 86 |
+
- Routing is harder than answering: "Harry Potter audiobooks" (catalogue) vs "audiobooks
|
| 87 |
+
online" (BorrowBox) hinge on one word of context.
|
| 88 |
+
|
| 89 |
+
## Reproduce it
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
pip install -r requirements.txt gradio
|
| 93 |
+
python build_kb.py && python graph_build.py # refresh data + graph (live)
|
| 94 |
+
python app.py # run (no-LLM mode without HF_TOKEN)
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
Everything reads only `worcestershire.gov.uk` and the council catalogue β we deliberately
|
| 98 |
+
exclude the out-of-date Hive website. Public data, public good, on a model that fits on
|
| 99 |
+
a laptop.
|
IDEAS.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# π‘ IDEAS β bringing "your library, out loud" to life
|
| 2 |
+
|
| 3 |
+
A living backlog of ways to get the public *interested* in what their library
|
| 4 |
+
offers, not just *informed*. Everything here is gradeable against the evidence
|
| 5 |
+
([EAST](https://oecd-opsi.org/toolkits/east-four-simple-ways-to-apply-behavioural-insights/):
|
| 6 |
+
Easy Β· Attractive Β· Social Β· Timely; [DCMS COM-B non-user research](https://www.gov.uk/government/publications/what-works-to-engage-library-non-users/what-works-to-engage-library-non-users))
|
| 7 |
+
and stays inside the hackathon rules (**β€32B models, local where possible**).
|
| 8 |
+
|
| 9 |
+
The unifying move: the same source-of-truth (`library_kb.json` + `library_graph.json`)
|
| 10 |
+
that powers the assistant **also auto-generates the media** below. One graph, many voices.
|
| 11 |
+
|
| 12 |
+
Legend β **Impact** (behaviour-change lever) Β· **Effort** (hack-weekend feasibility) Β· **Badge** it helps win.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 1. AI-generated media (the core idea)
|
| 17 |
+
|
| 18 |
+
The library can't afford a media team. A β€32B model + small local TTS/diffusion *is* the media team.
|
| 19 |
+
|
| 20 |
+
| Idea | What it is | Impact (EAST) | Effort | Badge |
|
| 21 |
+
|---|---|---|---|---|
|
| 22 |
+
| **"Shelf Life" AI podcast** | NotebookLM-style **two-host audio** auto-built from the KB β e.g. a 3-min episode "5 things Malvern Library does that you didn't know". Script by the 32B model, voices by local TTS (Piper/Kokoro). | Attractive + intellectual register | ββ | Best Demo, Off-the-Grid |
|
| 23 |
+
| **"Did You Know?" Shorts** | 15β30s vertical video, one hidden gem, hook in the first second ("You're paying Β£9.99 for newspapers your library gives you free"). Auto-scripted per service; captions + a stock/branch image. | Attractive + Social, Gen-Z register | ββ | Best Demo |
|
| 24 |
+
| **Personalised micro-clip** | User picks a segment ("job-seeker / parent / saver / curious") β tool generates a tailored 30s script/share-card for *their* situation. | Tailored messaging (DCMS) | β | Best Agent |
|
| 25 |
+
| **"Library Minute" radio drop** | A 60s audio spot for local radio / the council podcast, regenerated weekly from new events + new books. | Timely + Social | β | Best Demo |
|
| 26 |
+
| **The Β£-saved "value receipt" card** | A shareable image: *"This chat saved you Β£28.98 β 1 hardback + a month of magazines."* Operationalises DCMS's money-saving reframe. | Attractive (the #1 reframe) | β | Best Demo, Community Choice |
|
| 27 |
+
|
| 28 |
+
## 2. Match the register to the audience (DCMS segmentation)
|
| 29 |
+
|
| 30 |
+
The user's instinct β *sometimes a Short, sometimes something for an intellectual* β is exactly the
|
| 31 |
+
DCMS finding that messaging must be **tailored per segment**. Map content style β segment:
|
| 32 |
+
|
| 33 |
+
| Segment (why they don't come) | Register | Format |
|
| 34 |
+
|---|---|---|
|
| 35 |
+
| Digitally-confident sceptics ("libraries are dated") | Sharp, stat-led, slightly provocative | Short + the value-receipt |
|
| 36 |
+
| Parents / families | Warm, practical, time-saving | "What's on this week near you" reel |
|
| 37 |
+
| Family historians / retirees | Long-form, rich | "Shelf Life" deep-dive podcast (Ancestry, local archive) |
|
| 38 |
+
| Job-seekers / new starters | Reassuring, step-by-step | Tailored micro-clip + how-to |
|
| 39 |
+
| The simply curious | Playful, surprising | "Did You Know?" Short, the oracle (Β§4) |
|
| 40 |
+
|
| 41 |
+
## 3. Facilitate it *inside the tool* (so the app is the studio)
|
| 42 |
+
|
| 43 |
+
- **"Make me a clip" button** on any answer β generates a script + share-card (and, with TTS, an audio file) right there. Turns every Q&A into shareable content. *(Satisfies the hackathon's social-post requirement automatically.)*
|
| 44 |
+
- **Weekly auto-episode**: a scheduled job assembles new events + `whats_new` hot-takes into a "Shelf Life" episode + a Short, posted to [@worcslibraries](https://www.facebook.com/Worcslibraries/).
|
| 45 |
+
- **QR-to-clip**: a poster/shelf QR opens the tool pre-asked ("What can this library do for me?") and offers the clip β the *Timely* nudge at the point of being in the building.
|
| 46 |
+
- **Conversion logging** (via the trace layer) tells Jack which clip/topic actually drives sign-ups β an evidence loop, not vanity metrics.
|
| 47 |
+
|
| 48 |
+
## 4. Whimsy & delight (Thousand Token Wood crossover)
|
| 49 |
+
|
| 50 |
+
The same engine can wander somewhere weirder β a second, joyful entry point:
|
| 51 |
+
|
| 52 |
+
- **The Library Oracle** β describe your week, get a book "prescribed" with a one-line hot take + a reservation link.
|
| 53 |
+
- **Blind Date with a Book** β the model writes a teasing, spoiler-free dating-profile for a real catalogue title; swipe to reserve.
|
| 54 |
+
- **"The Library of You"** β answer 3 questions, get a tiny generated "membership of an imaginary branch curated for you" (real services mapped to a whimsical persona).
|
| 55 |
+
- **Mobile-van adventure map** β the 154-village graph rendered as a hand-drawn trail (ties to the hackathon's own "Thousand Token Wood" aesthetic).
|
| 56 |
+
|
| 57 |
+
## 5. Small-model production stack (keeps it hackathon-legal + earns badges)
|
| 58 |
+
|
| 59 |
+
| Job | β€32B / local option |
|
| 60 |
+
|---|---|
|
| 61 |
+
| Scripts, hot-takes, podcast dialogue | the app's main β€32B model (Qwen2.5-32B etc.), local via **llama.cpp** π¦ |
|
| 62 |
+
| Voices (TTS) | Kokoro-82M / Piper β tiny, local, fast π (Tiny Titan β€4B) |
|
| 63 |
+
| Images / thumbnails | FLUX.1-schnell / SDXL-Turbo (small, fast) |
|
| 64 |
+
| Video assembly | ffmpeg + captions (deterministic, no model) |
|
| 65 |
+
|
| 66 |
+
Doing media generation on **small local models** flips the whole pitch: *"a county library with no
|
| 67 |
+
budget produces NLB-grade outreach on a laptop"* β and stacks Off-the-Grid + Llama Champion + Tiny Titan.
|
| 68 |
+
|
| 69 |
+
## 6. Distribution channels
|
| 70 |
+
|
| 71 |
+
In-app share-card Β· [@worcslibraries](https://x.com/worcslibraries) FB/X/IG/YouTube Β· kiosk loop in-branch Β·
|
| 72 |
+
shelf QR codes Β· the council e-newsletter Β· partner schools & job centres.
|
| 73 |
+
|
| 74 |
+
---
|
| 75 |
+
|
| 76 |
+
## Shortlist to actually demo this weekend
|
| 77 |
+
|
| 78 |
+
1. **Value receipt (Β£ saved) share-card** β highest impact-per-effort, directly evidence-based, instant social-post.
|
| 79 |
+
2. **"Did You Know?" Short generator** β one button, one hidden gem, vertical clip. The wow moment.
|
| 80 |
+
3. **"Shelf Life" 3-min podcast for one branch** β proves the long-form/intellectual register and the auto-from-graph pipeline.
|
| 81 |
+
|
| 82 |
+
Everything else is backlog. Add freely β this file is the bank, not the plan.
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Julian Elliott & Jack Hubbert
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
PLAN.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# πΊοΈ PLAN β Worcestershire Libraries Live Assistant
|
| 2 |
+
|
| 3 |
+
The blueprint for the build: what it is, how it's wired, what's done, and what's next.
|
| 4 |
+
Companion docs: [README](README.md) Β· [SUBMISSION](SUBMISSION.md) Β· [IDEAS](IDEAS.md) Β· [FIELD_NOTES](FIELD_NOTES.md).
|
| 5 |
+
|
| 6 |
+
## Goal
|
| 7 |
+
|
| 8 |
+
Give Worcestershire Libraries one conversational voice that answers any resident
|
| 9 |
+
question from **live, official, source-cited data**, tells them **exactly what they
|
| 10 |
+
need to sign up**, and **surfaces the services they never knew existed** β on a β€32B
|
| 11 |
+
model, locally-capable. The DCMS-evidenced answer to "the library doesn't shout
|
| 12 |
+
about what it offers."
|
| 13 |
+
|
| 14 |
+
## Architecture
|
| 15 |
+
|
| 16 |
+
```
|
| 17 |
+
βββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
user question βββΆ β route() LLM JSON router + keyword β
|
| 19 |
+
β fallback (no-token safe) β
|
| 20 |
+
βββββββββββββββββ¬βββββββββββββββββββββββββββββ
|
| 21 |
+
βΌ
|
| 22 |
+
βββββββββββββββββ 10 tools ββββββββββββββββββ βββ GraphRAG βββ
|
| 23 |
+
β search_catalogue (SirsiDynix Atom feed) β β local_search β
|
| 24 |
+
β whats_new (newest titles) β β global_searchβ
|
| 25 |
+
β find_library (hours/open-now/facils) βββββ multi-hop β
|
| 26 |
+
β mobile_library (154 villages) β β over graph β
|
| 27 |
+
β library_events (live events) β ββββββββββββββββ
|
| 28 |
+
β online_hub (PressReader/BorrowBoxβ¦) β library_graph.json
|
| 29 |
+
β libraries_unlocked Β· printing Β· membership β 320 nodes/465 edges
|
| 30 |
+
βββββββββββββββββββββ¬βββββββββββββββββββββββββ
|
| 31 |
+
βΌ
|
| 32 |
+
synthesise (β€32B) + eligibility + Β£-receipt + EAST nudge
|
| 33 |
+
+ source link + open agent trace
|
| 34 |
+
βΌ
|
| 35 |
+
answer + quick-reply chips
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## Components & files
|
| 39 |
+
|
| 40 |
+
| Layer | File | Role |
|
| 41 |
+
|---|---|---|
|
| 42 |
+
| Ingestion | `build_kb.py` β `library_kb.json` | Crawl all 218 library URLs β 87 services / 23 branches / 17 resources, with eligibility & facilities |
|
| 43 |
+
| Graph | `graph_build.py` β `library_graph.json` | Deterministic GraphRAG: entities β relationships β communities β reports |
|
| 44 |
+
| Sources | `library_sources.py` | 10 live/KB tools + curated hub access + membership tiers |
|
| 45 |
+
| Retrieval | `graph_rag.py` | local/global graph search (multi-hop) |
|
| 46 |
+
| Traces | `trace.py` β `traces.jsonl` | per-turn structured agent trace (Open Trace badge) |
|
| 47 |
+
| App | `app.py` | router, renders, behaviour-change layer, Gradio UI |
|
| 48 |
+
| Custom UI | `server.py` + `index.html` | `gradio.Server` custom frontend (Off-Brand badge) |
|
| 49 |
+
|
| 50 |
+
## Build phases
|
| 51 |
+
|
| 52 |
+
- [x] **P1 β Live tools.** Catalogue (Atom), mobile, events, printing. Verified live.
|
| 53 |
+
- [x] **P2 β Comprehensive KB.** Full council-site crawl; eligibility + facilities + hours.
|
| 54 |
+
- [x] **P3 β New tools.** find_library (open-now), online_hub (+curated access), libraries_unlocked, membership_help, whats_new.
|
| 55 |
+
- [x] **P4 β GraphRAG.** 320-node graph; multi-hop "late library + cafΓ© + meeting rooms" β Malvern.
|
| 56 |
+
- [x] **P5 β Behaviour-change layer.** EAST nudges, Β£-saved value receipt, quick-reply chips (DCMS/COM-B grounded).
|
| 57 |
+
- [x] **P6 β Traces.** JSONL logging + in-chat "how I answered" panel.
|
| 58 |
+
- [x] **P7 β KB refinement.** Curate all 17 hub resources, filter time-bound junk, tag seasonal pages.
|
| 59 |
+
- [ ] **P8 β Deploy** to the Space + `HF_TOKEN` + smoke-test (user action).
|
| 60 |
+
- [ ] **P9 β Stretch:** custom `gr.Server` frontend; a small fine-tune (π― badge); llama.cpp local run (ππ¦).
|
| 61 |
+
|
| 62 |
+
## The behaviour-change layer (why, not just what)
|
| 63 |
+
|
| 64 |
+
Grounded in [DCMS *What works to engage non-users*](https://www.gov.uk/government/publications/what-works-to-engage-library-non-users) (COM-B) + the
|
| 65 |
+
Behavioural Insights Team **EAST** framework:
|
| 66 |
+
|
| 67 |
+
- **Easy** β quick-reply chips, exact "what you need", deep links.
|
| 68 |
+
- **Attractive** β Β£-saved "value receipt" (DCMS's strongest reframe: money-saving).
|
| 69 |
+
- **Social/Timely** β one contextual "did you know?" nudge at the moment of contact.
|
| 70 |
+
- **Awareness of breadth** (the #1 barrier) β the nudge engine surfaces hidden gems.
|
| 71 |
+
- **Measurement loop** β trace logs which nudges convert β evidence for Jack's campaigns.
|
| 72 |
+
|
| 73 |
+
## Trace & eval strategy (π‘ Open Trace)
|
| 74 |
+
|
| 75 |
+
Every turn writes one JSON object to `traces.jsonl` (route β steps β answer β sources β
|
| 76 |
+
timing), close to the hackathon's own trace-dataset schema. `trace.push_to_hub(repo_id)`
|
| 77 |
+
uploads it as a dataset. The same trace renders in-chat as a "how I answered" panel
|
| 78 |
+
(π€ Best Agent evidence). Future: a golden-question eval set scoring route accuracy +
|
| 79 |
+
source correctness.
|
| 80 |
+
|
| 81 |
+
## Badge roadmap
|
| 82 |
+
|
| 83 |
+
π€ Best Agent β
Β· π‘ Open Trace β
Β· π Field Notes β
Β· π¨ Off-Brand β (server.py) Β·
|
| 84 |
+
π Off-the-Grid / π¦ Llama Champion β (local llama.cpp) Β· π― Well-Tuned β (next).
|
| 85 |
+
|
| 86 |
+
## Risks & mitigations
|
| 87 |
+
|
| 88 |
+
| Risk | Mitigation |
|
| 89 |
+
|---|---|
|
| 90 |
+
| HF Inference flaky / no token | **No-LLM fallback** renders raw live data β demo never breaks |
|
| 91 |
+
| Council site HTML changes | Re-run `build_kb.py` (re-crawl) any time; tools fail soft |
|
| 92 |
+
| SirsiDynix slow/timeouts | per-call try/except; app degrades gracefully |
|
| 93 |
+
| Gradio 6 needs Py3.10+ | tested logic locally; boot-test on the Space first |
|
| 94 |
+
| Facility data sparse (e.g. "study space") | multi-hop honest about coverage; lead demo on cafΓ©+meeting |
|
| 95 |
+
|
| 96 |
+
## Future work
|
| 97 |
+
|
| 98 |
+
Fine-tune a tiny model on Qβtool routing; live PressReader title search; FOI ingestion
|
| 99 |
+
(WhatDoTheyKnow) as a transparency feature; auto-generated "Shelf Life" podcast +
|
| 100 |
+
share-cards (see [IDEAS.md](IDEAS.md)); per-branch service mapping.
|
README.md
CHANGED
|
@@ -1,48 +1,93 @@
|
|
| 1 |
---
|
| 2 |
-
title: Worcestershire Libraries
|
| 3 |
-
short_description: Ask about Worcestershire's 23 libraries and services.
|
| 4 |
emoji: π
|
| 5 |
-
colorFrom:
|
| 6 |
-
colorTo:
|
| 7 |
sdk: gradio
|
| 8 |
-
sdk_version: 6.
|
| 9 |
app_file: app.py
|
| 10 |
pinned: true
|
| 11 |
license: mit
|
| 12 |
-
|
| 13 |
-
- gradio
|
| 14 |
-
- library
|
| 15 |
-
- community
|
| 16 |
-
- rag
|
| 17 |
-
- small-model
|
| 18 |
-
- backyard-ai
|
| 19 |
-
- build-small-hackathon
|
| 20 |
---
|
| 21 |
|
| 22 |
-
# Worcestershire Libraries β
|
| 23 |
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
|
| 37 |
-
|
|
|
|
| 38 |
|
| 39 |
-
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
```bash
|
| 44 |
-
pip install -r requirements.txt
|
| 45 |
-
export HF_TOKEN=
|
| 46 |
-
|
| 47 |
-
python app.py
|
| 48 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Worcestershire Libraries Live Assistant
|
|
|
|
| 3 |
emoji: π
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.17.3
|
| 8 |
app_file: app.py
|
| 9 |
pinned: true
|
| 10 |
license: mit
|
| 11 |
+
short_description: Live, source-cited answers about your local library
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# π Worcestershire Libraries β Live Assistant
|
| 15 |
|
| 16 |
+
A small-model (**β€ 32B**) agent that answers real questions about Worcestershire
|
| 17 |
+
Libraries by **mining live data at question time** from official sources only.
|
| 18 |
|
| 19 |
+
> Built for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon)
|
| 20 |
+
> Β· **Backyard AI** track. The "someone I know" is **Jack, a library resources
|
| 21 |
+
> manager in Worcester**, whose recurring complaint is that *the library never
|
| 22 |
+
> shouts loudly enough about everything it offers*. This is the megaphone.
|
| 23 |
|
| 24 |
+
## What it does
|
| 25 |
|
| 26 |
+
Ask in plain English and it routes your question to a live tool, reads what comes
|
| 27 |
+
back from the council's own systems, and explains it:
|
| 28 |
|
| 29 |
+
| You ask⦠| It mines⦠| Source |
|
| 30 |
+
|---|---|---|
|
| 31 |
+
| π βDo you have Harry Potter audiobooks?β | the **SirsiDynix catalogue** (books, eBooks, audio, DVDs) | `wcc.ent.sirsidynix.net.uk` |
|
| 32 |
+
| π βWhen does the mobile library visit Abberley?β | the **mobile-library timetable** (154 villages) | `worcestershire.gov.uk` |
|
| 33 |
+
| π
βWhat's on this week?β | **events & activities** | `worcestershire.gov.uk` |
|
| 34 |
+
| π¨οΈ βHow do I print from my phone?β | **Print Your Way** steps & prices | `worcestershire.gov.uk` |
|
| 35 |
|
| 36 |
+
Every reply carries a **βchecked live just nowβ footer** with the tool used and a
|
| 37 |
+
link back to the official page.
|
| 38 |
|
| 39 |
+
## The honest small-model fit
|
| 40 |
|
| 41 |
+
The model never invents library facts. The intelligence lives in the **live
|
| 42 |
+
retrieval**; a 7B model is more than enough to *route* the question and *phrase*
|
| 43 |
+
the answer. That's a genuine fit with the brief β not a 32B model pretending to
|
| 44 |
+
know a catalogue it was never trained on.
|
| 45 |
+
|
| 46 |
+
- **Model:** `Qwen/Qwen2.5-7B-Instruct` by default (set `MODEL_ID` to swap up to
|
| 47 |
+
any β€32B model, e.g. `Qwen/Qwen2.5-32B-Instruct`).
|
| 48 |
+
- **Graceful degradation:** with no `HF_TOKEN`, the app still works in *no-LLM
|
| 49 |
+
mode* β deterministic keyword routing + the raw live data. The demo never
|
| 50 |
+
breaks.
|
| 51 |
+
|
| 52 |
+
## Why not "the Hive"?
|
| 53 |
+
|
| 54 |
+
We deliberately **avoid scraping thehiveworcester.org** β that content is
|
| 55 |
+
unreliable and often years out of date. Only the council website and the live
|
| 56 |
+
catalogue are used, so answers are trustworthy and current.
|
| 57 |
+
|
| 58 |
+
## Architecture
|
| 59 |
+
|
| 60 |
+
```
|
| 61 |
+
question βββΆ route() (LLM JSON router, keyword fallback)
|
| 62 |
+
β
|
| 63 |
+
βΌ
|
| 64 |
+
library_sources.py βββΆ live HTTP to council + catalogue
|
| 65 |
+
β β’ search_catalogue() (Atom feed)
|
| 66 |
+
β β’ mobile_library() (village pages)
|
| 67 |
+
β β’ library_events() (events page)
|
| 68 |
+
β β’ printing_help() (printing page)
|
| 69 |
+
βΌ
|
| 70 |
+
synthesize() βββΆ warm, grounded answer + live source footer
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
`library_sources.py` has **zero** Gradio/LLM dependencies and is independently
|
| 74 |
+
testable against the live sites: `python library_sources.py`.
|
| 75 |
+
|
| 76 |
+
## Run locally
|
| 77 |
|
| 78 |
```bash
|
| 79 |
+
pip install -r requirements.txt gradio
|
| 80 |
+
export HF_TOKEN=hf_xxx # optional β omit for no-LLM mode
|
| 81 |
+
python app.py # http://localhost:7860
|
|
|
|
| 82 |
```
|
| 83 |
+
|
| 84 |
+
## Bonus quests in reach
|
| 85 |
+
|
| 86 |
+
- π€ **Best Agent** β a real route β live-tool β synthesise loop.
|
| 87 |
+
- π¨ **Off-Brand** β custom-branded Worcestershire-teal UI.
|
| 88 |
+
- π‘ **Open Trace** β each answer exposes its routing + source (easy to publish).
|
| 89 |
+
- π **Field Notes** β write-up of building it with/for Jack.
|
| 90 |
+
|
| 91 |
+
## License
|
| 92 |
+
|
| 93 |
+
MIT β Β© Julian Elliott & Jack Hubbert.
|
SUBMISSION.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# π Worcestershire Libraries β Live Assistant Β· Submission Pack
|
| 2 |
+
|
| 3 |
+
> **Build Small Hackathon Β· Track: π‘ Backyard AI** (with a π Thousand Token Wood crossover)
|
| 4 |
+
> Oneβline: *The library that "doesn't shout about what it offers" β given a voice that knows everything, built from public data, running on a small model.*
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 1. The fable (open the pitch with this)
|
| 9 |
+
|
| 10 |
+
> Jack is a library resources manager in Worcester. His recurring frustration:
|
| 11 |
+
> **"the library doesn't shout loudly enough about everything it offers."**
|
| 12 |
+
>
|
| 13 |
+
> Everything a resident needs to know *is* public β but it's scattered across 200+
|
| 14 |
+
> council pages, a 1990sβstyle catalogue, PDF van timetables and buried FOI logs.
|
| 15 |
+
> So we gave the library one calm, conversational voice that mines all of it **live**,
|
| 16 |
+
> tells you exactly **what you need to sign up**, and β the DCMSβevidenced part β
|
| 17 |
+
> nudges you toward the services you never knew existed. On a β€32B model. On a laptop.
|
| 18 |
+
|
| 19 |
+
That's the whole story: **civic data democratisation, small and local.**
|
| 20 |
+
|
| 21 |
+
## 2. What it is (elevator)
|
| 22 |
+
|
| 23 |
+
A Gradio app where you ask in plain English and a smallβmodel agent answers from
|
| 24 |
+
**live official data + a local knowledge graph**:
|
| 25 |
+
|
| 26 |
+
- π Find a book / eBook / audiobook (live **SirsiDynix catalogue**)
|
| 27 |
+
- π Branch **"open now?"**, hours, toilets, parking, cafΓ© (live council pages)
|
| 28 |
+
- π **Mobile library** times for any of **154 villages**
|
| 29 |
+
- π
**What's on** this week
|
| 30 |
+
- π» **Free online** β newspapers (PressReader), eBooks (BorrowBox), family history (Ancestry) β with *exactly how to access each*
|
| 31 |
+
- π¨οΈ **Print Your Way** from your phone
|
| 32 |
+
- π§ **Multiβhop graph** questions: *"a lateβopening library with a cafΓ© and meeting rooms"* β Malvern
|
| 33 |
+
|
| 34 |
+
Every answer carries **what you need to sign up**, a **Β£βsaved value receipt**, one
|
| 35 |
+
**"did you know?" nudge**, a live **source link**, and an open **agent trace**.
|
| 36 |
+
|
| 37 |
+
## 3. Why we win (competitive read)
|
| 38 |
+
|
| 39 |
+
The field is mostly *either* emotional *or* technical *or* genuinelyβused β rarely all
|
| 40 |
+
three, and **none mine live, verifiable realβworld data**. We hit every judging criterion:
|
| 41 |
+
|
| 42 |
+
| Judging criterion | Our evidence |
|
| 43 |
+
|---|---|
|
| 44 |
+
| Problem is specific & real | Jack's own words; a real council with real underused services |
|
| 45 |
+
| The person actually used it | Named beneficiary + real patrons; demo on his branch |
|
| 46 |
+
| Honest smallβmodel fit | The graph does the reasoning; the 7β32B model just routes + phrases |
|
| 47 |
+
| Polish | Branded UI, streaming, chips, sourceβcited every time |
|
| 48 |
+
| (depth nobody else has) | Real **GraphRAG** + live multiβsource mining, not one prompt |
|
| 49 |
+
|
| 50 |
+
Borrowed insight from the field: shareable output wins Community Choice (Lolaby/whimsy
|
| 51 |
+
entries) β our **valueβreceipt / hotβtake shareβcard** is our shareable hook; strong
|
| 52 |
+
Backyard entries **lead with a face + a quote** β the demo opens on Jack.
|
| 53 |
+
|
| 54 |
+
## 4. Bonus badges we're claiming
|
| 55 |
+
|
| 56 |
+
| Badge | Status | Evidence |
|
| 57 |
+
|---|---|---|
|
| 58 |
+
| π€ **Best Agent** | β
strong | 10 tools, LLM router + keyword fallback, graph search, visible traces |
|
| 59 |
+
| π‘ **Open Trace** | β
| every turn β `traces.jsonl` (shareable schema); oneβclick push to Hub |
|
| 60 |
+
| π **Field Notes** | β
| `IDEAS.md`, this pack, + a short build blog |
|
| 61 |
+
| π¨ **OffβBrand** | β partial | custom Worcestershireβteal UI (stretch: `gr.Server`) |
|
| 62 |
+
| π **Off the Grid** / π¦ **Llama Champion** | β optional | runs fully local via llama.cpp (documented); Space default uses HF Inference |
|
| 63 |
+
| π― **WellβTuned** | β future | a small fineβtune is the next badge to grab |
|
| 64 |
+
| Specials | π¬ Best Demo Β· π Judges' Wildcard (data democratisation) Β· π³οΈ Community Choice |
|
| 65 |
+
|
| 66 |
+
## 5. Demo video script (~75s)
|
| 67 |
+
|
| 68 |
+
| t | Shot | Voiceover |
|
| 69 |
+
|---|---|---|
|
| 70 |
+
| 0β8s | Jack on camera (or his quote on screen) | "Jack runs library resources in Worcester. He says the library never shouts about what it offers." |
|
| 71 |
+
| 8β18s | Type *"Is Malvern library open now?"* β π΄/π’ + facilities | "So we built it a voice. It checks the council site live β open now, toilets, parking." |
|
| 72 |
+
| 18β30s | *"Can I read newspapers for free?"* β PressReader steps + titles | "It knows what's free online β and exactly how to sign up." |
|
| 73 |
+
| 30β45s | *"A lateβopening library with a cafΓ© and meeting rooms"* β Malvern | "Ask by *features* β that's a knowledgeβgraph traversal a chatbot can't do." |
|
| 74 |
+
| 45β58s | Show the Β£βsaved receipt + a "did you know?" nudge | "Every answer shows what you saved, and surfaces a service you didn't know existed β straight from the DCMS playbook on reβengaging nonβusers." |
|
| 75 |
+
| 58β70s | Expand the agent trace | "And it shows its working β open traces, all from public data, on a small model." |
|
| 76 |
+
| 70β75s | Logo + Space URL | "Your library. Out loud." |
|
| 77 |
+
|
| 78 |
+
## 6. Social post (pick one)
|
| 79 |
+
|
| 80 |
+
**X / Bluesky:**
|
| 81 |
+
> Libraries are full of free stuff nobody knows about. So we gave Worcestershire's a
|
| 82 |
+
> voice: ask it anything, it mines the council site + catalogue **live**, tells you
|
| 83 |
+
> exactly how to sign up, and shows what you just saved π· β all on a β€32B model.
|
| 84 |
+
> #BuildSmall π‘π [link]
|
| 85 |
+
|
| 86 |
+
**LinkedIn:**
|
| 87 |
+
> For the Build Small Hackathon we built a "Backyard AI" for a friend who manages
|
| 88 |
+
> library resources in Worcester. His problem: the library doesn't shout about
|
| 89 |
+
> everything it offers. Our answer: a smallβmodel agent over a live knowledge graph
|
| 90 |
+
> of the *whole* service β books, mobile van, events, free newspapers, "what you need
|
| 91 |
+
> to sign up" β grounded in DCMS behaviourβchange research. Public data, public good,
|
| 92 |
+
> running on a laptop. [link]
|
| 93 |
+
|
| 94 |
+
## 7. Tech summary (for the writeβup / Q&A)
|
| 95 |
+
|
| 96 |
+
```
|
| 97 |
+
question β route (LLM JSON + keyword fallback)
|
| 98 |
+
β 10 live/KB tools ββ SirsiDynix catalogue (Atom)
|
| 99 |
+
β GraphRAG search ββ council pages (87 services / 23 branches / 17 resources)
|
| 100 |
+
β synthesise (β€32B) library_graph.json: 320 nodes Β· 465 edges Β· 8 communities
|
| 101 |
+
β answer + eligibility + Β£βreceipt + EAST nudge + source + open trace
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
- **Inspiration:** microsoft/graphrag, microsoft/markitdown, IBM Docling.
|
| 105 |
+
- **Evidence spine:** DCMS/Ipsos *What works to engage nonβusers* (COMβB) + BIT **EAST**.
|
| 106 |
+
- **Honest constraint fit:** small model + big graph; **noβLLM fallback** so the demo never breaks.
|
| 107 |
+
- **Sources:** worcestershire.gov.uk only β the outβofβdate Hive site is deliberately excluded.
|
| 108 |
+
|
| 109 |
+
## 8. Goβlive checklist (for Jack, Friday AM)
|
| 110 |
+
|
| 111 |
+
- [ ] Create Space `build-small-hackathon/wpl-discovery` (Gradio, public).
|
| 112 |
+
- [ ] Push: `app.py`, `library_sources.py`, `graph_rag.py`, `trace.py`,
|
| 113 |
+
`library_kb.json`, `library_graph.json`, `requirements.txt`, `README.md`, `LICENSE`.
|
| 114 |
+
*(build_kb.py / graph_build.py are buildβtime only β optional to include.)*
|
| 115 |
+
- [ ] Add Space secret **`HF_TOKEN`** (read scope) β enables the model. *Without it the
|
| 116 |
+
app still runs in noβLLM mode, so a missing token won't block Jack's test.*
|
| 117 |
+
- [ ] Optional: set `MODEL_ID=Qwen/Qwen2.5-32B-Instruct` to run at the cap.
|
| 118 |
+
- [ ] Smokeβtest the 6 example chips; confirm a live catalogue hit + a graph multiβhop.
|
| 119 |
+
- [ ] Send Jack the link + 3 starter questions; capture his reaction for the demo video.
|
| 120 |
+
|
| 121 |
+
**Refresh data any time:** `python build_kb.py && python graph_build.py` (reβcrawls the
|
| 122 |
+
council site and rebuilds the graph β keeps everything current).
|
app.py
CHANGED
|
@@ -1,769 +1,606 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
-
Worcestershire Libraries β
|
| 4 |
-
Run: python chat_app.py
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
-
import
|
|
|
|
|
|
|
| 11 |
import os
|
| 12 |
import re
|
| 13 |
-
import
|
| 14 |
-
from pathlib import Path
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
-
from gradio import ChatMessage
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
# ββ LLM setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
-
# Priority: ANTHROPIC_API_KEY β HF_TOKEN (HuggingFace Inference) β context-only
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
_anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
| 33 |
-
_hf_token = os.environ.get("HF_TOKEN", "")
|
| 34 |
|
| 35 |
-
#
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
if _anthropic_key:
|
| 39 |
-
try:
|
| 40 |
-
import anthropic as _anthropic
|
| 41 |
-
_anthropic_client = _anthropic.Anthropic(api_key=_anthropic_key)
|
| 42 |
-
LLM_AVAILABLE = True
|
| 43 |
-
LLM_BACKEND = "anthropic"
|
| 44 |
-
except Exception as e:
|
| 45 |
-
print(f"Anthropic init failed: {e}")
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
# ββ Startup singletons βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
-
|
| 58 |
-
WIKI_DIR = BASE_DIR / "wiki"
|
| 59 |
-
_tool = LibraryQueryTool(WIKI_DIR)
|
| 60 |
-
|
| 61 |
-
_ctx_file = BASE_DIR / "AGENT_CONTEXT.md"
|
| 62 |
-
AGENT_CONTEXT = _ctx_file.read_text(encoding="utf-8") if _ctx_file.exists() else ""
|
| 63 |
-
|
| 64 |
-
SYSTEM_PROMPT = f"""You are the Worcestershire Libraries virtual assistant.
|
| 65 |
-
You help members of the public with questions about libraries across Worcestershire β
|
| 66 |
-
branches, opening hours, the mobile library, events, courses, services, and membership.
|
| 67 |
-
|
| 68 |
-
## Your domain knowledge
|
| 69 |
-
{AGENT_CONTEXT}
|
| 70 |
-
|
| 71 |
-
## Rules
|
| 72 |
-
- Always use the search tool before answering factual questions. Never guess hours, addresses, or emails.
|
| 73 |
-
- Be warm, concise and helpful. Use bullet points for hours/facilities lists.
|
| 74 |
-
- For events and activities: describe the TYPES of regular activities shown in the wiki context
|
| 75 |
-
(e.g. Storytime, Bounce & Rhyme, reading groups, coding clubs, adult learning) even when
|
| 76 |
-
specific upcoming dates are not listed. Always include the events page link for current schedules:
|
| 77 |
-
https://www.worcestershire.gov.uk/council-services/libraries/library-events-and-activities
|
| 78 |
-
- Do NOT add a source or date citation β that is appended automatically.
|
| 79 |
-
- If you cannot find the answer, say so honestly and give:
|
| 80 |
-
https://www.worcestershire.gov.uk/council-services/libraries
|
| 81 |
-
- Today is {datetime.date.today().isoformat()}.
|
| 82 |
-
"""
|
| 83 |
|
| 84 |
-
# ββ Content definitions βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
-
|
| 86 |
-
QUICK_QUESTIONS = [
|
| 87 |
-
("π Mobile library", "What mobile library dates are coming up this month?"),
|
| 88 |
-
("π¨οΈ Printing", "How do I print at the library?"),
|
| 89 |
-
("πΆ Children", "What children's activities does the library offer?"),
|
| 90 |
-
("π Join free", "How do I join the library and get a free library card?"),
|
| 91 |
-
]
|
| 92 |
-
|
| 93 |
-
# Topic navigator: icon, section title, [(button label, full prompt), ...]
|
| 94 |
-
TOPIC_QUESTIONS = [
|
| 95 |
-
("π", "Mobile Library", [
|
| 96 |
-
("Where is it right now?", "Where is the mobile library right now?"),
|
| 97 |
-
("When is it coming this month?", "What mobile library dates are coming up this month?"),
|
| 98 |
-
("Find my village's schedule", "When does the mobile library visit my village?"),
|
| 99 |
-
("Is it coming today or tomorrow?", "Is the mobile library coming today or tomorrow?"),
|
| 100 |
-
]),
|
| 101 |
-
("π", "Hours & Locations", [
|
| 102 |
-
("What are the opening hours?", "What are the opening hours for Worcestershire Libraries?"),
|
| 103 |
-
("Find my nearest library", "What library branches are there in Worcestershire?"),
|
| 104 |
-
("What is Libraries Unlocked?", "What is Libraries Unlocked and how do I sign up?"),
|
| 105 |
-
("Sunday opening", "Which Worcestershire libraries are open on Sundays?"),
|
| 106 |
-
]),
|
| 107 |
-
("π", "Libraries Unlocked", [
|
| 108 |
-
("What can I do during extended hours?", "What services can I use during Libraries Unlocked extended hours?"),
|
| 109 |
-
("Which libraries have it?", "Which Worcestershire libraries have Libraries Unlocked?"),
|
| 110 |
-
("How do I sign up?", "How do I sign up for Libraries Unlocked membership?"),
|
| 111 |
-
("Is it free?", "Is Libraries Unlocked membership free, and who is eligible?"),
|
| 112 |
-
]),
|
| 113 |
-
("π¨οΈ", "Printing & Computers", [
|
| 114 |
-
("How do I print at the library?", "How do I print at the library?"),
|
| 115 |
-
("Print from my phone or tablet", "How do I print from my own phone or tablet at the library?"),
|
| 116 |
-
("Book a computer session", "How do I book a computer at the library?"),
|
| 117 |
-
("What does printing cost?", "What does it cost to print at Worcestershire Libraries?"),
|
| 118 |
-
]),
|
| 119 |
-
("πΆ", "Children & Families", [
|
| 120 |
-
("What's on for children?", "What children's activities and events does the library offer?"),
|
| 121 |
-
("Baby and toddler sessions", "What sessions are available for babies and toddlers at the library?"),
|
| 122 |
-
("Summer Reading Challenge", "What is the Summer Reading Challenge at the library?"),
|
| 123 |
-
("After-school activities", "What activities are available for school-age children at the library?"),
|
| 124 |
-
]),
|
| 125 |
-
("π", "Books, Fees & Cards", [
|
| 126 |
-
("Join the library free", "How do I join the library and get a free library card?"),
|
| 127 |
-
("Free eBooks & audiobooks", "How do I borrow eBooks and audiobooks free with my library card?"),
|
| 128 |
-
("Late fees and how to renew", "What are the late fees for library books and how do I renew?"),
|
| 129 |
-
("Books delivered to my home", "Can the library deliver books to my home?"),
|
| 130 |
-
]),
|
| 131 |
-
("π«", "Room Hire", [
|
| 132 |
-
("How do I book a meeting room?", "How do I book a meeting room at a Worcestershire library?"),
|
| 133 |
-
("How much does room hire cost?", "What does it cost to hire a meeting room at Worcestershire Libraries?"),
|
| 134 |
-
("When is room hire free?", "When is room hire free at Worcestershire Libraries?"),
|
| 135 |
-
("Book a room online", "Where can I book a library meeting room online?"),
|
| 136 |
-
]),
|
| 137 |
-
("π‘", "Support & Wellbeing", [
|
| 138 |
-
("Free drop-in β no card needed", "What is the Warm Welcome programme at Worcestershire Libraries?"),
|
| 139 |
-
("Free courses & digital skills", "What free adult learning courses are available at Worcestershire Libraries?"),
|
| 140 |
-
("Dementia support", "Tell me about the Memories and Me dementia support programme at the library."),
|
| 141 |
-
("Help finding work or business", "What support does the library offer for finding work or starting a business?"),
|
| 142 |
-
]),
|
| 143 |
-
]
|
| 144 |
-
|
| 145 |
-
WELCOME_TEXT = """## π Welcome to Worcestershire Libraries
|
| 146 |
-
|
| 147 |
-
Ask me anything β opening hours, mobile library, events, printing, membership, or any other service.
|
| 148 |
-
|
| 149 |
-
*Not sure what to ask? Try:*
|
| 150 |
-
- *"When does the mobile library visit [your village]?"*
|
| 151 |
-
- *"How do I print at the library?"*
|
| 152 |
-
- *"What's on for children this week?"*
|
| 153 |
-
"""
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
if isinstance(content, str):
|
| 160 |
-
return content
|
| 161 |
-
if isinstance(content, list):
|
| 162 |
-
return " ".join(b.get("text", "") for b in content if isinstance(b, dict))
|
| 163 |
-
return str(content) if content else ""
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def _normalize_history(history: list) -> list[ChatMessage]:
|
| 167 |
-
"""Convert any Gradio history format to clean ChatMessage objects with string content.
|
| 168 |
-
|
| 169 |
-
Gradio 6 serialises ChatMessage.content as list-of-blocks when reading back
|
| 170 |
-
the chatbot state. This strips that back to plain text so it never leaks into
|
| 171 |
-
the chat display or the API call.
|
| 172 |
-
"""
|
| 173 |
-
clean: list[ChatMessage] = []
|
| 174 |
-
for msg in history or []:
|
| 175 |
-
if hasattr(msg, "role"):
|
| 176 |
-
role, content = msg.role, _blocks_to_str(msg.content)
|
| 177 |
-
elif isinstance(msg, dict):
|
| 178 |
-
role, content = msg.get("role", "user"), _blocks_to_str(msg.get("content", ""))
|
| 179 |
-
else:
|
| 180 |
-
continue
|
| 181 |
-
if role in ("user", "assistant") and content.strip():
|
| 182 |
-
clean.append(ChatMessage(role=role, content=content))
|
| 183 |
-
return clean
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
def _extract_source(context: str) -> str:
|
| 187 |
-
"""Pull source URL and crawl date out of query_tool output, formatted for display."""
|
| 188 |
-
url_match = re.search(r'\*\*Source:\*\* \[(https?://[^\]]+)\]\([^\)]+\)', context)
|
| 189 |
-
date_match = re.search(r'Last updated from website: (\d{4}-\d{2}-\d{2})', context)
|
| 190 |
-
|
| 191 |
-
if not url_match:
|
| 192 |
-
return ""
|
| 193 |
-
|
| 194 |
-
url = url_match.group(1)
|
| 195 |
-
label = url.split("/")[-1].replace("-", " ").replace("_", " ").title() or "Library website"
|
| 196 |
-
date_str = date_match.group(1) if date_match else "unknown date"
|
| 197 |
-
|
| 198 |
-
# Freshness warning
|
| 199 |
-
warning = ""
|
| 200 |
try:
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
""
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
messages.pop(0)
|
| 238 |
-
return messages
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
def _no_llm_response(context: str, question: str) -> str:
|
| 242 |
-
"""Format a context-only response when no API key is available."""
|
| 243 |
-
if not context or "no relevant content" in context.lower():
|
| 244 |
-
return (
|
| 245 |
-
"> *AI assistant not available β showing direct wiki search result.*\n\n"
|
| 246 |
-
"I couldn't find specific information about that in the library wiki.\n\n"
|
| 247 |
-
"Please contact your local library or visit "
|
| 248 |
-
"[worcestershire.gov.uk/libraries](https://www.worcestershire.gov.uk/council-services/libraries)."
|
| 249 |
-
)
|
| 250 |
-
return (
|
| 251 |
-
"> *AI assistant not available β showing library knowledge base content directly.*\n\n"
|
| 252 |
-
+ context
|
| 253 |
-
)
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
# ββ Core chat handler βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 257 |
-
|
| 258 |
-
def respond(message: str, history: list):
|
| 259 |
-
"""Generator: process a chat message and stream the response.
|
| 260 |
-
|
| 261 |
-
Yields 3-tuples: (chatbot_value, history_state_value, msg_clear).
|
| 262 |
-
Owning history_state directly avoids the lambda-h-copy pattern that lets
|
| 263 |
-
Gradio's internal list-of-blocks serialisation leak into the display.
|
| 264 |
-
"""
|
| 265 |
-
if not message.strip():
|
| 266 |
-
yield history, history, ""
|
| 267 |
return
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
# Add user message
|
| 273 |
-
history = history + [ChatMessage(role="user", content=message)]
|
| 274 |
-
yield history, history, ""
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
if not LLM_AVAILABLE:
|
| 281 |
-
reply = _no_llm_response(context, message)
|
| 282 |
-
result = history + [ChatMessage(role="assistant", content=reply)]
|
| 283 |
-
yield result, result, ""
|
| 284 |
return
|
| 285 |
|
| 286 |
-
|
| 287 |
-
api_messages = _history_to_anthropic(history[:-1])
|
| 288 |
-
|
| 289 |
-
# Inject retrieved context into the user turn
|
| 290 |
-
if context and "no relevant content" not in context.lower():
|
| 291 |
-
user_content = (
|
| 292 |
-
f"{message}\n\n"
|
| 293 |
-
f"---\nRelevant library information from the wiki:\n\n{context}"
|
| 294 |
-
)
|
| 295 |
-
else:
|
| 296 |
-
user_content = message
|
| 297 |
-
|
| 298 |
-
api_messages.append({"role": "user", "content": user_content})
|
| 299 |
-
|
| 300 |
-
# Stream response
|
| 301 |
-
accumulated = ""
|
| 302 |
-
history = history + [ChatMessage(role="assistant", content="")]
|
| 303 |
-
|
| 304 |
try:
|
| 305 |
-
|
| 306 |
-
with _anthropic_client.messages.stream(
|
| 307 |
-
model="claude-haiku-4-5-20251001",
|
| 308 |
-
max_tokens=700,
|
| 309 |
-
system=SYSTEM_PROMPT,
|
| 310 |
-
messages=api_messages,
|
| 311 |
-
) as stream:
|
| 312 |
-
for text in stream.text_stream:
|
| 313 |
-
accumulated += text
|
| 314 |
-
history[-1] = ChatMessage(role="assistant", content=accumulated + " β")
|
| 315 |
-
yield history, history, ""
|
| 316 |
-
|
| 317 |
-
elif LLM_BACKEND == "huggingface":
|
| 318 |
-
hf_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + api_messages
|
| 319 |
-
stream = _hf_client.chat_completion(
|
| 320 |
-
model=HF_MODEL,
|
| 321 |
-
messages=hf_messages,
|
| 322 |
-
max_tokens=700,
|
| 323 |
-
stream=True,
|
| 324 |
-
)
|
| 325 |
-
for chunk in stream:
|
| 326 |
-
delta = chunk.choices[0].delta.content or ""
|
| 327 |
-
accumulated += delta
|
| 328 |
-
history[-1] = ChatMessage(role="assistant", content=accumulated + " β")
|
| 329 |
-
yield history, history, ""
|
| 330 |
-
|
| 331 |
-
# Final β remove streaming cursor, append source line
|
| 332 |
-
history[-1] = ChatMessage(role="assistant", content=accumulated.rstrip() + source_line)
|
| 333 |
-
yield history, history, ""
|
| 334 |
-
|
| 335 |
except Exception as e:
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
}
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
#wcc-header {
|
| 389 |
-
background: linear-gradient(135deg, var(--wcc-navy) 0%, #1e4db7 100%);
|
| 390 |
-
border-bottom: 4px solid var(--wcc-gold);
|
| 391 |
-
border-radius: 12px;
|
| 392 |
-
padding: 20px 28px;
|
| 393 |
-
margin-bottom: 4px;
|
| 394 |
-
color: white;
|
| 395 |
-
}
|
| 396 |
-
#wcc-header h1 {
|
| 397 |
-
margin: 0 0 4px 0;
|
| 398 |
-
font-size: 1.6rem;
|
| 399 |
-
font-weight: 700;
|
| 400 |
-
letter-spacing: -0.02em;
|
| 401 |
-
color: white !important;
|
| 402 |
-
}
|
| 403 |
-
#wcc-header p {
|
| 404 |
-
margin: 0;
|
| 405 |
-
font-size: 0.9rem;
|
| 406 |
-
opacity: 0.85;
|
| 407 |
-
color: white !important;
|
| 408 |
-
}
|
| 409 |
-
#wcc-header .badge {
|
| 410 |
-
display: inline-block;
|
| 411 |
-
background: rgba(255,255,255,0.15);
|
| 412 |
-
border-radius: 20px;
|
| 413 |
-
padding: 2px 10px;
|
| 414 |
-
margin: 6px 4px 0 0;
|
| 415 |
-
font-size: 0.78rem;
|
| 416 |
-
letter-spacing: 0.01em;
|
| 417 |
-
}
|
| 418 |
-
|
| 419 |
-
/* ββ Quick question pill buttons ββ */
|
| 420 |
-
.quick-q button {
|
| 421 |
-
background: var(--wcc-blue-light) !important;
|
| 422 |
-
color: var(--wcc-navy) !important;
|
| 423 |
-
border: 1.5px solid #93c5fd !important;
|
| 424 |
-
border-radius: 20px !important;
|
| 425 |
-
font-size: 0.8rem !important;
|
| 426 |
-
font-weight: 600 !important;
|
| 427 |
-
padding: 6px 14px !important;
|
| 428 |
-
white-space: nowrap !important;
|
| 429 |
-
transition: all 0.15s ease !important;
|
| 430 |
-
}
|
| 431 |
-
.quick-q button:hover {
|
| 432 |
-
background: var(--wcc-blue) !important;
|
| 433 |
-
color: white !important;
|
| 434 |
-
border-color: var(--wcc-blue) !important;
|
| 435 |
-
transform: translateY(-1px);
|
| 436 |
-
box-shadow: 0 3px 8px rgba(29,78,216,0.25);
|
| 437 |
-
}
|
| 438 |
-
|
| 439 |
-
/* ββ Topic navigator ββ */
|
| 440 |
-
#topic-nav-label {
|
| 441 |
-
font-size: 0.78rem;
|
| 442 |
-
color: var(--wcc-text-muted);
|
| 443 |
-
margin: 0 0 6px 2px;
|
| 444 |
-
letter-spacing: 0.02em;
|
| 445 |
-
text-transform: uppercase;
|
| 446 |
-
}
|
| 447 |
-
.topic-q button {
|
| 448 |
-
background: var(--wcc-surface) !important;
|
| 449 |
-
border: 1px solid var(--wcc-border) !important;
|
| 450 |
-
border-radius: 8px !important;
|
| 451 |
-
text-align: left !important;
|
| 452 |
-
padding: 9px 14px !important;
|
| 453 |
-
font-size: 0.88rem !important;
|
| 454 |
-
line-height: 1.4 !important;
|
| 455 |
-
color: var(--wcc-text) !important;
|
| 456 |
-
transition: background 0.12s ease, border-color 0.12s ease, padding-left 0.12s ease !important;
|
| 457 |
-
margin-bottom: 4px !important;
|
| 458 |
-
width: 100% !important;
|
| 459 |
-
cursor: pointer !important;
|
| 460 |
-
}
|
| 461 |
-
.topic-q button:hover {
|
| 462 |
-
background: var(--wcc-blue-light) !important;
|
| 463 |
-
border-color: var(--wcc-blue) !important;
|
| 464 |
-
border-left-width: 3px !important;
|
| 465 |
-
padding-left: 11px !important;
|
| 466 |
-
color: var(--wcc-navy) !important;
|
| 467 |
-
}
|
| 468 |
-
|
| 469 |
-
/* ββ No-LLM notice banner ββ */
|
| 470 |
-
#no-llm-notice {
|
| 471 |
-
background: #fffbeb;
|
| 472 |
-
border: 1px solid #fde68a;
|
| 473 |
-
border-radius: 8px;
|
| 474 |
-
padding: 8px 14px;
|
| 475 |
-
font-size: 0.82rem;
|
| 476 |
-
color: #92400e;
|
| 477 |
-
margin-top: 4px;
|
| 478 |
-
}
|
| 479 |
-
|
| 480 |
-
/* ββ Chatbot ββ */
|
| 481 |
-
#wcc-chatbot {
|
| 482 |
-
border: 1px solid var(--wcc-border) !important;
|
| 483 |
-
border-radius: 12px !important;
|
| 484 |
-
background: var(--wcc-surface) !important;
|
| 485 |
-
min-height: 460px;
|
| 486 |
-
}
|
| 487 |
-
#wcc-chatbot .message.bot { background: var(--wcc-blue-light) !important; }
|
| 488 |
-
#wcc-chatbot .message.user { background: var(--wcc-blue-light) !important; }
|
| 489 |
-
|
| 490 |
-
/* ββ Input area ββ */
|
| 491 |
-
#msg-input textarea {
|
| 492 |
-
border-radius: 10px !important;
|
| 493 |
-
border: 1.5px solid var(--wcc-border) !important;
|
| 494 |
-
font-size: 0.95rem !important;
|
| 495 |
-
background: var(--wcc-surface) !important;
|
| 496 |
-
color: var(--wcc-text) !important;
|
| 497 |
-
}
|
| 498 |
-
#msg-input textarea:focus {
|
| 499 |
-
border-color: var(--wcc-blue) !important;
|
| 500 |
-
box-shadow: 0 0 0 3px rgba(29,78,216,0.1) !important;
|
| 501 |
-
}
|
| 502 |
-
#send-btn button {
|
| 503 |
-
background: var(--wcc-blue) !important;
|
| 504 |
-
border-radius: 10px !important;
|
| 505 |
-
font-weight: 700 !important;
|
| 506 |
-
min-width: 80px;
|
| 507 |
-
}
|
| 508 |
-
#clear-btn button {
|
| 509 |
-
border-radius: 10px !important;
|
| 510 |
-
color: var(--wcc-text-muted) !important;
|
| 511 |
-
}
|
| 512 |
-
|
| 513 |
-
/* ββ Right panel ββ */
|
| 514 |
-
#right-panel { padding-left: 12px; }
|
| 515 |
-
#right-panel .prose { font-size: 0.88rem; }
|
| 516 |
-
|
| 517 |
-
/* ββ Help in person block ββ */
|
| 518 |
-
#help-in-person {
|
| 519 |
-
background: #f0fdf4;
|
| 520 |
-
border: 1px solid #86efac;
|
| 521 |
-
border-radius: 8px;
|
| 522 |
-
padding: 10px 14px;
|
| 523 |
-
font-size: 0.85rem;
|
| 524 |
-
color: #166534;
|
| 525 |
-
margin-bottom: 10px;
|
| 526 |
-
line-height: 1.6;
|
| 527 |
-
}
|
| 528 |
-
#help-in-person a { color: #15803d; font-weight: 600; }
|
| 529 |
-
|
| 530 |
-
/* ββ Footer ββ */
|
| 531 |
-
#wcc-footer {
|
| 532 |
-
background: var(--wcc-surface);
|
| 533 |
-
border: 1px solid var(--wcc-border);
|
| 534 |
-
border-radius: 10px;
|
| 535 |
-
padding: 10px 18px;
|
| 536 |
-
font-size: 0.78rem;
|
| 537 |
-
color: var(--wcc-text-muted);
|
| 538 |
-
margin-top: 8px;
|
| 539 |
-
text-align: center;
|
| 540 |
-
}
|
| 541 |
-
#wcc-footer a { color: var(--wcc-blue); }
|
| 542 |
-
|
| 543 |
-
/* ββ Mobile ββ */
|
| 544 |
-
@media (max-width: 768px) {
|
| 545 |
-
#wcc-header h1 { font-size: 1.2rem; }
|
| 546 |
-
#topic-nav-label { display: none; }
|
| 547 |
-
.topic-q button { font-size: 0.92rem !important; padding: 11px 14px !important; }
|
| 548 |
-
}
|
| 549 |
"""
|
| 550 |
|
| 551 |
-
# ββ UI builder βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 552 |
-
|
| 553 |
-
WCC_THEME = gr.themes.Soft(
|
| 554 |
-
primary_hue=gr.themes.colors.blue,
|
| 555 |
-
secondary_hue=gr.themes.colors.amber,
|
| 556 |
-
neutral_hue=gr.themes.colors.slate,
|
| 557 |
-
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
|
| 558 |
-
)
|
| 559 |
-
|
| 560 |
|
| 561 |
-
def
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
with gr.Column(scale=1, elem_id="right-panel"):
|
| 634 |
-
gr.HTML('<p id="topic-nav-label">Browse topics β click any question to get an answer</p>')
|
| 635 |
-
|
| 636 |
-
all_topic_btns = []
|
| 637 |
-
for i, (icon, section_title, questions) in enumerate(TOPIC_QUESTIONS):
|
| 638 |
-
with gr.Accordion(
|
| 639 |
-
f"{icon} {section_title}",
|
| 640 |
-
open=(i == 0),
|
| 641 |
-
elem_classes=["topic-section"],
|
| 642 |
-
):
|
| 643 |
-
for q_label, prompt in questions:
|
| 644 |
-
btn = gr.Button(
|
| 645 |
-
q_label,
|
| 646 |
-
elem_classes=["topic-q"],
|
| 647 |
-
variant="secondary",
|
| 648 |
-
size="sm",
|
| 649 |
-
)
|
| 650 |
-
all_topic_btns.append((btn, prompt))
|
| 651 |
-
|
| 652 |
-
with gr.Accordion("πΊοΈ All 23 library branches", open=False):
|
| 653 |
-
gr.Markdown("""
|
| 654 |
-
**Bromsgrove** Β· **Kidderminster** Β· **Redditch** Β· **Malvern**
|
| 655 |
-
**Evesham** Β· **Droitwich Spa** Β· **Pershore** Β· **Upton-upon-Severn**
|
| 656 |
-
**The Hive** (Worcester) Β· **St. John's** Β· **Warndon** Β· **Bewdley**
|
| 657 |
-
**Stourport-on-Severn** Β· **Tenbury Wells** Β· **Hagley** Β· **Broadway**
|
| 658 |
-
**Alvechurch** Β· **Catshill** Β· **Martley** Β· **Rubery**
|
| 659 |
-
**Welland** Β· **Woodrow** Β· **Wythall**
|
| 660 |
-
|
| 661 |
-
[Find your nearest branch β](https://www.worcestershire.gov.uk/council-services/libraries/find-library)
|
| 662 |
-
""")
|
| 663 |
-
|
| 664 |
-
gr.HTML("""
|
| 665 |
-
<div id="help-in-person">
|
| 666 |
-
<strong>Need to speak to someone?</strong><br>
|
| 667 |
-
Visit any branch β staff are always happy to help.<br>
|
| 668 |
-
<a href="mailto:libraries@worcestershire.gov.uk">libraries@worcestershire.gov.uk</a>
|
| 669 |
-
Β·
|
| 670 |
-
<a href="https://www.worcestershire.gov.uk/council-services/libraries/contact-us" target="_blank">Contact us β</a>
|
| 671 |
-
</div>
|
| 672 |
-
""")
|
| 673 |
-
|
| 674 |
-
# ββ Footer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 675 |
-
gr.HTML(f"""
|
| 676 |
-
<div id="wcc-footer">
|
| 677 |
-
Worcestershire Libraries β
|
| 678 |
-
<a href="https://www.worcestershire.gov.uk/council-services/libraries" target="_blank">worcestershire.gov.uk/libraries</a>
|
| 679 |
-
Β· Wiki last updated: {datetime.date.today().strftime("%-d %B %Y")}
|
| 680 |
-
Β· Information may change β always verify hours before visiting
|
| 681 |
-
</div>
|
| 682 |
-
""")
|
| 683 |
-
|
| 684 |
-
# ββ History state ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 685 |
-
history_state = gr.State([ChatMessage(role="assistant", content=WELCOME_TEXT)])
|
| 686 |
-
|
| 687 |
-
# ββ Event wiring βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 688 |
-
|
| 689 |
-
def _stream(message, history):
|
| 690 |
-
yield from respond(message, history)
|
| 691 |
-
|
| 692 |
-
def _clear(_history):
|
| 693 |
-
initial = [ChatMessage(role="assistant", content=WELCOME_TEXT)]
|
| 694 |
-
return initial, initial, ""
|
| 695 |
-
|
| 696 |
-
# Send on button click or Enter
|
| 697 |
-
# respond() yields (chatbot, history_state, msg) β no need for a follow-up
|
| 698 |
-
# lambda-copy to sync history_state, which was causing the blocks-repr bug.
|
| 699 |
-
send_btn.click(
|
| 700 |
-
fn=_stream,
|
| 701 |
-
inputs=[msg, history_state],
|
| 702 |
-
outputs=[chatbot, history_state, msg],
|
| 703 |
-
show_progress="hidden",
|
| 704 |
-
)
|
| 705 |
-
|
| 706 |
-
msg.submit(
|
| 707 |
-
fn=_stream,
|
| 708 |
-
inputs=[msg, history_state],
|
| 709 |
-
outputs=[chatbot, history_state, msg],
|
| 710 |
-
show_progress="hidden",
|
| 711 |
-
)
|
| 712 |
-
|
| 713 |
-
# Clear button
|
| 714 |
-
clear_btn.click(
|
| 715 |
-
fn=_clear,
|
| 716 |
-
inputs=[history_state],
|
| 717 |
-
outputs=[chatbot, history_state, msg],
|
| 718 |
-
)
|
| 719 |
-
|
| 720 |
-
# Quick question buttons
|
| 721 |
-
for btn, question in quick_btns:
|
| 722 |
-
btn.click(
|
| 723 |
-
fn=lambda q=question: q,
|
| 724 |
-
outputs=[msg],
|
| 725 |
-
).then(
|
| 726 |
-
fn=_stream,
|
| 727 |
-
inputs=[msg, history_state],
|
| 728 |
-
outputs=[chatbot, history_state, msg],
|
| 729 |
-
show_progress="hidden",
|
| 730 |
-
)
|
| 731 |
-
|
| 732 |
-
# Topic navigator questions
|
| 733 |
-
for btn, prompt in all_topic_btns:
|
| 734 |
-
btn.click(
|
| 735 |
-
fn=lambda p=prompt: p,
|
| 736 |
-
outputs=[msg],
|
| 737 |
-
).then(
|
| 738 |
-
fn=_stream,
|
| 739 |
-
inputs=[msg, history_state],
|
| 740 |
-
outputs=[chatbot, history_state, msg],
|
| 741 |
-
show_progress="hidden",
|
| 742 |
-
)
|
| 743 |
|
| 744 |
return demo
|
| 745 |
|
| 746 |
|
| 747 |
-
# ββ App instantiation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 748 |
-
# Build at module level so HF Spaces hot-reload/import mode can find the demo.
|
| 749 |
-
# launch() is only called when run directly (local dev).
|
| 750 |
-
|
| 751 |
-
demo = build_ui()
|
| 752 |
-
|
| 753 |
if __name__ == "__main__":
|
| 754 |
-
|
| 755 |
-
print("LLM mode: β Claude Haiku (ANTHROPIC_API_KEY)")
|
| 756 |
-
elif LLM_BACKEND == "huggingface":
|
| 757 |
-
print(f"LLM mode: β HuggingFace Inference β {HF_MODEL}")
|
| 758 |
-
else:
|
| 759 |
-
print("LLM mode: β context-only (set ANTHROPIC_API_KEY or HF_TOKEN)")
|
| 760 |
-
print(f"Wiki: {len(list(WIKI_DIR.rglob('*.md')))} pages loaded")
|
| 761 |
-
# HF Spaces uses 7860 by default; locally override with GRADIO_SERVER_PORT=7862
|
| 762 |
-
_port = int(os.environ.get("GRADIO_SERVER_PORT", "7860"))
|
| 763 |
-
demo.launch(
|
| 764 |
-
server_name="0.0.0.0",
|
| 765 |
-
server_port=_port,
|
| 766 |
-
share=False,
|
| 767 |
-
theme=WCC_THEME,
|
| 768 |
-
css=WCC_CSS,
|
| 769 |
-
)
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Worcestershire Libraries β Live Assistant (Build Small Hackathon)
|
|
|
|
| 3 |
|
| 4 |
+
A small-model (<=32B) civic agent that answers real questions about
|
| 5 |
+
Worcestershire Libraries from LIVE official data + a local knowledge graph.
|
| 6 |
+
|
| 7 |
+
β’ Live tools β catalogue, mobile library, events, branch hours/facilities
|
| 8 |
+
β’ Knowledge graph (GraphRAG-style) β multi-hop "which late library has a cafΓ©?"
|
| 9 |
+
β’ Curated detail β exactly what you need to sign up for each service
|
| 10 |
+
β’ Behaviour-change layer β EAST nudges + Β£-saved "value receipt" (DCMS-evidenced)
|
| 11 |
+
β’ Open agent traces β every answer logs its reasoning to traces.jsonl
|
| 12 |
+
|
| 13 |
+
Run: HF_TOKEN=... python app.py (works with no token in "no-LLM" mode too)
|
| 14 |
+
Model: set MODEL_ID (default Qwen/Qwen2.5-7B-Instruct, <=32B).
|
| 15 |
"""
|
| 16 |
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
import os
|
| 21 |
import re
|
| 22 |
+
import time
|
|
|
|
| 23 |
|
| 24 |
import gradio as gr
|
|
|
|
| 25 |
|
| 26 |
+
import library_sources as ls
|
| 27 |
+
import graph_rag
|
| 28 |
+
from trace import Trace
|
| 29 |
+
|
| 30 |
+
# --------------------------------------------------------------------------- #
|
| 31 |
+
# Model (<= 32 billion params)
|
| 32 |
+
# --------------------------------------------------------------------------- #
|
| 33 |
+
|
| 34 |
+
MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
|
| 35 |
+
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
|
| 36 |
+
|
| 37 |
+
_client = None
|
| 38 |
+
def get_client():
|
| 39 |
+
global _client
|
| 40 |
+
if _client is None:
|
| 41 |
+
from huggingface_hub import InferenceClient
|
| 42 |
+
_client = InferenceClient(model=MODEL_ID, token=HF_TOKEN, timeout=60)
|
| 43 |
+
return _client
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def llm(messages, *, max_tokens=512, temperature=0.3, stream=False):
|
| 47 |
+
return get_client().chat_completion(
|
| 48 |
+
messages, max_tokens=max_tokens, temperature=temperature, stream=stream)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --------------------------------------------------------------------------- #
|
| 52 |
+
# Tools the agent can call
|
| 53 |
+
# --------------------------------------------------------------------------- #
|
| 54 |
+
|
| 55 |
+
TOOLS = {
|
| 56 |
+
"search_catalogue": {
|
| 57 |
+
"desc": "Find a specific book, eBook, audiobook or DVD the library holds. "
|
| 58 |
+
"args: {\"query\": \"<title / author / subject>\"}",
|
| 59 |
+
"fn": lambda a: ls.search_catalogue(a.get("query", ""), limit=6)},
|
| 60 |
+
"whats_new": {
|
| 61 |
+
"desc": "Newest titles in a genre, for fun recommendations. "
|
| 62 |
+
"args: {\"genre\": \"<genre/topic>\"}",
|
| 63 |
+
"fn": lambda a: ls.whats_new(a.get("genre") or a.get("query"))},
|
| 64 |
+
"find_library": {
|
| 65 |
+
"desc": "A branch's opening hours ('open now?'), address and facilities "
|
| 66 |
+
"(toilets, parking, cafΓ©, study space). args: {\"name\": \"<branch>\"}",
|
| 67 |
+
"fn": lambda a: ls.find_library(a.get("name") or a.get("query"))},
|
| 68 |
+
"mobile_library": {
|
| 69 |
+
"desc": "When/where the mobile library van visits a village. "
|
| 70 |
+
"args: {\"place\": \"<village>\"}",
|
| 71 |
+
"fn": lambda a: ls.mobile_library(a.get("place") or a.get("query", ""))},
|
| 72 |
+
"library_events": {
|
| 73 |
+
"desc": "Upcoming events, activities, clubs and sessions. "
|
| 74 |
+
"args: {\"query\": \"<optional keyword/place>\"}",
|
| 75 |
+
"fn": lambda a: ls.library_events(a.get("query") or None, limit=8)},
|
| 76 |
+
"online_hub": {
|
| 77 |
+
"desc": "Free-from-home digital resources β eBooks (BorrowBox), newspapers/"
|
| 78 |
+
"magazines (PressReader), family history (Ancestry). args: {\"topic\": \"\"}",
|
| 79 |
+
"fn": lambda a: ls.online_hub(a.get("topic") or a.get("query"))},
|
| 80 |
+
"libraries_unlocked": {
|
| 81 |
+
"desc": "Extended 8am-8pm self-service access and which branches have it. "
|
| 82 |
+
"args: {\"branch\": \"<optional>\"}",
|
| 83 |
+
"fn": lambda a: ls.libraries_unlocked(a.get("branch") or a.get("query"))},
|
| 84 |
+
"printing_help": {
|
| 85 |
+
"desc": "How to print/photocopy incl. Print Your Way from a phone, + prices. "
|
| 86 |
+
"args: {}",
|
| 87 |
+
"fn": lambda a: ls.printing_help()},
|
| 88 |
+
"membership_help": {
|
| 89 |
+
"desc": "What you need to sign up β digital vs full vs Libraries Unlocked. "
|
| 90 |
+
"args: {\"service\": \"<optional>\"}",
|
| 91 |
+
"fn": lambda a: ls.membership_help(a.get("service") or a.get("query"))},
|
| 92 |
+
"graph_search": {
|
| 93 |
+
"desc": "Match a library by a COMBINATION of features, e.g. 'late-opening "
|
| 94 |
+
"with a cafΓ© and parking'. args: {\"query\": \"<the request>\"}",
|
| 95 |
+
"fn": lambda a: graph_rag.graph_search(a.get("query", ""))},
|
| 96 |
+
}
|
| 97 |
|
| 98 |
+
ROUTER_SYSTEM = (
|
| 99 |
+
"You route a Worcestershire Libraries question to exactly one tool.\nTools:\n"
|
| 100 |
+
+ "\n".join(f"- {n}: {t['desc']}" for n, t in TOOLS.items())
|
| 101 |
+
+ "\n- none: greeting / off-topic / general 'what can you do'.\n\n"
|
| 102 |
+
"Reply with ONLY JSON: {\"tool\": \"<name>\", \"args\": {...}}. No prose."
|
| 103 |
+
)
|
| 104 |
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
# --------------------------------------------------------------------------- #
|
| 107 |
+
# Routing β LLM first, deterministic keyword fallback always available
|
| 108 |
+
# --------------------------------------------------------------------------- #
|
| 109 |
+
|
| 110 |
+
def keyword_route(q: str) -> tuple[str, dict]:
|
| 111 |
+
t = q.lower()
|
| 112 |
+
feat_hits = sum(1 for w in ("parking", "cafΓ©", "cafe", "wifi", "wi-fi", "study",
|
| 113 |
+
"toilet", "computer", "meeting room", "baby")
|
| 114 |
+
if w in t)
|
| 115 |
+
if re.search(r"\b(print|printing|photocopy|photocopies|scan|copier)\b", t):
|
| 116 |
+
return "printing_help", {}
|
| 117 |
+
if (re.search(r"\b(which|what) librar|librar(y|ies) (with|that has)\b", t)
|
| 118 |
+
or feat_hits >= 2 or "overall" in t):
|
| 119 |
+
return "graph_search", {"query": q}
|
| 120 |
+
if re.search(r"\b(mobile library|mobile van|the van|comes to|visit)\b", t):
|
| 121 |
+
m = re.findall(r"\b([A-Z][a-z]+(?:[ -][A-Z][a-z]+)*)\b", q)
|
| 122 |
+
return "mobile_library", {"place": (m[-1] if m else q.split()[-1])}
|
| 123 |
+
if re.search(r"\b(open|opening|hours|close|closing|toilet|parking|address|"
|
| 124 |
+
r"facilit|where is|near me|study space)\b", t):
|
| 125 |
+
m = re.findall(r"\b([A-Z][a-z]+(?:[ -][A-Z][a-z]+)*)\b", q)
|
| 126 |
+
return "find_library", {"name": (m[-1] if m else "")}
|
| 127 |
+
if re.search(r"\b(unlocked|8pm|after hours|out of hours|evening access|"
|
| 128 |
+
r"open late)\b", t):
|
| 129 |
+
return "libraries_unlocked", {}
|
| 130 |
+
platform = re.search(r"\b(borrowbox|pressreader|ancestry|espacenet|ebsco|oxford|"
|
| 131 |
+
r"theory test|bfi|cobra|digital library|online (library )?hub)\b", t)
|
| 132 |
+
media = re.search(r"\b(ebooks?|e-books?|audiobooks?|emagazines?)\b", t)
|
| 133 |
+
online_ctx = re.search(r"\b(online|free|digital|from home|at home|on my phone|"
|
| 134 |
+
r"app|stream(ing)?|download)\b", t)
|
| 135 |
+
if (platform or re.search(r"\bnewspapers?|magazines?\b", t)
|
| 136 |
+
or (media and online_ctx) or re.search(r"read\b.*\bfree", t)):
|
| 137 |
+
return "online_hub", {"topic": q}
|
| 138 |
+
if re.search(r"\b(member|membership|join|library card|sign ?up|what do i need)\b", t):
|
| 139 |
+
return "membership_help", {"service": q}
|
| 140 |
+
if re.search(r"\b(event|events|what'?s on|whats on|activit|class|club|session|"
|
| 141 |
+
r"group|happening|this week)\b", t):
|
| 142 |
+
return "library_events", {"query": ""}
|
| 143 |
+
if re.search(r"\b(new|newest|latest|recommend|hot take|just in|good read|"
|
| 144 |
+
r"suggestion)\b", t):
|
| 145 |
+
return "whats_new", {"genre": re.sub(r"\b(new|newest|latest|recommend|any|"
|
| 146 |
+
r"good|some|me|a|books?)\b", " ", t).strip()}
|
| 147 |
+
if re.search(r"\b(book|books|read|novel|author|catalog|catalogue|borrow|dvd|"
|
| 148 |
+
r"have you got|do you have)\b", t):
|
| 149 |
+
q2 = re.sub(r"\b(do you have|have you got|any|the book|a copy of|books?|"
|
| 150 |
+
r"by|in stock|available)\b", " ", t)
|
| 151 |
+
return "search_catalogue", {"query": q2.strip(" ?.") or q}
|
| 152 |
+
# last resort: a bare title or "<Title> by <Author>" -> catalogue
|
| 153 |
+
if re.search(r"\bby [A-Z]", q) or len(re.findall(r"\b[A-Z][a-z]+", q)) >= 2:
|
| 154 |
+
return "search_catalogue", {"query": q}
|
| 155 |
+
return "none", {}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def route(q: str) -> tuple[str, dict, str, int]:
|
| 159 |
+
t0 = time.time()
|
| 160 |
+
if HF_TOKEN:
|
| 161 |
+
try:
|
| 162 |
+
out = llm([{"role": "system", "content": ROUTER_SYSTEM},
|
| 163 |
+
{"role": "user", "content": q}],
|
| 164 |
+
max_tokens=120, temperature=0.0)
|
| 165 |
+
m = re.search(r"\{.*\}", out.choices[0].message.content, re.S)
|
| 166 |
+
if m:
|
| 167 |
+
data = json.loads(m.group(0))
|
| 168 |
+
tool = data.get("tool", "none")
|
| 169 |
+
if tool in TOOLS or tool == "none":
|
| 170 |
+
return tool, data.get("args", {}) or {}, "llm", _ms(t0)
|
| 171 |
+
except Exception:
|
| 172 |
+
pass
|
| 173 |
+
tool, args = keyword_route(q)
|
| 174 |
+
return tool, args, "keyword", _ms(t0)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _ms(t0): return int((time.time() - t0) * 1000)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# --------------------------------------------------------------------------- #
|
| 181 |
+
# Render live tool results -> markdown (with eligibility woven in)
|
| 182 |
+
# --------------------------------------------------------------------------- #
|
| 183 |
+
|
| 184 |
+
def render_catalogue(r):
|
| 185 |
+
if r.get("error"):
|
| 186 |
+
return f"_Couldn't reach the catalogue: {r['error']}_"
|
| 187 |
+
if not r["items"]:
|
| 188 |
+
return (f"I searched for **{r['query']}** but found nothing β try fewer or "
|
| 189 |
+
f"different words.\n\nπ [Search the catalogue]({r['search_url']})")
|
| 190 |
+
out = [f"Found ~**{r.get('total_hint', r['count'])}** matches for **{r['query']}** "
|
| 191 |
+
f"β top {r['count']}:\n"]
|
| 192 |
+
for it in r["items"]:
|
| 193 |
+
meta = " Β· ".join(b for b in (it["author"], it["format"], it["year"]) if b)
|
| 194 |
+
tag = " _(borrow online)_" if it["digital"] else ""
|
| 195 |
+
link = f" β [details]({it['detail_url']})" if it["detail_url"] else ""
|
| 196 |
+
out.append(f"- {it['icon']} **{it['title']}** β {meta}{tag}{link}")
|
| 197 |
+
out.append(f"\nβ
**To borrow:** {ls.ELIGIBILITY['borrow_physical']} "
|
| 198 |
+
f"eBooks/audio need free digital membership.")
|
| 199 |
+
out.append(f"π [Full results]({r['search_url']})")
|
| 200 |
+
return "\n".join(out)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def render_whats_new(r):
|
| 204 |
+
if not r["items"]:
|
| 205 |
+
return "I couldn't pull new titles just now β try a specific genre."
|
| 206 |
+
out = [f"π **Newest '{r['genre']}' in the catalogue:**\n"]
|
| 207 |
+
for it in r["items"]:
|
| 208 |
+
meta = " Β· ".join(b for b in (it["author"], it["year"]) if b)
|
| 209 |
+
out.append(f"- {it['icon']} **{it['title']}** β {meta}")
|
| 210 |
+
out.append(f"\nπ [See more]({r['search_url']})")
|
| 211 |
+
return "\n".join(out)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def render_find_library(r):
|
| 215 |
+
if r.get("error"):
|
| 216 |
+
s = ", ".join(r.get("suggestions", [])[:6])
|
| 217 |
+
return f"{r['error']} Did you mean: {s}?\n\nπ [All libraries]({r['page_url']})"
|
| 218 |
+
if "branches" in r: # list mode
|
| 219 |
+
out = ["π **Worcestershire libraries:**\n"]
|
| 220 |
+
for b in r["branches"][:25]:
|
| 221 |
+
out.append(f"- **{b['name']}** β {b['address']}")
|
| 222 |
+
return "\n".join(out)
|
| 223 |
+
badge = "π’ **Open now**" if r["open_now"] else "π΄ **Closed now**"
|
| 224 |
+
out = [f"π **{r['name']}** β {badge} ({r['status']})",
|
| 225 |
+
f"{r['address']}\n",
|
| 226 |
+
f"**Today ({r['today']}):** {r['today_staffed'] or 'see below'}"]
|
| 227 |
+
if r.get("unlocked_today"):
|
| 228 |
+
out.append(f"**Libraries Unlocked self-service:** {r['unlocked_today']}")
|
| 229 |
+
if r.get("facilities"):
|
| 230 |
+
out.append(f"\n**Facilities:** {', '.join(r['facilities'])}")
|
| 231 |
+
out.append(f"\nβ
{ls.ELIGIBILITY['visit']}")
|
| 232 |
+
out.append(f"π [Branch page]({r['page_url']})")
|
| 233 |
+
return "\n".join(out)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def render_mobile(r):
|
| 237 |
+
if r.get("error"):
|
| 238 |
+
s = ", ".join(x.title() for x in (r.get("suggestions") or [])[:8])
|
| 239 |
+
extra = f" Did you mean: {s}?" if s else ""
|
| 240 |
+
return f"{r['error']}{extra}\n\nπ [All stops]({r.get('page_url', ls.MOBILE_INDEX)})"
|
| 241 |
+
out = [f"π **Mobile library β {r['village']}**", f"Runs: **{r['date_of_operation']}**\n"]
|
| 242 |
+
for s in r["stops"]:
|
| 243 |
+
out.append(f"- `{s['time']}` β {s['location']}")
|
| 244 |
+
out.append(f"\nβ
{ls.ELIGIBILITY['mobile']}")
|
| 245 |
+
out.append(f"Enquiries: {r['email']} Β· π [Timetable]({r['page_url']})")
|
| 246 |
+
return "\n".join(out)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def render_events(r):
|
| 250 |
+
if not r["events"]:
|
| 251 |
+
return f"No matching events found.\n\nπ [All events]({r['page_url']})"
|
| 252 |
+
out = [f"π
**{r['count']} upcoming events:**\n"]
|
| 253 |
+
for e in r["events"]:
|
| 254 |
+
when = " Β· ".join(b for b in (e.get("next_date"), e["when"], e["time"]) if b)
|
| 255 |
+
loc = f" @ {e['location']}" if e["location"] else ""
|
| 256 |
+
out.append(f"- **[{e['name']}]({e['url']})** β {when}{loc}")
|
| 257 |
+
out.append(f"\nβ
{ls.ELIGIBILITY['events']}\nπ [Full listing]({r['page_url']})")
|
| 258 |
+
return "\n".join(out)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def render_online_hub(r):
|
| 262 |
+
out = ["π» **Free online β with your library card:**\n"]
|
| 263 |
+
for it in r["items"][:5]:
|
| 264 |
+
out.append(f"**{it['name']}** β {it['summary']}")
|
| 265 |
+
if it.get("what_you_need"):
|
| 266 |
+
out.append(f" - β
**What you need:** {it['what_you_need']}")
|
| 267 |
+
if it.get("access"):
|
| 268 |
+
out.append(" - **How:** " + " β ".join(it["access"]))
|
| 269 |
+
if it.get("limits"):
|
| 270 |
+
out.append(f" - {it['limits']}")
|
| 271 |
+
if it.get("titles"):
|
| 272 |
+
out.append(f" - _Includes:_ {', '.join(it['titles'][:6])}β¦")
|
| 273 |
+
out.append("")
|
| 274 |
+
out.append(f"π [Online library hub]({r['page_url']})")
|
| 275 |
+
return "\n".join(out)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def render_unlocked(r):
|
| 279 |
+
out = ["π **Libraries Unlocked** β use the library 8amβ8pm, MonβSat, even "
|
| 280 |
+
"when it's unstaffed.",
|
| 281 |
+
f"\nβ
**What you need:** {r['what_you_need']}"]
|
| 282 |
+
if r.get("unlocks"):
|
| 283 |
+
out.append(f"\n{r['unlocks']}")
|
| 284 |
+
else:
|
| 285 |
+
out.append(f"\n**Branches:** {', '.join(r['branches'])}.")
|
| 286 |
+
if r.get("branch_match"):
|
| 287 |
+
out.append(f"\nβ Yes β **{r['branch_match']}** has Libraries Unlocked.")
|
| 288 |
+
elif r.get("branch_match") is None and "branch_match" in r:
|
| 289 |
+
out.append("\nThat branch isn't on the Libraries Unlocked list yet.")
|
| 290 |
+
out.append(f"\nπ [Libraries Unlocked]({r['page_url']})")
|
| 291 |
+
return "\n".join(out)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def render_membership(r):
|
| 295 |
+
out = ["πͺͺ **What you need to sign up:**\n"]
|
| 296 |
+
for tier in r["tiers"]:
|
| 297 |
+
out.append(f"**{tier['tier']}** β {tier['what_you_need']}")
|
| 298 |
+
out.append(f" - _Unlocks:_ {tier['unlocks']}\n")
|
| 299 |
+
if r.get("need"):
|
| 300 |
+
out.append(f"β‘οΈ For your question: **{r['need']}**")
|
| 301 |
+
out.append(f"\nπ [Join the library]({r['page_url']})")
|
| 302 |
+
return "\n".join(out)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def render_printing(r):
|
| 306 |
+
steps = "\n".join(f"{i}. {s}" for i, s in enumerate(r["steps"], 1))
|
| 307 |
+
price = "\n".join(f"- {k}: {v}" for k, v in r["pricing"].items())
|
| 308 |
+
return (f"π¨οΈ **Print Your Way**\n\n{r['summary']}\n\n**Devices:** "
|
| 309 |
+
f"{r['device_requirements']}\n\n**How to print:**\n{steps}\n\n"
|
| 310 |
+
f"**Prices:**\n{price}\n\nπ [Printing page]({r['page_url']})")
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def render_graph(r):
|
| 314 |
+
_frag = {"caf": "a cafΓ©", "meeting": "meeting rooms", "wi-fi": "free Wi-Fi",
|
| 315 |
+
"study": "study space", "parking": "parking", "computer": "public computers",
|
| 316 |
+
"toilet": "toilets", "baby": "baby changing", "accessible": "accessible toilet",
|
| 317 |
+
"wheelchair": "wheelchair access", "printing": "printing", "self": "self-service"}
|
| 318 |
+
if r["kind"] == "branch_filter":
|
| 319 |
+
feats = []
|
| 320 |
+
if r["late"]:
|
| 321 |
+
feats.append("open late (8amβ8pm)")
|
| 322 |
+
feats += [_frag.get(f, f) for f in r["wanted_facilities"]]
|
| 323 |
+
if r["area"]:
|
| 324 |
+
feats.append(f"in {r['area']}")
|
| 325 |
+
crit = ", ".join(feats) or "your criteria"
|
| 326 |
+
if not r["branches"]:
|
| 327 |
+
return (f"No library currently matches **{crit}** in our data. "
|
| 328 |
+
"Try fewer features.\n\nπ [All libraries](" + r["page_url"] + ")")
|
| 329 |
+
out = [f"π§ Libraries matching **{crit}**:\n"]
|
| 330 |
+
for b in r["branches"]:
|
| 331 |
+
lu = " Β· open to 8pm" if b["libraries_unlocked"] else ""
|
| 332 |
+
out.append(f"- **{b['name']}**{lu} β {', '.join(b['facilities'])}")
|
| 333 |
+
return "\n".join(out)
|
| 334 |
+
if r["kind"] == "entity":
|
| 335 |
+
if not r["entities"]:
|
| 336 |
+
return "I couldn't find that in the knowledge graph β try rephrasing."
|
| 337 |
+
out = []
|
| 338 |
+
for e in r["entities"][:3]:
|
| 339 |
+
out.append(f"**{e['label']}** ({e['type']}) β {e.get('summary','')[:160]}")
|
| 340 |
+
if e.get("what_you_need"):
|
| 341 |
+
out.append(f" - β
{e['what_you_need']}")
|
| 342 |
+
rel = ", ".join(f"{x['rel'].lower().replace('_',' ')} {x['label']}"
|
| 343 |
+
for x in e["related"][:4])
|
| 344 |
+
if rel:
|
| 345 |
+
out.append(f" - _linked to:_ {rel}")
|
| 346 |
+
if e.get("url"):
|
| 347 |
+
out.append(f" - π [more]({e['url']})")
|
| 348 |
+
return "\n".join(out)
|
| 349 |
+
# global
|
| 350 |
+
out = ["πΊοΈ **Across the whole service:**\n"]
|
| 351 |
+
for c in r["communities"]:
|
| 352 |
+
out.append(f"- **{c['title']}** β {c['report'][:200]}")
|
| 353 |
+
return "\n".join(out)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
RENDER = {
|
| 357 |
+
"search_catalogue": render_catalogue, "whats_new": render_whats_new,
|
| 358 |
+
"find_library": render_find_library, "mobile_library": render_mobile,
|
| 359 |
+
"library_events": render_events, "online_hub": render_online_hub,
|
| 360 |
+
"libraries_unlocked": render_unlocked, "membership_help": render_membership,
|
| 361 |
+
"printing_help": render_printing, "graph_search": render_graph,
|
| 362 |
+
}
|
| 363 |
|
|
|
|
|
|
|
| 364 |
|
| 365 |
+
# --------------------------------------------------------------------------- #
|
| 366 |
+
# Behaviour-change layer β Β£-saved value receipt + EAST nudges + chips
|
| 367 |
+
# --------------------------------------------------------------------------- #
|
| 368 |
+
|
| 369 |
+
def value_receipt(tool, raw):
|
| 370 |
+
if tool in ("search_catalogue", "whats_new") and raw.get("items"):
|
| 371 |
+
return "π· _Borrowing instead of buying β **Β£9βΒ£20 saved** per title._"
|
| 372 |
+
if tool == "online_hub":
|
| 373 |
+
return ("π· _Free with your card β a newspaper or eBook subscription is "
|
| 374 |
+
"**~Β£8βΒ£12/month** you don't pay._")
|
| 375 |
+
if tool == "printing_help":
|
| 376 |
+
return "π· _Far cheaper than a high-street print shop._"
|
| 377 |
+
return ""
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# (EAST: Easy=chips, Attractive=value, Social/Timely=nudge)
|
| 381 |
+
NUDGES = {
|
| 382 |
+
"search_catalogue": ("π‘ No time to visit? Many titles are free on **BorrowBox** tonight.",
|
| 383 |
+
["Is it on BorrowBox?", "Reserve & collect β how?", "Hot takes on new books"]),
|
| 384 |
+
"whats_new": ("π‘ Reserve it free and collect at your branch.",
|
| 385 |
+
["More like this", "Is it an eBook?", "What's on this week?"]),
|
| 386 |
+
"find_library": ("π‘ Want in before/after staffed hours? **Libraries Unlocked** = 8amβ8pm.",
|
| 387 |
+
["Tell me about Libraries Unlocked", "What's on there?", "How do I join?"]),
|
| 388 |
+
"mobile_library": ("π‘ Housebound? The **Home Library Service** brings books to your door.",
|
| 389 |
+
["How do I join?", "What's on this week?", "Find my nearest library"]),
|
| 390 |
+
"library_events": ("π‘ Most events are free β just turn up.",
|
| 391 |
+
["Children's events", "Do I need to book?", "Find my nearest library"]),
|
| 392 |
+
"online_hub": ("π‘ It's free with your card β set up tonight from your sofa.",
|
| 393 |
+
["How do I sign up?", "What newspapers are there?", "BorrowBox limits"]),
|
| 394 |
+
"libraries_unlocked": ("π‘ It's free β just a quick one-off induction.",
|
| 395 |
+
["Which branches?", "How do I get the induction?", "What can I do there?"]),
|
| 396 |
+
"printing_help": ("π‘ No printer at home? Print from your phone, collect within 24h.",
|
| 397 |
+
["Printing prices", "Find my nearest library", "How do I join?"]),
|
| 398 |
+
"membership_help": ("π‘ Digital membership is instant β no card needed.",
|
| 399 |
+
["Set up digital membership", "What's the difference?", "What can I borrow online?"]),
|
| 400 |
+
"graph_search": ("π‘ Tell me what matters (late, cafΓ©, study space) and I'll match a branch.",
|
| 401 |
+
["Late-opening + cafΓ©", "Find a book", "What's on this week?"]),
|
| 402 |
+
}
|
| 403 |
+
HELP_CHIPS = ["Do you have Harry Potter?", "Mobile library near me",
|
| 404 |
+
"What's on this week?", "How do I print from my phone?"]
|
| 405 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
+
# --------------------------------------------------------------------------- #
|
| 408 |
+
# Synthesis
|
| 409 |
+
# --------------------------------------------------------------------------- #
|
| 410 |
+
|
| 411 |
+
SYNTH_SYSTEM = (
|
| 412 |
+
"You are the Worcestershire Libraries assistant. Answer ONLY from the LIVE "
|
| 413 |
+
"DATA provided β never invent titles, times, prices, stops or facilities. Warm, "
|
| 414 |
+
"concise, British English. Keep the markdown links and the β
/π lines from the "
|
| 415 |
+
"data. If the data doesn't answer it, say so and point to the source link.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
|
| 418 |
+
def synthesize_stream(question, rendered):
|
| 419 |
+
if not HF_TOKEN:
|
| 420 |
+
yield rendered
|
| 421 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
try:
|
| 423 |
+
stream = llm([{"role": "system", "content": SYNTH_SYSTEM},
|
| 424 |
+
{"role": "user", "content": f"Question: {question}\n\n"
|
| 425 |
+
f"LIVE DATA:\n{rendered}"}],
|
| 426 |
+
max_tokens=650, temperature=0.3, stream=True)
|
| 427 |
+
acc = ""
|
| 428 |
+
for chunk in stream:
|
| 429 |
+
d = chunk.choices[0].delta.content or ""
|
| 430 |
+
if d:
|
| 431 |
+
acc += d
|
| 432 |
+
yield acc
|
| 433 |
+
if not acc.strip():
|
| 434 |
+
yield rendered
|
| 435 |
+
except Exception:
|
| 436 |
+
yield rendered
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
HELP = (
|
| 440 |
+
"π I'm the **Worcestershire Libraries assistant**. I check the council site "
|
| 441 |
+
"and catalogue *live* and can help you:\n\n"
|
| 442 |
+
"- π **Find a book / eBook / audiobook**\n"
|
| 443 |
+
"- π **Branch hours, 'open now?', toilets, parking**\n"
|
| 444 |
+
"- π **Mobile library** times for your village\n"
|
| 445 |
+
"- π
**What's on** this week\n"
|
| 446 |
+
"- π» **Free online** β newspapers, magazines, family history\n"
|
| 447 |
+
"- π¨οΈ **Printing** from your phone\n\n"
|
| 448 |
+
"_Official sources only β I don't use the out-of-date Hive website._")
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
# --------------------------------------------------------------------------- #
|
| 452 |
+
# Chat handler β yields (answer_text, chips_or_None)
|
| 453 |
+
# --------------------------------------------------------------------------- #
|
| 454 |
+
|
| 455 |
+
def respond(message, history):
|
| 456 |
+
message = (message or "").strip()
|
| 457 |
+
if not message:
|
| 458 |
+
yield HELP, HELP_CHIPS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
return
|
| 460 |
|
| 461 |
+
tr = Trace(message, MODEL_ID)
|
| 462 |
+
tool, args, how, rms = route(message)
|
| 463 |
+
tr.set_route(tool, args, how, rms)
|
|
|
|
|
|
|
|
|
|
| 464 |
|
| 465 |
+
if tool == "none":
|
| 466 |
+
tr.finish(HELP, []).save()
|
| 467 |
+
yield HELP, HELP_CHIPS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
return
|
| 469 |
|
| 470 |
+
t1 = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
try:
|
| 472 |
+
raw = TOOLS[tool]["fn"](args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
except Exception as e:
|
| 474 |
+
tr.step("tool_call", name=tool, ok=False, error=str(e)).finish("", []).save()
|
| 475 |
+
yield (f"Sorry β I couldn't reach the library source just now. "
|
| 476 |
+
f"Please try again in a moment."), None
|
| 477 |
+
return
|
| 478 |
+
rendered = RENDER[tool](raw)
|
| 479 |
+
source = raw.get("page_url") or raw.get("search_url") or ""
|
| 480 |
+
tr.step("tool_call", name=tool, source=source, ms=_ms(t1), ok=True)
|
| 481 |
+
|
| 482 |
+
t2 = time.time()
|
| 483 |
+
answer = ""
|
| 484 |
+
for partial in synthesize_stream(message, rendered):
|
| 485 |
+
answer = partial
|
| 486 |
+
yield answer, None
|
| 487 |
+
tr.step("synthesis", model=MODEL_ID, ms=_ms(t2))
|
| 488 |
+
|
| 489 |
+
# behaviour-change extras + provenance + open trace
|
| 490 |
+
value = value_receipt(tool, raw)
|
| 491 |
+
nudge, chips = NUDGES.get(tool, ("", []))
|
| 492 |
+
checked = raw.get("checked", "")
|
| 493 |
+
footer = (f"\n\n<sub>π Checked **live**"
|
| 494 |
+
+ (f" Β· {checked}" if checked else "")
|
| 495 |
+
+ f" Β· tool `{tool}` ({how})"
|
| 496 |
+
+ (f" Β· [source]({source})" if source else "") + "</sub>")
|
| 497 |
+
tr.finish(answer, [source]).save()
|
| 498 |
+
|
| 499 |
+
final = answer
|
| 500 |
+
if value:
|
| 501 |
+
final += f"\n\n{value}"
|
| 502 |
+
if nudge:
|
| 503 |
+
final += f"\n\n{nudge}"
|
| 504 |
+
final += footer + tr.to_markdown()
|
| 505 |
+
yield final, chips
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
# --------------------------------------------------------------------------- #
|
| 509 |
+
# UI
|
| 510 |
+
# --------------------------------------------------------------------------- #
|
| 511 |
+
|
| 512 |
+
CSS = """
|
| 513 |
+
:root { --wcc:#0a7d78; --wcc-dark:#075a56; }
|
| 514 |
+
.gradio-container { max-width: 940px !important; margin: auto !important;
|
| 515 |
+
font-family:'Segoe UI', system-ui, sans-serif; }
|
| 516 |
+
#hero { background:linear-gradient(135deg,var(--wcc),var(--wcc-dark)); color:#fff;
|
| 517 |
+
padding:22px 26px; border-radius:16px; margin-bottom:12px;
|
| 518 |
+
box-shadow:0 6px 20px rgba(7,90,86,.25); }
|
| 519 |
+
#hero h1 { margin:0; font-size:1.5rem; }
|
| 520 |
+
#hero p { margin:.4rem 0 0; opacity:.92; font-size:.93rem; }
|
| 521 |
+
#hero .live { display:inline-block; background:#fff; color:var(--wcc-dark);
|
| 522 |
+
font-weight:700; font-size:.7rem; padding:2px 9px; border-radius:999px;
|
| 523 |
+
margin-bottom:8px; text-transform:uppercase; letter-spacing:.5px; }
|
| 524 |
+
footer { visibility:hidden; }
|
| 525 |
+
.prose a { color:var(--wcc-dark); font-weight:600; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
"""
|
| 527 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
|
| 529 |
+
def build_demo():
|
| 530 |
+
with gr.Blocks(css=CSS, title="Worcestershire Libraries β Live Assistant",
|
| 531 |
+
theme=gr.themes.Soft(primary_hue="teal")) as demo:
|
| 532 |
+
gr.HTML("<div id='hero'><span class='live'>β live data</span>"
|
| 533 |
+
"<h1>Worcestershire Libraries β Live Assistant</h1>"
|
| 534 |
+
"<p>Books, mobile library, events, printing and what's free online β "
|
| 535 |
+
"answered live from the council site & catalogue, with exactly what "
|
| 536 |
+
"you need to sign up.</p></div>")
|
| 537 |
+
|
| 538 |
+
chat = gr.Chatbot(type="messages", height=460, show_label=False,
|
| 539 |
+
placeholder="π Ask me anything about your local libraryβ¦",
|
| 540 |
+
elem_classes=["prose"])
|
| 541 |
+
with gr.Row():
|
| 542 |
+
box = gr.Textbox(placeholder="e.g. When does the mobile library visit Abberley?",
|
| 543 |
+
show_label=False, scale=8, autofocus=True)
|
| 544 |
+
send = gr.Button("Ask", variant="primary", scale=1)
|
| 545 |
+
with gr.Row():
|
| 546 |
+
chips = [gr.Button(visible=False, size="sm", variant="secondary")
|
| 547 |
+
for _ in range(3)]
|
| 548 |
+
|
| 549 |
+
gr.Examples(
|
| 550 |
+
["Do you have Harry Potter audiobooks?",
|
| 551 |
+
"Is Malvern library open now?",
|
| 552 |
+
"A late-opening library with a cafΓ© and meeting rooms",
|
| 553 |
+
"When does the mobile library visit Abberley?",
|
| 554 |
+
"Can I read newspapers for free?",
|
| 555 |
+
"What do I need to sign up?"],
|
| 556 |
+
inputs=box, label="Try one")
|
| 557 |
+
|
| 558 |
+
if not HF_TOKEN:
|
| 559 |
+
gr.Markdown("> β οΈ No `HF_TOKEN` set β **no-LLM mode**: you get the raw "
|
| 560 |
+
"live data (still fully working). Add an `HF_TOKEN` secret "
|
| 561 |
+
"for conversational phrasing.")
|
| 562 |
+
|
| 563 |
+
def hide3():
|
| 564 |
+
return tuple(gr.update(visible=False) for _ in range(3))
|
| 565 |
+
|
| 566 |
+
def show3(sugg):
|
| 567 |
+
sugg = (sugg or []) + ["", "", ""]
|
| 568 |
+
return tuple(gr.update(value=sugg[i], visible=bool(sugg[i]))
|
| 569 |
+
for i in range(3))
|
| 570 |
+
|
| 571 |
+
def user_turn(msg, hist):
|
| 572 |
+
if not (msg or "").strip():
|
| 573 |
+
return "", hist or [], *hide3()
|
| 574 |
+
return "", (hist or []) + [{"role": "user", "content": msg}], *hide3()
|
| 575 |
+
|
| 576 |
+
def chip_turn(label, hist):
|
| 577 |
+
return "", (hist or []) + [{"role": "user", "content": label}], *hide3()
|
| 578 |
+
|
| 579 |
+
def bot_turn(hist):
|
| 580 |
+
if not hist or hist[-1]["role"] != "user":
|
| 581 |
+
yield hist, *hide3()
|
| 582 |
+
return
|
| 583 |
+
msg = hist[-1]["content"]
|
| 584 |
+
hist = hist + [{"role": "assistant", "content": ""}]
|
| 585 |
+
final_chips = []
|
| 586 |
+
for text, ch in respond(msg, hist[:-1]):
|
| 587 |
+
hist[-1]["content"] = text
|
| 588 |
+
if ch is not None:
|
| 589 |
+
final_chips = ch
|
| 590 |
+
yield hist, gr.update(), gr.update(), gr.update()
|
| 591 |
+
yield hist, *show3(final_chips)
|
| 592 |
+
|
| 593 |
+
outs = [chat, *chips]
|
| 594 |
+
box.submit(user_turn, [box, chat], [box, chat, *chips], queue=False).then(
|
| 595 |
+
bot_turn, chat, outs)
|
| 596 |
+
send.click(user_turn, [box, chat], [box, chat, *chips], queue=False).then(
|
| 597 |
+
bot_turn, chat, outs)
|
| 598 |
+
for c in chips:
|
| 599 |
+
c.click(chip_turn, [c, chat], [box, chat, *chips], queue=False).then(
|
| 600 |
+
bot_turn, chat, outs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
|
| 602 |
return demo
|
| 603 |
|
| 604 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
if __name__ == "__main__":
|
| 606 |
+
build_demo().queue().launch(server_name="0.0.0.0", server_port=7860)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
build_kb.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
build_kb.py β crawl the *entire* Worcestershire Libraries section of
|
| 3 |
+
worcestershire.gov.uk and build one current, reliable knowledge base.
|
| 4 |
+
|
| 5 |
+
Output: library_kb.json with three parts:
|
| 6 |
+
β’ services β every library service/content page: summary + WHAT YOU NEED
|
| 7 |
+
to sign up (eligibility) + how-to steps + source URL
|
| 8 |
+
β’ branches β each library: address, day-by-day hours (core + Libraries
|
| 9 |
+
Unlocked), facilities (toilets, parking, cafe, wifi ...)
|
| 10 |
+
β’ online_hub β each online resource (BorrowBox, Ancestry ...) + what you need
|
| 11 |
+
|
| 12 |
+
Run: python build_kb.py (re-run any time to refresh β it's all live)
|
| 13 |
+
|
| 14 |
+
We only ever read worcestershire.gov.uk (the council's own pages). We never
|
| 15 |
+
touch thehiveworcester.org β that content is unreliable / out of date.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import re
|
| 22 |
+
import time
|
| 23 |
+
from datetime import datetime, timezone
|
| 24 |
+
|
| 25 |
+
import requests
|
| 26 |
+
from bs4 import BeautifulSoup
|
| 27 |
+
|
| 28 |
+
GOV = "https://www.worcestershire.gov.uk"
|
| 29 |
+
SITEMAP = f"{GOV}/sitemap.xml"
|
| 30 |
+
HEADERS = {"User-Agent": "WorcsLibrariesKB/2.0 (hackathon; +council pages only)"}
|
| 31 |
+
TIMEOUT = 20
|
| 32 |
+
DELAY = 0.25 # be polite
|
| 33 |
+
|
| 34 |
+
# Pages that are not user-facing services β skip from the KB.
|
| 35 |
+
SKIP = re.compile(
|
| 36 |
+
r"/events/|/mobile-library/|privacy-notice|byelaws|reading-pledge-wall|"
|
| 37 |
+
r"worcestershires?-library-stories|/library-stories|service-disruption",
|
| 38 |
+
re.I,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
FACILITY_VOCAB = {
|
| 42 |
+
"public toilets": r"\bpublic toilets?\b",
|
| 43 |
+
"accessible toilet": r"\b(disabled|accessible) toilets?\b",
|
| 44 |
+
"baby changing": r"\bbaby chang",
|
| 45 |
+
"wheelchair access": r"\bwheelchair|step-?free|level access\b",
|
| 46 |
+
"hearing loop": r"\bhearing loop|induction loop\b",
|
| 47 |
+
"free Wi-Fi": r"\bwi-?fi\b",
|
| 48 |
+
"public computers": r"\b(public )?computers?\b",
|
| 49 |
+
"study space": r"\bstudy (space|area|room)|quiet (space|study)\b",
|
| 50 |
+
"meeting rooms": r"\bmeeting rooms?\b",
|
| 51 |
+
"cafΓ©": r"\bcaf[eΓ©]\b",
|
| 52 |
+
"parking": r"\bparking\b",
|
| 53 |
+
"self-service": r"\bself-?service\b",
|
| 54 |
+
"printing": r"\bprint(ing)?|photocopy",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# Sentence triggers that signal eligibility / "what you need to sign up".
|
| 58 |
+
NEED_TRIGGERS = re.compile(
|
| 59 |
+
r"\b(you('| wi)?ll need|you need|to (join|use|access|register|sign ?up)|"
|
| 60 |
+
r"who can|available to|free (to|for)|eligible|membership|library card|"
|
| 61 |
+
r"\bPIN\b|register|sign ?up|induction|aged \d+|residents?|anyone|upgrade)\b",
|
| 62 |
+
re.I,
|
| 63 |
+
)
|
| 64 |
+
# Time-bound notices that LOOK like eligibility but aren't (drop them).
|
| 65 |
+
NEED_EXCLUDE = re.compile(
|
| 66 |
+
r"\bfrom \d|will be on hand|demonstrate|this (summer|christmas|autumn|spring)|"
|
| 67 |
+
r"\b(january|february|march|april|may|june|july|august|september|october|"
|
| 68 |
+
r"november|december)\b|\b20\d\d\b", re.I,
|
| 69 |
+
)
|
| 70 |
+
# Dated / campaign pages β tag as seasonal so they're not shown as standing services.
|
| 71 |
+
SEASONAL = re.compile(
|
| 72 |
+
r"summer-reading-challenge|world-book-day|steamfest|christmas|halloween|"
|
| 73 |
+
r"national-year|young-poet|get-school-ready|reading-pledge|world book day|"
|
| 74 |
+
r"this summer", re.I,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _get(url: str) -> str:
|
| 81 |
+
r = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
|
| 82 |
+
r.raise_for_status()
|
| 83 |
+
return r.text
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def discover() -> list[str]:
|
| 87 |
+
"""All unique library URLs from the sitemap (deduped across path prefixes)."""
|
| 88 |
+
idx = _get(SITEMAP)
|
| 89 |
+
pages = sorted(set(re.findall(r"sitemap\.xml\?page=\d+", idx)))
|
| 90 |
+
urls: set[str] = set()
|
| 91 |
+
for p in pages:
|
| 92 |
+
xml = _get(f"{GOV}/{p}")
|
| 93 |
+
for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
|
| 94 |
+
if "librar" in loc.lower():
|
| 95 |
+
urls.add(loc.strip())
|
| 96 |
+
# collapse /libraries/ vs /worcestershire-libraries/ duplicates by slug-tail
|
| 97 |
+
canon: dict[str, str] = {}
|
| 98 |
+
for u in urls:
|
| 99 |
+
key = re.sub(r".*?/(worcestershire-)?libraries/?", "", u).rstrip("/")
|
| 100 |
+
# prefer the shorter, canonical /libraries/ form
|
| 101 |
+
if key not in canon or "/worcestershire-libraries/" not in u:
|
| 102 |
+
canon.setdefault(key, u)
|
| 103 |
+
if "/worcestershire-libraries/" not in u:
|
| 104 |
+
canon[key] = u
|
| 105 |
+
return sorted(canon.values())
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def main_text(soup: BeautifulSoup):
|
| 109 |
+
main = soup.find("main") or soup.find(id="main-content") or soup
|
| 110 |
+
for bad in main.select("nav, header, footer, script, style, .breadcrumb, form"):
|
| 111 |
+
bad.decompose()
|
| 112 |
+
return main
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def intro(main) -> str:
|
| 116 |
+
p = main.find("p")
|
| 117 |
+
return re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if p else ""
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def what_you_need(main) -> list[str]:
|
| 121 |
+
txt = re.sub(r"\s+", " ", main.get_text(" ", strip=True))
|
| 122 |
+
out, seen = [], set()
|
| 123 |
+
for m in re.finditer(r"[^.!?]*\.", txt):
|
| 124 |
+
seg = m.group(0).strip()
|
| 125 |
+
if (15 <= len(seg) <= 220 and NEED_TRIGGERS.search(seg)
|
| 126 |
+
and not NEED_EXCLUDE.search(seg)):
|
| 127 |
+
key = seg.lower()
|
| 128 |
+
if key not in seen:
|
| 129 |
+
seen.add(key)
|
| 130 |
+
out.append(seg)
|
| 131 |
+
if len(out) >= 5:
|
| 132 |
+
break
|
| 133 |
+
return out
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def how_to(main) -> list[str]:
|
| 137 |
+
for ol in main.find_all("ol"):
|
| 138 |
+
items = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))
|
| 139 |
+
for li in ol.find_all("li")]
|
| 140 |
+
items = [i for i in items if 4 < len(i) < 200]
|
| 141 |
+
if 1 < len(items) <= 12:
|
| 142 |
+
return items
|
| 143 |
+
return []
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def title_of(soup, url) -> str:
|
| 147 |
+
h1 = soup.find("h1")
|
| 148 |
+
if h1:
|
| 149 |
+
return h1.get_text(" ", strip=True)
|
| 150 |
+
return url.rstrip("/").split("/")[-1].replace("-", " ").title()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def parse_hours(main) -> dict[str, dict]:
|
| 154 |
+
"""{Day: {"staffed": "...", "unlocked": "..."}} β handles both the
|
| 155 |
+
Libraries-Unlocked column table and plain 'Day: hours' text."""
|
| 156 |
+
# 1) structured table (Libraries Unlocked branches)
|
| 157 |
+
for table in main.find_all("table"):
|
| 158 |
+
rows = table.find_all("tr")
|
| 159 |
+
if not rows:
|
| 160 |
+
continue
|
| 161 |
+
headers = [c.get_text(" ", strip=True).lower()
|
| 162 |
+
for c in rows[0].find_all(["th", "td"])]
|
| 163 |
+
core_idx = next((i for i, h in enumerate(headers)
|
| 164 |
+
if "core" in h or "staffed" in h), None)
|
| 165 |
+
out: dict[str, dict] = {}
|
| 166 |
+
for tr in rows[1:]:
|
| 167 |
+
cells = [re.sub(r"\s+", " ", c.get_text(" ", strip=True))
|
| 168 |
+
for c in tr.find_all(["th", "td"])]
|
| 169 |
+
if cells and cells[0].lower() in DAYS:
|
| 170 |
+
staffed = (cells[core_idx] if core_idx and core_idx < len(cells)
|
| 171 |
+
else (cells[1] if len(cells) > 1 else ""))
|
| 172 |
+
out[cells[0].title()] = {"staffed": staffed,
|
| 173 |
+
"unlocked": "8:00am to 8:00pm"}
|
| 174 |
+
if out:
|
| 175 |
+
return out
|
| 176 |
+
# 2) plain-text 'Day: hours' (community libraries)
|
| 177 |
+
txt = main.get_text("\n")
|
| 178 |
+
out = {}
|
| 179 |
+
for m in re.finditer(
|
| 180 |
+
r"(?im)^\s*(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s*:"
|
| 181 |
+
r"\s*(.+?)\s*$", txt):
|
| 182 |
+
out[m.group(1).title()] = {"staffed": m.group(2).strip()}
|
| 183 |
+
return out
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def parse_branch(soup, url) -> dict:
|
| 187 |
+
main = main_text(soup)
|
| 188 |
+
address = ""
|
| 189 |
+
addr = main.find(class_=re.compile("address|location|adr", re.I))
|
| 190 |
+
if addr:
|
| 191 |
+
address = re.sub(r"\s+", " ", addr.get_text(" ", strip=True))
|
| 192 |
+
hours = parse_hours(main)
|
| 193 |
+
blob = main.get_text(" ", strip=True)
|
| 194 |
+
facilities = [name for name, pat in FACILITY_VOCAB.items()
|
| 195 |
+
if re.search(pat, blob, re.I)]
|
| 196 |
+
return {
|
| 197 |
+
"name": title_of(soup, url),
|
| 198 |
+
"url": url,
|
| 199 |
+
"address": address,
|
| 200 |
+
"hours": hours,
|
| 201 |
+
"facilities": facilities,
|
| 202 |
+
"libraries_unlocked": bool(re.search(r"libraries unlocked", blob, re.I))
|
| 203 |
+
and any("unlocked" in v for v in hours.values()),
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def parse_hub(soup, url) -> dict:
|
| 208 |
+
main = main_text(soup)
|
| 209 |
+
return {
|
| 210 |
+
"name": title_of(soup, url),
|
| 211 |
+
"url": url,
|
| 212 |
+
"summary": intro(main),
|
| 213 |
+
"what_you_need": what_you_need(main),
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def parse_service(soup, url) -> dict:
|
| 218 |
+
main = main_text(soup)
|
| 219 |
+
title = title_of(soup, url)
|
| 220 |
+
if SEASONAL.search(url) or SEASONAL.search(title):
|
| 221 |
+
cat = "seasonal"
|
| 222 |
+
else:
|
| 223 |
+
cat = "membership" if "your-library-membership" in url else (
|
| 224 |
+
"learning" if "learn-upskill" in url or "learning-outside" in url else (
|
| 225 |
+
"business" if "business" in url or "bipc" in url else (
|
| 226 |
+
"reading" if "read-and-discover" in url else (
|
| 227 |
+
"wellbeing" if "wellbeing" in url or "warm" in url or "connect" in url
|
| 228 |
+
else "general"))))
|
| 229 |
+
return {
|
| 230 |
+
"title": title,
|
| 231 |
+
"url": url,
|
| 232 |
+
"category": cat,
|
| 233 |
+
"summary": intro(main),
|
| 234 |
+
"what_you_need": what_you_need(main),
|
| 235 |
+
"how_to": how_to(main),
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def build() -> dict:
|
| 240 |
+
urls = discover()
|
| 241 |
+
print(f"discovered {len(urls)} unique library URLs")
|
| 242 |
+
branches, hub, services = [], [], []
|
| 243 |
+
for i, u in enumerate(urls, 1):
|
| 244 |
+
if SKIP.search(u):
|
| 245 |
+
continue
|
| 246 |
+
try:
|
| 247 |
+
soup = BeautifulSoup(_get(u), "html.parser")
|
| 248 |
+
except Exception as e:
|
| 249 |
+
print(f" ! {u} -> {e}")
|
| 250 |
+
continue
|
| 251 |
+
if re.search(r"/find-library/[a-z]", u):
|
| 252 |
+
branches.append(parse_branch(soup, u))
|
| 253 |
+
elif re.search(r"/online-library-hub/[a-z]", u):
|
| 254 |
+
hub.append(parse_hub(soup, u))
|
| 255 |
+
else:
|
| 256 |
+
services.append(parse_service(soup, u))
|
| 257 |
+
if i % 15 == 0:
|
| 258 |
+
print(f" ...{i}/{len(urls)}")
|
| 259 |
+
time.sleep(DELAY)
|
| 260 |
+
|
| 261 |
+
return {
|
| 262 |
+
"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 263 |
+
"source": GOV,
|
| 264 |
+
"note": "Council pages only; the Hive website is deliberately excluded.",
|
| 265 |
+
"counts": {"services": len(services), "branches": len(branches),
|
| 266 |
+
"online_hub": len(hub)},
|
| 267 |
+
"membership_tiers": MEMBERSHIP_TIERS,
|
| 268 |
+
"branches": sorted(branches, key=lambda b: b["name"]),
|
| 269 |
+
"online_hub": sorted(hub, key=lambda h: h["name"]),
|
| 270 |
+
"services": sorted(services, key=lambda s: (s["category"], s["title"])),
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# Curated cross-cutting "what you need" matrix β the one view no single page
|
| 275 |
+
# gives, verified against the council pages crawled here.
|
| 276 |
+
MEMBERSHIP_TIERS = [
|
| 277 |
+
{"tier": "Digital membership",
|
| 278 |
+
"what_you_need": "Just a Worcestershire postcode β sign up online in minutes, no card needed.",
|
| 279 |
+
"unlocks": "Free eBooks, eAudiobooks, eMagazines & eNewspapers (BorrowBox, PressReader), Times Digital Archive, Oxford University Press.",
|
| 280 |
+
"url": f"{GOV}/council-services/libraries/online-library-hub/digital-library-membership"},
|
| 281 |
+
{"tier": "Full membership",
|
| 282 |
+
"what_you_need": "Free for everyone β join online then collect, or join in person at any library. Gives you a library card number + PIN.",
|
| 283 |
+
"unlocks": "Borrow physical books, reserve/renew, use public computers, Print Your Way, the mobile library, and the full online hub.",
|
| 284 |
+
"url": f"{GOV}/council-services/libraries/your-library-membership/join-library"},
|
| 285 |
+
{"tier": "Libraries Unlocked",
|
| 286 |
+
"what_you_need": "Be a full member aged 15+, then do a short one-off induction with staff.",
|
| 287 |
+
"unlocks": "Self-service access 8am-8pm Mon-Sat (even when unstaffed) at 11 branches: Bromsgrove, Droitwich, Evesham, Kidderminster, Malvern, Pershore, Redditch, Rubery, St John's, Stourport, Tenbury.",
|
| 288 |
+
"url": f"{GOV}/council-services/libraries/libraries-unlocked"},
|
| 289 |
+
]
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
if __name__ == "__main__":
|
| 293 |
+
kb = build()
|
| 294 |
+
with open("library_kb.json", "w", encoding="utf-8") as f:
|
| 295 |
+
json.dump(kb, f, indent=2, ensure_ascii=False)
|
| 296 |
+
print("\nwrote library_kb.json")
|
| 297 |
+
print("counts:", kb["counts"])
|
| 298 |
+
print("\nsample service:")
|
| 299 |
+
s = next((x for x in kb["services"] if x["what_you_need"]), kb["services"][0])
|
| 300 |
+
print(" ", s["title"], "->", s["what_you_need"][:1])
|
| 301 |
+
print("sample branch:")
|
| 302 |
+
b = kb["branches"][0]
|
| 303 |
+
print(" ", b["name"], "| facilities:", b["facilities"], "| days:", list(b["hours"])[:2])
|
graph_build.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
graph_build.py β turn library_kb.json into a knowledge graph (GraphRAG-style).
|
| 3 |
+
|
| 4 |
+
Pipeline (mirrors microsoft/graphrag's stages, but deterministic + local so it
|
| 5 |
+
runs on a laptop with no LLM calls and no API cost):
|
| 6 |
+
|
| 7 |
+
KB documents -> Entities -> Relationships -> Communities -> Reports
|
| 8 |
+
|
| 9 |
+
Why a graph and not flat RAG? It answers MULTI-HOP questions a chatbot can't,
|
| 10 |
+
e.g. "which late-opening library has free parking and a cafe?" β that traverses
|
| 11 |
+
Branch -HAS_FACILITY-> Facility and Branch -OFFERS-> Libraries Unlocked in one go.
|
| 12 |
+
|
| 13 |
+
Run after build_kb.py: python build_kb.py && python graph_build.py
|
| 14 |
+
Output: library_graph.json (nodes, typed edges, communities, reports)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import re
|
| 21 |
+
from datetime import datetime, timezone
|
| 22 |
+
|
| 23 |
+
import networkx as nx
|
| 24 |
+
from networkx.algorithms import community as nx_comm
|
| 25 |
+
|
| 26 |
+
try: # curated online-hub access detail
|
| 27 |
+
from library_sources import CURATED_HUB
|
| 28 |
+
except Exception:
|
| 29 |
+
CURATED_HUB = {}
|
| 30 |
+
|
| 31 |
+
KB_PATH = "library_kb.json"
|
| 32 |
+
OUT_PATH = "library_graph.json"
|
| 33 |
+
GOV = "https://www.worcestershire.gov.uk"
|
| 34 |
+
|
| 35 |
+
POSTCODE = re.compile(r"[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def town_of(address: str) -> str:
|
| 39 |
+
m = POSTCODE.search(address or "")
|
| 40 |
+
if not m:
|
| 41 |
+
return ""
|
| 42 |
+
before = (address[:m.start()]).strip().split()
|
| 43 |
+
return before[-1].title() if before else ""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def infer_tier(need) -> str | None:
|
| 47 |
+
t = " ".join(need).lower() if isinstance(need, list) else str(need).lower()
|
| 48 |
+
if "induction" in t or "unlocked" in t:
|
| 49 |
+
return "Libraries Unlocked"
|
| 50 |
+
if "papercut" in t or "print" in t:
|
| 51 |
+
return "Full membership"
|
| 52 |
+
if "digital" in t or "postcode" in t:
|
| 53 |
+
return "Digital membership"
|
| 54 |
+
if any(w in t for w in ("member", "card", "join", "pin")):
|
| 55 |
+
return "Full membership"
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def build_graph(kb: dict) -> nx.Graph:
|
| 60 |
+
G = nx.Graph()
|
| 61 |
+
|
| 62 |
+
def add(node_id, ntype, label, **attrs):
|
| 63 |
+
G.add_node(node_id, type=ntype, label=label, **attrs)
|
| 64 |
+
return node_id
|
| 65 |
+
|
| 66 |
+
# --- Membership tiers (the "what you need to sign up" spine) ---
|
| 67 |
+
for tier in kb.get("membership_tiers", []):
|
| 68 |
+
add(f"tier::{tier['tier']}", "Membership", tier["tier"],
|
| 69 |
+
what_you_need=tier.get("what_you_need", ""),
|
| 70 |
+
unlocks=tier.get("unlocks", ""), url=tier.get("url", ""))
|
| 71 |
+
|
| 72 |
+
hub_node = add("hub::online", "Hub", "Online library hub",
|
| 73 |
+
url=f"{GOV}/council-services/libraries/online-library-hub")
|
| 74 |
+
|
| 75 |
+
# --- Branches -> facilities, area, Libraries Unlocked ---
|
| 76 |
+
for b in kb.get("branches", []):
|
| 77 |
+
bid = add(f"branch::{b['name']}", "Branch", b["name"],
|
| 78 |
+
address=b.get("address", ""), hours=b.get("hours", {}),
|
| 79 |
+
facilities=b.get("facilities", []),
|
| 80 |
+
libraries_unlocked=b.get("libraries_unlocked", False),
|
| 81 |
+
url=b.get("url", ""))
|
| 82 |
+
for fac in b.get("facilities", []):
|
| 83 |
+
fid = add(f"facility::{fac}", "Facility", fac)
|
| 84 |
+
G.add_edge(bid, fid, rel="HAS_FACILITY")
|
| 85 |
+
town = town_of(b.get("address", ""))
|
| 86 |
+
if town:
|
| 87 |
+
aid = add(f"area::{town}", "Area", town)
|
| 88 |
+
G.add_edge(bid, aid, rel="LOCATED_IN")
|
| 89 |
+
if b.get("libraries_unlocked"):
|
| 90 |
+
G.add_edge(bid, "tier::Libraries Unlocked", rel="OFFERS")
|
| 91 |
+
|
| 92 |
+
# --- Services -> category topic, required membership tier ---
|
| 93 |
+
for s in kb.get("services", []):
|
| 94 |
+
sid = add(f"service::{s['title']}", "Service", s["title"],
|
| 95 |
+
category=s.get("category", "general"),
|
| 96 |
+
summary=s.get("summary", ""),
|
| 97 |
+
what_you_need=s.get("what_you_need", []),
|
| 98 |
+
how_to=s.get("how_to", []), url=s.get("url", ""))
|
| 99 |
+
tid = add(f"topic::{s.get('category','general')}", "Topic",
|
| 100 |
+
s.get("category", "general").title())
|
| 101 |
+
G.add_edge(sid, tid, rel="IN_CATEGORY")
|
| 102 |
+
tier = infer_tier(s.get("what_you_need", []))
|
| 103 |
+
if tier and f"tier::{tier}" in G:
|
| 104 |
+
G.add_edge(sid, f"tier::{tier}", rel="REQUIRES")
|
| 105 |
+
|
| 106 |
+
# --- Online-hub resources -> hub, required tier, curated access ---
|
| 107 |
+
for h in kb.get("online_hub", []):
|
| 108 |
+
cur = next((v for k, v in CURATED_HUB.items()
|
| 109 |
+
if k in h["name"].lower() or h["name"].lower() in k), {})
|
| 110 |
+
rid = add(f"resource::{h['name']}", "Resource", h["name"],
|
| 111 |
+
summary=cur.get("inside") or h.get("summary", ""),
|
| 112 |
+
what_you_need=cur.get("what_you_need", ""),
|
| 113 |
+
access=cur.get("access", []), at_home=cur.get("at_home"),
|
| 114 |
+
titles=cur.get("titles", []), url=h.get("url", ""))
|
| 115 |
+
G.add_edge(rid, hub_node, rel="PART_OF")
|
| 116 |
+
tier = "Digital membership" if cur.get("at_home", True) else "Full membership"
|
| 117 |
+
if f"tier::{tier}" in G:
|
| 118 |
+
G.add_edge(rid, f"tier::{tier}", rel="REQUIRES")
|
| 119 |
+
|
| 120 |
+
# --- Mobile library villages (best-effort live fetch) ---
|
| 121 |
+
mob = add("service::Mobile library", "Service", "Mobile library",
|
| 122 |
+
category="access",
|
| 123 |
+
url=f"{GOV}/council-services/libraries/your-library-membership/mobile-library")
|
| 124 |
+
try:
|
| 125 |
+
from library_sources import _village_index
|
| 126 |
+
for name, url in list(_village_index().items()):
|
| 127 |
+
vid = add(f"village::{name}", "Village", name.title(), url=url)
|
| 128 |
+
G.add_edge(vid, mob, rel="SERVED_BY")
|
| 129 |
+
except Exception as e:
|
| 130 |
+
print(f" (mobile villages skipped: {e})")
|
| 131 |
+
|
| 132 |
+
return G
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def detect_communities(G: nx.Graph) -> list[dict]:
|
| 136 |
+
coms = nx_comm.greedy_modularity_communities(G)
|
| 137 |
+
reports = []
|
| 138 |
+
for i, members in enumerate(sorted(coms, key=len, reverse=True)):
|
| 139 |
+
members = list(members)
|
| 140 |
+
by_type: dict[str, list[str]] = {}
|
| 141 |
+
for n in members:
|
| 142 |
+
by_type.setdefault(G.nodes[n]["type"], []).append(G.nodes[n]["label"])
|
| 143 |
+
# readable, deterministic "community report"
|
| 144 |
+
title_bits = []
|
| 145 |
+
for t in ("Branch", "Area", "Service", "Resource", "Facility", "Membership"):
|
| 146 |
+
if by_type.get(t):
|
| 147 |
+
title_bits.append(f"{len(by_type[t])} {t.lower()}{'s' if len(by_type[t])>1 else ''}")
|
| 148 |
+
title = f"Cluster {i+1}: " + ", ".join(title_bits[:3]) if title_bits else f"Cluster {i+1}"
|
| 149 |
+
lines = [f"{t}: {', '.join(sorted(set(v))[:12])}"
|
| 150 |
+
for t, v in sorted(by_type.items())]
|
| 151 |
+
reports.append({
|
| 152 |
+
"id": f"community::{i}",
|
| 153 |
+
"title": title,
|
| 154 |
+
"size": len(members),
|
| 155 |
+
"members": members,
|
| 156 |
+
"report": " | ".join(lines),
|
| 157 |
+
})
|
| 158 |
+
return reports
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def main():
|
| 162 |
+
with open(KB_PATH, encoding="utf-8") as f:
|
| 163 |
+
kb = json.load(f)
|
| 164 |
+
G = build_graph(kb)
|
| 165 |
+
communities = detect_communities(G)
|
| 166 |
+
|
| 167 |
+
out = {
|
| 168 |
+
"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 169 |
+
"built_from": KB_PATH,
|
| 170 |
+
"method": "deterministic GraphRAG-style graph (entities->relationships->"
|
| 171 |
+
"communities->reports); inspired by microsoft/graphrag",
|
| 172 |
+
"stats": {"nodes": G.number_of_nodes(), "edges": G.number_of_edges(),
|
| 173 |
+
"communities": len(communities),
|
| 174 |
+
"node_types": _count_types(G)},
|
| 175 |
+
"nodes": [{"id": n, **{k: v for k, v in d.items()}}
|
| 176 |
+
for n, d in G.nodes(data=True)],
|
| 177 |
+
"edges": [{"source": u, "target": v, "rel": d.get("rel", "RELATED")}
|
| 178 |
+
for u, v, d in G.edges(data=True)],
|
| 179 |
+
"communities": communities,
|
| 180 |
+
}
|
| 181 |
+
with open(OUT_PATH, "w", encoding="utf-8") as f:
|
| 182 |
+
json.dump(out, f, indent=2, ensure_ascii=False)
|
| 183 |
+
|
| 184 |
+
print(f"wrote {OUT_PATH}")
|
| 185 |
+
print("stats:", out["stats"])
|
| 186 |
+
|
| 187 |
+
# --- prove the multi-hop value (a query flat RAG can't answer) ---
|
| 188 |
+
print("\nMulti-hop demo: late-opening (Libraries Unlocked) libraries that "
|
| 189 |
+
"also have a cafΓ© AND meeting rooms:")
|
| 190 |
+
hits = 0
|
| 191 |
+
for n, d in G.nodes(data=True):
|
| 192 |
+
if d["type"] != "Branch" or not d.get("libraries_unlocked"):
|
| 193 |
+
continue
|
| 194 |
+
facs = set(d.get("facilities", []))
|
| 195 |
+
if any("caf" in f.lower() for f in facs) and "meeting rooms" in facs:
|
| 196 |
+
print(f" β {d['label']} β open to 8pm, cafΓ© + meeting rooms")
|
| 197 |
+
hits += 1
|
| 198 |
+
print(f" ({hits} match β traversed BranchβOFFERSβUnlocked + BranchβHAS_FACILITY)")
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _count_types(G):
|
| 202 |
+
c = {}
|
| 203 |
+
for _, d in G.nodes(data=True):
|
| 204 |
+
c[d["type"]] = c.get(d["type"], 0) + 1
|
| 205 |
+
return c
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
if __name__ == "__main__":
|
| 209 |
+
main()
|
graph_rag.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
graph_rag.py β query the knowledge graph built by graph_build.py.
|
| 3 |
+
|
| 4 |
+
This is the runtime half of our GraphRAG: it answers the MULTI-HOP questions
|
| 5 |
+
flat retrieval can't, by traversing typed edges
|
| 6 |
+
(Branch-HAS_FACILITY-Facility, Branch-OFFERS-Libraries Unlocked, Service-REQUIRES-tierβ¦).
|
| 7 |
+
|
| 8 |
+
local_search β entity-anchored: "which late library has a cafΓ© + meeting rooms?"
|
| 9 |
+
global_search β community-level: "what does my library offer overall?"
|
| 10 |
+
|
| 11 |
+
No LLM here β it returns structured context that app.py's small model phrases.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
|
| 20 |
+
GRAPH_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
| 21 |
+
"library_graph.json")
|
| 22 |
+
|
| 23 |
+
# query word -> facility label fragment to match in a branch's facilities list
|
| 24 |
+
FACILITY_TERMS = {
|
| 25 |
+
"parking": "parking", "car park": "parking", "cafΓ©": "caf", "cafe": "caf",
|
| 26 |
+
"coffee": "caf", "wifi": "wi-fi", "wi-fi": "wi-fi", "internet": "wi-fi",
|
| 27 |
+
"computer": "computer", "pc": "computer", "study": "study", "quiet": "study",
|
| 28 |
+
"toilet": "toilet", "loo": "toilet", "baby": "baby", "changing": "baby",
|
| 29 |
+
"wheelchair": "wheelchair", "accessible": "accessible", "disabled": "accessible",
|
| 30 |
+
"meeting room": "meeting", "meeting": "meeting", "print": "printing",
|
| 31 |
+
"photocopy": "printing", "self-service": "self",
|
| 32 |
+
}
|
| 33 |
+
LATE_TERMS = ["late", "unlocked", "8pm", "evening", "after work", "after hours",
|
| 34 |
+
"open late", "out of hours"]
|
| 35 |
+
|
| 36 |
+
_G = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def graph() -> dict:
|
| 40 |
+
global _G
|
| 41 |
+
if _G is None:
|
| 42 |
+
try:
|
| 43 |
+
with open(GRAPH_PATH, encoding="utf-8") as f:
|
| 44 |
+
raw = json.load(f)
|
| 45 |
+
except FileNotFoundError:
|
| 46 |
+
raw = {"nodes": [], "edges": [], "communities": []}
|
| 47 |
+
nodes = {n["id"]: n for n in raw.get("nodes", [])}
|
| 48 |
+
adj: dict[str, list] = {nid: [] for nid in nodes}
|
| 49 |
+
for e in raw.get("edges", []):
|
| 50 |
+
s, t, rel = e["source"], e["target"], e.get("rel", "RELATED")
|
| 51 |
+
adj.setdefault(s, []).append((t, rel))
|
| 52 |
+
adj.setdefault(t, []).append((s, rel))
|
| 53 |
+
by_type: dict[str, list] = {}
|
| 54 |
+
for n in nodes.values():
|
| 55 |
+
by_type.setdefault(n["type"], []).append(n)
|
| 56 |
+
_G = {"nodes": nodes, "adj": adj, "by_type": by_type,
|
| 57 |
+
"communities": raw.get("communities", []),
|
| 58 |
+
"generated": raw.get("generated", "")}
|
| 59 |
+
return _G
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _area_in_query(q: str) -> str:
|
| 63 |
+
areas = [n["label"] for n in graph()["by_type"].get("Area", [])]
|
| 64 |
+
for a in areas:
|
| 65 |
+
if a.lower() in q:
|
| 66 |
+
return a
|
| 67 |
+
return ""
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def local_search(query: str) -> dict:
|
| 71 |
+
"""Entity-anchored multi-hop search."""
|
| 72 |
+
g = graph()
|
| 73 |
+
q = (query or "").lower()
|
| 74 |
+
|
| 75 |
+
wanted = {frag for term, frag in FACILITY_TERMS.items() if term in q}
|
| 76 |
+
want_late = any(t in q for t in LATE_TERMS)
|
| 77 |
+
area = _area_in_query(q)
|
| 78 |
+
|
| 79 |
+
# --- branch filter (the headline multi-hop) ---
|
| 80 |
+
if wanted or want_late or (area and "librar" in q):
|
| 81 |
+
results = []
|
| 82 |
+
for b in g["by_type"].get("Branch", []):
|
| 83 |
+
if want_late and not b.get("libraries_unlocked"):
|
| 84 |
+
continue
|
| 85 |
+
if area and area.lower() not in (b.get("address", "")).lower():
|
| 86 |
+
continue
|
| 87 |
+
facs = b.get("facilities", [])
|
| 88 |
+
if all(any(w in f.lower() for f in facs) for w in wanted):
|
| 89 |
+
results.append(b)
|
| 90 |
+
return {
|
| 91 |
+
"kind": "branch_filter",
|
| 92 |
+
"wanted_facilities": sorted(wanted),
|
| 93 |
+
"late": want_late, "area": area,
|
| 94 |
+
"branches": [{"name": b["label"], "facilities": b.get("facilities", []),
|
| 95 |
+
"libraries_unlocked": b.get("libraries_unlocked", False),
|
| 96 |
+
"address": b.get("address", ""), "url": b.get("url", "")}
|
| 97 |
+
for b in results],
|
| 98 |
+
"count": len(results),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# --- entity neighbourhood lookup ---
|
| 102 |
+
terms = [w for w in re.findall(r"[a-z]{4,}", q)]
|
| 103 |
+
scored = []
|
| 104 |
+
for nid, n in g["nodes"].items():
|
| 105 |
+
if n["type"] in ("Village",):
|
| 106 |
+
continue
|
| 107 |
+
hay = (n.get("label", "") + " " + str(n.get("summary", ""))).lower()
|
| 108 |
+
score = sum(1 for t in terms if t in hay)
|
| 109 |
+
if n.get("label", "").lower() in q:
|
| 110 |
+
score += 3
|
| 111 |
+
if score:
|
| 112 |
+
scored.append((score, nid, n))
|
| 113 |
+
scored.sort(key=lambda x: -x[0])
|
| 114 |
+
ents = []
|
| 115 |
+
for _, nid, n in scored[:4]:
|
| 116 |
+
neigh = []
|
| 117 |
+
for t, rel in g["adj"].get(nid, [])[:8]:
|
| 118 |
+
tn = g["nodes"].get(t, {})
|
| 119 |
+
neigh.append({"rel": rel, "label": tn.get("label", t),
|
| 120 |
+
"type": tn.get("type", "")})
|
| 121 |
+
ents.append({"label": n["label"], "type": n["type"],
|
| 122 |
+
"summary": n.get("summary", ""),
|
| 123 |
+
"what_you_need": n.get("what_you_need", ""),
|
| 124 |
+
"url": n.get("url", ""), "related": neigh})
|
| 125 |
+
return {"kind": "entity", "entities": ents, "count": len(ents)}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def global_search(query: str) -> dict:
|
| 129 |
+
"""Community-level overview for 'big picture' questions."""
|
| 130 |
+
g = graph()
|
| 131 |
+
terms = set(re.findall(r"[a-z]{4,}", (query or "").lower()))
|
| 132 |
+
scored = []
|
| 133 |
+
for c in g["communities"]:
|
| 134 |
+
hay = (c.get("title", "") + " " + c.get("report", "")).lower()
|
| 135 |
+
scored.append((sum(1 for t in terms if t in hay), c))
|
| 136 |
+
scored.sort(key=lambda x: -x[0])
|
| 137 |
+
return {"kind": "global",
|
| 138 |
+
"communities": [{"title": c["title"], "report": c["report"][:600]}
|
| 139 |
+
for s, c in scored[:3]]}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def graph_search(query: str) -> dict:
|
| 143 |
+
"""Entry point used as an agent tool. Picks local vs global automatically."""
|
| 144 |
+
q = (query or "").lower()
|
| 145 |
+
if any(w in q for w in ("overall", "everything", "what do you offer",
|
| 146 |
+
"what can", "all the", "in general")):
|
| 147 |
+
res = global_search(query)
|
| 148 |
+
else:
|
| 149 |
+
res = local_search(query)
|
| 150 |
+
res["page_url"] = "https://www.worcestershire.gov.uk/council-services/libraries"
|
| 151 |
+
res["graph_generated"] = graph().get("generated", "")
|
| 152 |
+
return res
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
import json as _j
|
| 157 |
+
for q in ["a late-opening library with a cafΓ© and meeting rooms",
|
| 158 |
+
"which library has study space and free wifi",
|
| 159 |
+
"free wifi in Malvern",
|
| 160 |
+
"tell me about borrowbox",
|
| 161 |
+
"what does my library offer overall"]:
|
| 162 |
+
r = graph_search(q)
|
| 163 |
+
print(f"\nQ: {q}\n kind={r['kind']}", end="")
|
| 164 |
+
if r["kind"] == "branch_filter":
|
| 165 |
+
print(f" wanted={r['wanted_facilities']} late={r['late']} -> "
|
| 166 |
+
f"{[b['name'] for b in r['branches']]}")
|
| 167 |
+
elif r["kind"] == "entity":
|
| 168 |
+
print(" ->", [f"{e['label']}({e['type']})" for e in r["entities"]])
|
| 169 |
+
else:
|
| 170 |
+
print(" ->", [c["title"] for c in r["communities"]])
|
index.html
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en-GB">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Worcestershire Libraries β Live Assistant</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
| 9 |
+
<style>
|
| 10 |
+
:root{
|
| 11 |
+
--teal:#0a7d78; --teal-dark:#075a56; --cream:#f6f3ec; --ink:#1f2a2a;
|
| 12 |
+
--line:#e2ded3; --soft:#eef5f4;
|
| 13 |
+
}
|
| 14 |
+
*{box-sizing:border-box}
|
| 15 |
+
body{
|
| 16 |
+
margin:0; background:var(--cream); color:var(--ink);
|
| 17 |
+
font-family:'Inter',system-ui,sans-serif; line-height:1.5;
|
| 18 |
+
}
|
| 19 |
+
.wrap{max-width:760px; margin:0 auto; min-height:100vh; display:flex;
|
| 20 |
+
flex-direction:column;}
|
| 21 |
+
header{
|
| 22 |
+
background:linear-gradient(135deg,var(--teal),var(--teal-dark)); color:#fff;
|
| 23 |
+
padding:26px 28px 22px; border-radius:0 0 22px 22px;
|
| 24 |
+
box-shadow:0 8px 26px rgba(7,90,86,.28);
|
| 25 |
+
}
|
| 26 |
+
.live{display:inline-block; background:#fff; color:var(--teal-dark);
|
| 27 |
+
font-size:.66rem; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
|
| 28 |
+
padding:3px 10px; border-radius:999px; margin-bottom:10px;}
|
| 29 |
+
header h1{font-family:'Fraunces',Georgia,serif; font-weight:600; margin:0;
|
| 30 |
+
font-size:1.7rem; letter-spacing:-.01em;}
|
| 31 |
+
header p{margin:.45rem 0 0; opacity:.92; font-size:.92rem; max-width:60ch;}
|
| 32 |
+
main{flex:1; padding:18px 20px 0; overflow-y:auto;}
|
| 33 |
+
.msg{margin:14px 0; display:flex; gap:10px; animation:rise .25s ease;}
|
| 34 |
+
@keyframes rise{from{opacity:0; transform:translateY(6px)} to{opacity:1}}
|
| 35 |
+
.msg .av{flex:0 0 30px; height:30px; border-radius:50%; display:grid;
|
| 36 |
+
place-items:center; font-size:15px;}
|
| 37 |
+
.msg.user{flex-direction:row-reverse;}
|
| 38 |
+
.msg.user .av{background:var(--teal); color:#fff;}
|
| 39 |
+
.msg.bot .av{background:var(--soft); border:1px solid var(--line);}
|
| 40 |
+
.bubble{padding:12px 15px; border-radius:14px; max-width:84%; font-size:.95rem;}
|
| 41 |
+
.msg.user .bubble{background:var(--teal); color:#fff; border-bottom-right-radius:4px;}
|
| 42 |
+
.msg.bot .bubble{background:#fff; border:1px solid var(--line);
|
| 43 |
+
border-bottom-left-radius:4px;}
|
| 44 |
+
.bubble :is(h1,h2,h3){font-family:'Fraunces',serif; font-size:1.05rem; margin:.4em 0;}
|
| 45 |
+
.bubble a{color:var(--teal-dark); font-weight:600;}
|
| 46 |
+
.bubble ul{padding-left:1.1em; margin:.4em 0;}
|
| 47 |
+
.bubble code{background:var(--soft); padding:1px 5px; border-radius:5px; font-size:.85em;}
|
| 48 |
+
.bubble details{margin-top:10px; background:var(--soft); border-radius:10px;
|
| 49 |
+
padding:8px 12px; font-size:.85rem;}
|
| 50 |
+
.bubble summary{cursor:pointer; font-weight:600;}
|
| 51 |
+
.chips{display:flex; flex-wrap:wrap; gap:8px; padding:6px 20px 0;}
|
| 52 |
+
.chip{background:#fff; border:1px solid var(--line); border-radius:999px;
|
| 53 |
+
padding:7px 13px; font-size:.83rem; cursor:pointer; color:var(--teal-dark);
|
| 54 |
+
transition:.15s;}
|
| 55 |
+
.chip:hover{background:var(--teal); color:#fff; border-color:var(--teal);}
|
| 56 |
+
form{position:sticky; bottom:0; background:var(--cream); padding:14px 20px 20px;
|
| 57 |
+
display:flex; gap:10px;}
|
| 58 |
+
input{flex:1; padding:13px 16px; border:1px solid var(--line); border-radius:12px;
|
| 59 |
+
font-size:1rem; font-family:inherit; background:#fff;}
|
| 60 |
+
input:focus{outline:2px solid var(--teal); border-color:var(--teal);}
|
| 61 |
+
button{background:var(--teal); color:#fff; border:0; border-radius:12px;
|
| 62 |
+
padding:0 20px; font-weight:600; cursor:pointer; font-size:.95rem;}
|
| 63 |
+
button:hover{background:var(--teal-dark);}
|
| 64 |
+
button:disabled{opacity:.5; cursor:default;}
|
| 65 |
+
footer{text-align:center; font-size:.74rem; color:#8a8676; padding:0 20px 16px;}
|
| 66 |
+
.dot{display:inline-block; width:6px; height:6px; border-radius:50%;
|
| 67 |
+
background:var(--teal); margin:0 2px; animation:blink 1.2s infinite both;}
|
| 68 |
+
.dot:nth-child(2){animation-delay:.2s} .dot:nth-child(3){animation-delay:.4s}
|
| 69 |
+
@keyframes blink{0%,80%,100%{opacity:.2} 40%{opacity:1}}
|
| 70 |
+
</style>
|
| 71 |
+
</head>
|
| 72 |
+
<body>
|
| 73 |
+
<div class="wrap">
|
| 74 |
+
<header>
|
| 75 |
+
<span class="live">β live data</span>
|
| 76 |
+
<h1>Worcestershire Libraries</h1>
|
| 77 |
+
<p>Ask about books, the mobile library, events, printing and what's free
|
| 78 |
+
online β answered live from the council site & catalogue, with exactly
|
| 79 |
+
what you need to sign up.</p>
|
| 80 |
+
</header>
|
| 81 |
+
|
| 82 |
+
<main id="log">
|
| 83 |
+
<div class="msg bot"><div class="av">π</div><div class="bubble" id="welcome">
|
| 84 |
+
Hello! Try one of these, or ask your own question.
|
| 85 |
+
</div></div>
|
| 86 |
+
</main>
|
| 87 |
+
|
| 88 |
+
<div class="chips" id="chips">
|
| 89 |
+
<div class="chip">Is Malvern library open now?</div>
|
| 90 |
+
<div class="chip">A late-opening library with a cafΓ© and meeting rooms</div>
|
| 91 |
+
<div class="chip">Can I read newspapers for free?</div>
|
| 92 |
+
<div class="chip">When does the mobile library visit Abberley?</div>
|
| 93 |
+
<div class="chip">What do I need to sign up?</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<form id="form" autocomplete="off">
|
| 97 |
+
<input id="box" placeholder="Ask anything about your local libraryβ¦" autofocus />
|
| 98 |
+
<button id="send" type="submit">Ask</button>
|
| 99 |
+
</form>
|
| 100 |
+
<footer>Official sources only Β· we don't use the out-of-date Hive website Β·
|
| 101 |
+
runs on a β€32B model</footer>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<script type="module">
|
| 105 |
+
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
| 106 |
+
import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
|
| 107 |
+
|
| 108 |
+
const log = document.getElementById("log");
|
| 109 |
+
const form = document.getElementById("form");
|
| 110 |
+
const box = document.getElementById("box");
|
| 111 |
+
const send = document.getElementById("send");
|
| 112 |
+
|
| 113 |
+
let client = null;
|
| 114 |
+
async function getClient(){
|
| 115 |
+
if(!client) client = await Client.connect(window.location.origin);
|
| 116 |
+
return client;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function addMsg(role, html){
|
| 120 |
+
const wrap = document.createElement("div");
|
| 121 |
+
wrap.className = "msg " + role;
|
| 122 |
+
wrap.innerHTML = `<div class="av">${role==="user"?"π":"π"}</div>`+
|
| 123 |
+
`<div class="bubble"></div>`;
|
| 124 |
+
wrap.querySelector(".bubble").innerHTML = html;
|
| 125 |
+
log.appendChild(wrap);
|
| 126 |
+
log.scrollTop = log.scrollHeight;
|
| 127 |
+
return wrap.querySelector(".bubble");
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
async function ask(message){
|
| 131 |
+
addMsg("user", message.replace(/</g,"<"));
|
| 132 |
+
const bubble = addMsg("bot", '<span class="dot"></span><span class="dot"></span><span class="dot"></span>');
|
| 133 |
+
send.disabled = true;
|
| 134 |
+
try{
|
| 135 |
+
const c = await getClient();
|
| 136 |
+
const job = c.submit("/ask", [message]);
|
| 137 |
+
let latest = "";
|
| 138 |
+
for await (const ev of job){
|
| 139 |
+
if(ev.type === "data" && ev.data && ev.data[0] != null){
|
| 140 |
+
latest = ev.data[0];
|
| 141 |
+
bubble.innerHTML = marked.parse(latest);
|
| 142 |
+
log.scrollTop = log.scrollHeight;
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
if(!latest) bubble.textContent = "Sorry β no response. Please try again.";
|
| 146 |
+
}catch(err){
|
| 147 |
+
bubble.textContent = "Couldn't reach the assistant. Please try again in a moment.";
|
| 148 |
+
console.error(err);
|
| 149 |
+
}finally{
|
| 150 |
+
send.disabled = false;
|
| 151 |
+
box.focus();
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
form.addEventListener("submit", e=>{
|
| 156 |
+
e.preventDefault();
|
| 157 |
+
const v = box.value.trim();
|
| 158 |
+
if(!v) return;
|
| 159 |
+
box.value = "";
|
| 160 |
+
ask(v);
|
| 161 |
+
});
|
| 162 |
+
document.getElementById("chips").addEventListener("click", e=>{
|
| 163 |
+
if(e.target.classList.contains("chip")) ask(e.target.textContent.trim());
|
| 164 |
+
});
|
| 165 |
+
</script>
|
| 166 |
+
</body>
|
| 167 |
+
</html>
|
library_graph.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
library_kb.json
ADDED
|
@@ -0,0 +1,1879 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"generated": "2026-06-11T22:51:10+00:00",
|
| 3 |
+
"source": "https://www.worcestershire.gov.uk",
|
| 4 |
+
"note": "Council pages only; the Hive website is deliberately excluded.",
|
| 5 |
+
"counts": {
|
| 6 |
+
"services": 87,
|
| 7 |
+
"branches": 23,
|
| 8 |
+
"online_hub": 17
|
| 9 |
+
},
|
| 10 |
+
"membership_tiers": [
|
| 11 |
+
{
|
| 12 |
+
"tier": "Digital membership",
|
| 13 |
+
"what_you_need": "Just a Worcestershire postcode β sign up online in minutes, no card needed.",
|
| 14 |
+
"unlocks": "Free eBooks, eAudiobooks, eMagazines & eNewspapers (BorrowBox, PressReader), Times Digital Archive, Oxford University Press.",
|
| 15 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/digital-library-membership"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"tier": "Full membership",
|
| 19 |
+
"what_you_need": "Free for everyone β join online then collect, or join in person at any library. Gives you a library card number + PIN.",
|
| 20 |
+
"unlocks": "Borrow physical books, reserve/renew, use public computers, Print Your Way, the mobile library, and the full online hub.",
|
| 21 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/join-library"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"tier": "Libraries Unlocked",
|
| 25 |
+
"what_you_need": "Be a full member aged 15+, then do a short one-off induction with staff.",
|
| 26 |
+
"unlocks": "Self-service access 8am-8pm Mon-Sat (even when unstaffed) at 11 branches: Bromsgrove, Droitwich, Evesham, Kidderminster, Malvern, Pershore, Redditch, Rubery, St John's, Stourport, Tenbury.",
|
| 27 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/libraries-unlocked"
|
| 28 |
+
}
|
| 29 |
+
],
|
| 30 |
+
"branches": [
|
| 31 |
+
{
|
| 32 |
+
"name": "Alvechurch Library",
|
| 33 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/alvechurch-library",
|
| 34 |
+
"address": "Birmingham Road Alvechurch Birmingham B48 7TA United Kingdom",
|
| 35 |
+
"hours": {
|
| 36 |
+
"Monday": {
|
| 37 |
+
"staffed": "9:00am toΒ 1:00pm, 2:00pm toΒ 5:00pm"
|
| 38 |
+
},
|
| 39 |
+
"Tuesday": {
|
| 40 |
+
"staffed": "9:00am toΒ 1:00pm, 2:00pm toΒ 5:00pm"
|
| 41 |
+
},
|
| 42 |
+
"Wednesday": {
|
| 43 |
+
"staffed": "Closed"
|
| 44 |
+
},
|
| 45 |
+
"Thursday": {
|
| 46 |
+
"staffed": "2:00pm to 5:00pm"
|
| 47 |
+
},
|
| 48 |
+
"Friday": {
|
| 49 |
+
"staffed": "9:00am toΒ 1:00pm, 2:00pm toΒ 5:00pm"
|
| 50 |
+
},
|
| 51 |
+
"Saturday": {
|
| 52 |
+
"staffed": "10:00am toΒ 1:00pm, 2:00pm toΒ 4:00pm"
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
"facilities": [
|
| 56 |
+
"public toilets",
|
| 57 |
+
"accessible toilet",
|
| 58 |
+
"baby changing",
|
| 59 |
+
"wheelchair access",
|
| 60 |
+
"free Wi-Fi",
|
| 61 |
+
"meeting rooms",
|
| 62 |
+
"printing"
|
| 63 |
+
],
|
| 64 |
+
"libraries_unlocked": false
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"name": "Bewdley Library",
|
| 68 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/bewdley-library",
|
| 69 |
+
"address": "Dog Lane Bewdley DY12 2EF United Kingdom",
|
| 70 |
+
"hours": {
|
| 71 |
+
"Monday": {
|
| 72 |
+
"staffed": "9:30am to 5:00pm"
|
| 73 |
+
},
|
| 74 |
+
"Tuesday": {
|
| 75 |
+
"staffed": "Closed"
|
| 76 |
+
},
|
| 77 |
+
"Wednesday": {
|
| 78 |
+
"staffed": "9:30am to 5:00pm"
|
| 79 |
+
},
|
| 80 |
+
"Thursday": {
|
| 81 |
+
"staffed": "9:30am to 5:00pm"
|
| 82 |
+
},
|
| 83 |
+
"Friday": {
|
| 84 |
+
"staffed": "9:30am to 5:00pm"
|
| 85 |
+
},
|
| 86 |
+
"Saturday": {
|
| 87 |
+
"staffed": "9:30am to 1:00pm"
|
| 88 |
+
}
|
| 89 |
+
},
|
| 90 |
+
"facilities": [
|
| 91 |
+
"wheelchair access",
|
| 92 |
+
"free Wi-Fi",
|
| 93 |
+
"printing"
|
| 94 |
+
],
|
| 95 |
+
"libraries_unlocked": false
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"name": "Broadway Library",
|
| 99 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/broadway-library",
|
| 100 |
+
"address": "Leamington Road Broadway WR12 7DZ United Kingdom",
|
| 101 |
+
"hours": {
|
| 102 |
+
"Monday": {
|
| 103 |
+
"staffed": "09:30am to 4:30pm"
|
| 104 |
+
},
|
| 105 |
+
"Tuesday": {
|
| 106 |
+
"staffed": "Closed"
|
| 107 |
+
},
|
| 108 |
+
"Wednesday": {
|
| 109 |
+
"staffed": "09:30am to 4:30pm"
|
| 110 |
+
},
|
| 111 |
+
"Thursday": {
|
| 112 |
+
"staffed": "Closed"
|
| 113 |
+
},
|
| 114 |
+
"Friday": {
|
| 115 |
+
"staffed": "9:30am to 4:30pm"
|
| 116 |
+
},
|
| 117 |
+
"Saturday": {
|
| 118 |
+
"staffed": "9:30am to 1:00pm"
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
"facilities": [
|
| 122 |
+
"wheelchair access",
|
| 123 |
+
"free Wi-Fi",
|
| 124 |
+
"printing"
|
| 125 |
+
],
|
| 126 |
+
"libraries_unlocked": false
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"name": "Bromsgrove Library",
|
| 130 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/bromsgrove-library",
|
| 131 |
+
"address": "Parkside Market Street Bromsgrove B61 8DA United Kingdom",
|
| 132 |
+
"hours": {
|
| 133 |
+
"Monday": {
|
| 134 |
+
"staffed": "1:00pm to 5:00pm",
|
| 135 |
+
"unlocked": "8:00am to 8:00pm"
|
| 136 |
+
},
|
| 137 |
+
"Tuesday": {
|
| 138 |
+
"staffed": "9:00am to 5:00pm",
|
| 139 |
+
"unlocked": "8:00am to 8:00pm"
|
| 140 |
+
},
|
| 141 |
+
"Wednesday": {
|
| 142 |
+
"staffed": "9:00am to 1:00pm",
|
| 143 |
+
"unlocked": "8:00am to 8:00pm"
|
| 144 |
+
},
|
| 145 |
+
"Thursday": {
|
| 146 |
+
"staffed": "9:00am to 5:00pm",
|
| 147 |
+
"unlocked": "8:00am to 8:00pm"
|
| 148 |
+
},
|
| 149 |
+
"Friday": {
|
| 150 |
+
"staffed": "9:00am to 5:00pm",
|
| 151 |
+
"unlocked": "8:00am to 8:00pm"
|
| 152 |
+
},
|
| 153 |
+
"Saturday": {
|
| 154 |
+
"staffed": "10:00am to 2:00pm",
|
| 155 |
+
"unlocked": "8:00am to 8:00pm"
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
"facilities": [
|
| 159 |
+
"public toilets",
|
| 160 |
+
"baby changing",
|
| 161 |
+
"wheelchair access",
|
| 162 |
+
"free Wi-Fi",
|
| 163 |
+
"public computers",
|
| 164 |
+
"meeting rooms",
|
| 165 |
+
"printing"
|
| 166 |
+
],
|
| 167 |
+
"libraries_unlocked": true
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"name": "Catshill Community Library",
|
| 171 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/catshill-community-library",
|
| 172 |
+
"address": "The Community Room Catshill Middle School Meadow Road Catshill Bromsgrove B61 0JW United Kingdom",
|
| 173 |
+
"hours": {
|
| 174 |
+
"Monday": {
|
| 175 |
+
"staffed": "9:15am to 12:15pm"
|
| 176 |
+
},
|
| 177 |
+
"Tuesday": {
|
| 178 |
+
"staffed": "3:00pm to 7:00pm"
|
| 179 |
+
},
|
| 180 |
+
"Wednesday": {
|
| 181 |
+
"staffed": "Closed"
|
| 182 |
+
},
|
| 183 |
+
"Thursday": {
|
| 184 |
+
"staffed": "3:00pm to 5:30pm"
|
| 185 |
+
},
|
| 186 |
+
"Friday": {
|
| 187 |
+
"staffed": "9:30am to Midday"
|
| 188 |
+
},
|
| 189 |
+
"Saturday": {
|
| 190 |
+
"staffed": "9:30am to 12:30pm"
|
| 191 |
+
}
|
| 192 |
+
},
|
| 193 |
+
"facilities": [
|
| 194 |
+
"public toilets",
|
| 195 |
+
"accessible toilet",
|
| 196 |
+
"wheelchair access",
|
| 197 |
+
"free Wi-Fi",
|
| 198 |
+
"printing"
|
| 199 |
+
],
|
| 200 |
+
"libraries_unlocked": false
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
"name": "Droitwich Spa Library",
|
| 204 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/droitwich-spa-library",
|
| 205 |
+
"address": "Victoria Square Droitwich Spa WR9 8DQ United Kingdom",
|
| 206 |
+
"hours": {
|
| 207 |
+
"Monday": {
|
| 208 |
+
"staffed": "10:00am to 5:00pm",
|
| 209 |
+
"unlocked": "8:00am to 8:00pm"
|
| 210 |
+
},
|
| 211 |
+
"Tuesday": {
|
| 212 |
+
"staffed": "10:00am to 5:00pm",
|
| 213 |
+
"unlocked": "8:00am to 8:00pm"
|
| 214 |
+
},
|
| 215 |
+
"Wednesday": {
|
| 216 |
+
"staffed": "1:00pm to 5:00pm",
|
| 217 |
+
"unlocked": "8:00am to 8:00pm"
|
| 218 |
+
},
|
| 219 |
+
"Thursday": {
|
| 220 |
+
"staffed": "Not applicable",
|
| 221 |
+
"unlocked": "8:00am to 8:00pm"
|
| 222 |
+
},
|
| 223 |
+
"Friday": {
|
| 224 |
+
"staffed": "10:00am to 5:00pm",
|
| 225 |
+
"unlocked": "8:00am to 8:00pm"
|
| 226 |
+
},
|
| 227 |
+
"Saturday": {
|
| 228 |
+
"staffed": "10:00am to 2:00pm",
|
| 229 |
+
"unlocked": "8:00am to 8:00pm"
|
| 230 |
+
}
|
| 231 |
+
},
|
| 232 |
+
"facilities": [
|
| 233 |
+
"accessible toilet",
|
| 234 |
+
"baby changing",
|
| 235 |
+
"wheelchair access",
|
| 236 |
+
"free Wi-Fi",
|
| 237 |
+
"meeting rooms",
|
| 238 |
+
"printing"
|
| 239 |
+
],
|
| 240 |
+
"libraries_unlocked": true
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"name": "Evesham Library",
|
| 244 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/evesham-library",
|
| 245 |
+
"address": "Oat Street Evesham WR11 4PJ United Kingdom",
|
| 246 |
+
"hours": {
|
| 247 |
+
"Monday": {
|
| 248 |
+
"staffed": "9:30am to 5:00pm",
|
| 249 |
+
"unlocked": "8:00am to 8:00pm"
|
| 250 |
+
},
|
| 251 |
+
"Tuesday": {
|
| 252 |
+
"staffed": "9:30am to 5:00pm",
|
| 253 |
+
"unlocked": "8:00am to 8:00pm"
|
| 254 |
+
},
|
| 255 |
+
"Wednesday": {
|
| 256 |
+
"staffed": "Not applicable",
|
| 257 |
+
"unlocked": "8:00am to 8:00pm"
|
| 258 |
+
},
|
| 259 |
+
"Thursday": {
|
| 260 |
+
"staffed": "9:30am to 5:00pm",
|
| 261 |
+
"unlocked": "8:00am to 8:00pm"
|
| 262 |
+
},
|
| 263 |
+
"Friday": {
|
| 264 |
+
"staffed": "9:30am to 5:00pm",
|
| 265 |
+
"unlocked": "8:00am to 8:00pm"
|
| 266 |
+
},
|
| 267 |
+
"Saturday": {
|
| 268 |
+
"staffed": "10:00am to 2:30pm",
|
| 269 |
+
"unlocked": "8:00am to 8:00pm"
|
| 270 |
+
}
|
| 271 |
+
},
|
| 272 |
+
"facilities": [
|
| 273 |
+
"accessible toilet",
|
| 274 |
+
"baby changing",
|
| 275 |
+
"wheelchair access",
|
| 276 |
+
"free Wi-Fi",
|
| 277 |
+
"public computers",
|
| 278 |
+
"study space",
|
| 279 |
+
"meeting rooms",
|
| 280 |
+
"self-service",
|
| 281 |
+
"printing"
|
| 282 |
+
],
|
| 283 |
+
"libraries_unlocked": true
|
| 284 |
+
},
|
| 285 |
+
{
|
| 286 |
+
"name": "Hagley Library",
|
| 287 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/hagley-library",
|
| 288 |
+
"address": "Worcester Road Hagley Stourbridge DY9 0NW United Kingdom",
|
| 289 |
+
"hours": {
|
| 290 |
+
"Monday": {
|
| 291 |
+
"staffed": "9:00am to 1:00pm, 2:00pm to 5:00pm"
|
| 292 |
+
},
|
| 293 |
+
"Tuesday": {
|
| 294 |
+
"staffed": "Closed"
|
| 295 |
+
},
|
| 296 |
+
"Wednesday": {
|
| 297 |
+
"staffed": "9:00am to 1:00pm, 2:00pm to 5:00pm"
|
| 298 |
+
},
|
| 299 |
+
"Thursday": {
|
| 300 |
+
"staffed": "9:00am to 1:00pm, 2:00pm to 5:00pm"
|
| 301 |
+
},
|
| 302 |
+
"Friday": {
|
| 303 |
+
"staffed": "9:00am to 1:00pm, 2:00pm to 5:00pm"
|
| 304 |
+
},
|
| 305 |
+
"Saturday": {
|
| 306 |
+
"staffed": "9:00am to 1:00pm, 2:00pm to 4:30pm"
|
| 307 |
+
}
|
| 308 |
+
},
|
| 309 |
+
"facilities": [
|
| 310 |
+
"public toilets",
|
| 311 |
+
"accessible toilet",
|
| 312 |
+
"wheelchair access",
|
| 313 |
+
"free Wi-Fi",
|
| 314 |
+
"printing"
|
| 315 |
+
],
|
| 316 |
+
"libraries_unlocked": false
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
"name": "Kidderminster Library",
|
| 320 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/kidderminster-library",
|
| 321 |
+
"address": "Market Street Kidderminster DY10 1AB United Kingdom",
|
| 322 |
+
"hours": {
|
| 323 |
+
"Monday": {
|
| 324 |
+
"staffed": "9:00am to 5:00pm",
|
| 325 |
+
"unlocked": "8:00am to 8:00pm"
|
| 326 |
+
},
|
| 327 |
+
"Tuesday": {
|
| 328 |
+
"staffed": "1:00pm to 5:00pm",
|
| 329 |
+
"unlocked": "8:00am to 8:00pm"
|
| 330 |
+
},
|
| 331 |
+
"Wednesday": {
|
| 332 |
+
"staffed": "9:00am to 5:00pm",
|
| 333 |
+
"unlocked": "8:00am to 8:00pm"
|
| 334 |
+
},
|
| 335 |
+
"Thursday": {
|
| 336 |
+
"staffed": "9:00am to 5:00pm",
|
| 337 |
+
"unlocked": "8:00am to 8:00pm"
|
| 338 |
+
},
|
| 339 |
+
"Friday": {
|
| 340 |
+
"staffed": "9:00am to 1:00pm",
|
| 341 |
+
"unlocked": "8:00am to 8:00pm"
|
| 342 |
+
},
|
| 343 |
+
"Saturday": {
|
| 344 |
+
"staffed": "10:00am to 3:00pm",
|
| 345 |
+
"unlocked": "8:00am to 8:00pm"
|
| 346 |
+
}
|
| 347 |
+
},
|
| 348 |
+
"facilities": [
|
| 349 |
+
"public toilets",
|
| 350 |
+
"accessible toilet",
|
| 351 |
+
"baby changing",
|
| 352 |
+
"wheelchair access",
|
| 353 |
+
"free Wi-Fi",
|
| 354 |
+
"meeting rooms",
|
| 355 |
+
"printing"
|
| 356 |
+
],
|
| 357 |
+
"libraries_unlocked": true
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
"name": "Malvern Library",
|
| 361 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/malvern-library",
|
| 362 |
+
"address": "Graham Road Malvern WR14 2HU United Kingdom",
|
| 363 |
+
"hours": {
|
| 364 |
+
"Monday": {
|
| 365 |
+
"staffed": "9:00am to 5:00pm",
|
| 366 |
+
"unlocked": "8:00am to 8:00pm"
|
| 367 |
+
},
|
| 368 |
+
"Tuesday": {
|
| 369 |
+
"staffed": "9:00am to 5:00pm",
|
| 370 |
+
"unlocked": "8:00am to 8:00pm"
|
| 371 |
+
},
|
| 372 |
+
"Wednesday": {
|
| 373 |
+
"staffed": "1:00pm to 5:00pm",
|
| 374 |
+
"unlocked": "8:00am to 8:00pm"
|
| 375 |
+
},
|
| 376 |
+
"Thursday": {
|
| 377 |
+
"staffed": "9:00am to 1:00pm",
|
| 378 |
+
"unlocked": "8:00am to 8:00pm"
|
| 379 |
+
},
|
| 380 |
+
"Friday": {
|
| 381 |
+
"staffed": "9:00am to 5:00pm",
|
| 382 |
+
"unlocked": "8:00am to 8:00pm"
|
| 383 |
+
},
|
| 384 |
+
"Saturday": {
|
| 385 |
+
"staffed": "10:00am to 3:00pm",
|
| 386 |
+
"unlocked": "8:00am to 8:00pm"
|
| 387 |
+
}
|
| 388 |
+
},
|
| 389 |
+
"facilities": [
|
| 390 |
+
"public toilets",
|
| 391 |
+
"accessible toilet",
|
| 392 |
+
"baby changing",
|
| 393 |
+
"wheelchair access",
|
| 394 |
+
"free Wi-Fi",
|
| 395 |
+
"public computers",
|
| 396 |
+
"study space",
|
| 397 |
+
"meeting rooms",
|
| 398 |
+
"cafΓ©",
|
| 399 |
+
"printing"
|
| 400 |
+
],
|
| 401 |
+
"libraries_unlocked": true
|
| 402 |
+
},
|
| 403 |
+
{
|
| 404 |
+
"name": "Martley Library",
|
| 405 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/martley-library",
|
| 406 |
+
"address": "Martley Village hall Berrow Green Road Martley WR6 6PQ United Kingdom",
|
| 407 |
+
"hours": {
|
| 408 |
+
"Monday": {
|
| 409 |
+
"staffed": "2:30pm to 5:00pm"
|
| 410 |
+
},
|
| 411 |
+
"Thursday": {
|
| 412 |
+
"staffed": "10:00am to 12:00pm"
|
| 413 |
+
},
|
| 414 |
+
"Friday": {
|
| 415 |
+
"staffed": "2:30pm to 4:00pm"
|
| 416 |
+
},
|
| 417 |
+
"Saturday": {
|
| 418 |
+
"staffed": "10:00am to 11:00am"
|
| 419 |
+
}
|
| 420 |
+
},
|
| 421 |
+
"facilities": [
|
| 422 |
+
"public toilets"
|
| 423 |
+
],
|
| 424 |
+
"libraries_unlocked": false
|
| 425 |
+
},
|
| 426 |
+
{
|
| 427 |
+
"name": "Pershore Library",
|
| 428 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/pershore-library",
|
| 429 |
+
"address": "Church Street Pershore WR10 1DT United Kingdom",
|
| 430 |
+
"hours": {
|
| 431 |
+
"Monday": {
|
| 432 |
+
"staffed": "10:00am to 4:30pm",
|
| 433 |
+
"unlocked": "8:00am to 8:00pm"
|
| 434 |
+
},
|
| 435 |
+
"Tuesday": {
|
| 436 |
+
"staffed": "10:00am to 4:30pm",
|
| 437 |
+
"unlocked": "8:00am to 8:00pm"
|
| 438 |
+
},
|
| 439 |
+
"Wednesday": {
|
| 440 |
+
"staffed": "10:00am to 4:30pm",
|
| 441 |
+
"unlocked": "8:00am to 8:00pm"
|
| 442 |
+
},
|
| 443 |
+
"Thursday": {
|
| 444 |
+
"staffed": "Not applicable",
|
| 445 |
+
"unlocked": "8:00am to 8:00pm"
|
| 446 |
+
},
|
| 447 |
+
"Friday": {
|
| 448 |
+
"staffed": "10:00am to 4:30pm",
|
| 449 |
+
"unlocked": "8:00am to 8:00pm"
|
| 450 |
+
},
|
| 451 |
+
"Saturday": {
|
| 452 |
+
"staffed": "10:00am to 2:00pm",
|
| 453 |
+
"unlocked": "8:00am to 8:00pm"
|
| 454 |
+
}
|
| 455 |
+
},
|
| 456 |
+
"facilities": [
|
| 457 |
+
"wheelchair access",
|
| 458 |
+
"free Wi-Fi",
|
| 459 |
+
"printing"
|
| 460 |
+
],
|
| 461 |
+
"libraries_unlocked": true
|
| 462 |
+
},
|
| 463 |
+
{
|
| 464 |
+
"name": "Redditch Library",
|
| 465 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/redditch-library",
|
| 466 |
+
"address": "15 Market Place Redditch B98 8AR United Kingdom",
|
| 467 |
+
"hours": {
|
| 468 |
+
"Monday": {
|
| 469 |
+
"staffed": "9:00am to 5:00pm",
|
| 470 |
+
"unlocked": "8:00am to 8:00pm"
|
| 471 |
+
},
|
| 472 |
+
"Tuesday": {
|
| 473 |
+
"staffed": "9:00am to 5:00pm",
|
| 474 |
+
"unlocked": "8:00am to 8:00pm"
|
| 475 |
+
},
|
| 476 |
+
"Wednesday": {
|
| 477 |
+
"staffed": "1:00pm to 5:00pm",
|
| 478 |
+
"unlocked": "8:00am to 8:00pm"
|
| 479 |
+
},
|
| 480 |
+
"Thursday": {
|
| 481 |
+
"staffed": "9:00am to 5:00pm",
|
| 482 |
+
"unlocked": "8:00am to 8:00pm"
|
| 483 |
+
},
|
| 484 |
+
"Friday": {
|
| 485 |
+
"staffed": "9:00am to 1:00pm",
|
| 486 |
+
"unlocked": "8:00am to 8:00pm"
|
| 487 |
+
},
|
| 488 |
+
"Saturday": {
|
| 489 |
+
"staffed": "9:00am to 4:00pm",
|
| 490 |
+
"unlocked": "8:00am to 8:00pm"
|
| 491 |
+
}
|
| 492 |
+
},
|
| 493 |
+
"facilities": [
|
| 494 |
+
"public toilets",
|
| 495 |
+
"accessible toilet",
|
| 496 |
+
"baby changing",
|
| 497 |
+
"wheelchair access",
|
| 498 |
+
"free Wi-Fi",
|
| 499 |
+
"meeting rooms",
|
| 500 |
+
"printing"
|
| 501 |
+
],
|
| 502 |
+
"libraries_unlocked": true
|
| 503 |
+
},
|
| 504 |
+
{
|
| 505 |
+
"name": "Rubery Library",
|
| 506 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/rubery-library",
|
| 507 |
+
"address": "7 Library Way Rubery Birmingham B45 9JS United Kingdom",
|
| 508 |
+
"hours": {
|
| 509 |
+
"Monday": {
|
| 510 |
+
"staffed": "10:00am to 4:30pm",
|
| 511 |
+
"unlocked": "8:00am to 8:00pm"
|
| 512 |
+
},
|
| 513 |
+
"Tuesday": {
|
| 514 |
+
"staffed": "1:00pm to 4:30pm",
|
| 515 |
+
"unlocked": "8:00am to 8:00pm"
|
| 516 |
+
},
|
| 517 |
+
"Wednesday": {
|
| 518 |
+
"staffed": "Not applicable",
|
| 519 |
+
"unlocked": "8:00am to 8:00pm"
|
| 520 |
+
},
|
| 521 |
+
"Thursday": {
|
| 522 |
+
"staffed": "10:00am to 4:30pm",
|
| 523 |
+
"unlocked": "8:00am to 8:00pm"
|
| 524 |
+
},
|
| 525 |
+
"Friday": {
|
| 526 |
+
"staffed": "10:00am to 2:00pm",
|
| 527 |
+
"unlocked": "8:00am to 8:00pm"
|
| 528 |
+
},
|
| 529 |
+
"Saturday": {
|
| 530 |
+
"staffed": "10:00am to 2:00pm",
|
| 531 |
+
"unlocked": "8:00am to 8:00pm"
|
| 532 |
+
}
|
| 533 |
+
},
|
| 534 |
+
"facilities": [
|
| 535 |
+
"accessible toilet",
|
| 536 |
+
"baby changing",
|
| 537 |
+
"free Wi-Fi",
|
| 538 |
+
"meeting rooms",
|
| 539 |
+
"printing"
|
| 540 |
+
],
|
| 541 |
+
"libraries_unlocked": true
|
| 542 |
+
},
|
| 543 |
+
{
|
| 544 |
+
"name": "St. John's Library",
|
| 545 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/st-johns-library",
|
| 546 |
+
"address": "Glebe Close St. John's Worcester WR2 5AX United Kingdom",
|
| 547 |
+
"hours": {
|
| 548 |
+
"Monday": {
|
| 549 |
+
"staffed": "2:00pm to 5:00pm",
|
| 550 |
+
"unlocked": "8:00am to 8:00pm"
|
| 551 |
+
},
|
| 552 |
+
"Tuesday": {
|
| 553 |
+
"staffed": "10:00am to 5:00pm",
|
| 554 |
+
"unlocked": "8:00am to 8:00pm"
|
| 555 |
+
},
|
| 556 |
+
"Wednesday": {
|
| 557 |
+
"staffed": "10:00am to 1:00pm",
|
| 558 |
+
"unlocked": "8:00am to 8:00pm"
|
| 559 |
+
},
|
| 560 |
+
"Thursday": {
|
| 561 |
+
"staffed": "2:00 pm to 5:00pm",
|
| 562 |
+
"unlocked": "8:00am to 8:00pm"
|
| 563 |
+
},
|
| 564 |
+
"Friday": {
|
| 565 |
+
"staffed": "10:00am to 5:00pm",
|
| 566 |
+
"unlocked": "8:00am to 8:00pm"
|
| 567 |
+
},
|
| 568 |
+
"Saturday": {
|
| 569 |
+
"staffed": "10:00am to 1:00pm",
|
| 570 |
+
"unlocked": "8:00am to 8:00pm"
|
| 571 |
+
}
|
| 572 |
+
},
|
| 573 |
+
"facilities": [
|
| 574 |
+
"public toilets",
|
| 575 |
+
"accessible toilet",
|
| 576 |
+
"baby changing",
|
| 577 |
+
"wheelchair access",
|
| 578 |
+
"free Wi-Fi",
|
| 579 |
+
"study space",
|
| 580 |
+
"meeting rooms",
|
| 581 |
+
"printing"
|
| 582 |
+
],
|
| 583 |
+
"libraries_unlocked": true
|
| 584 |
+
},
|
| 585 |
+
{
|
| 586 |
+
"name": "Stourport-on-Severn Library",
|
| 587 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/stourport-severn-library",
|
| 588 |
+
"address": "Civic Centre New Street Stourport on Severn DY13 8UN United Kingdom",
|
| 589 |
+
"hours": {
|
| 590 |
+
"Monday": {
|
| 591 |
+
"staffed": "10:30am to 4:30pm",
|
| 592 |
+
"unlocked": "8:00am to 8:00pm"
|
| 593 |
+
},
|
| 594 |
+
"Tuesday": {
|
| 595 |
+
"staffed": "10:30am to 4:30pm",
|
| 596 |
+
"unlocked": "8:00am to 8:00pm"
|
| 597 |
+
},
|
| 598 |
+
"Wednesday": {
|
| 599 |
+
"staffed": "Not applicable",
|
| 600 |
+
"unlocked": "8:00am to 8:00pm"
|
| 601 |
+
},
|
| 602 |
+
"Thursday": {
|
| 603 |
+
"staffed": "10:30am to 4:30pm",
|
| 604 |
+
"unlocked": "8:00am to 8:00pm"
|
| 605 |
+
},
|
| 606 |
+
"Friday": {
|
| 607 |
+
"staffed": "1:00pm to 4:30pm",
|
| 608 |
+
"unlocked": "8:00am to 8:00pm"
|
| 609 |
+
},
|
| 610 |
+
"Saturday": {
|
| 611 |
+
"staffed": "10:30am to 1:30pm",
|
| 612 |
+
"unlocked": "8:00am to 8:00pm"
|
| 613 |
+
}
|
| 614 |
+
},
|
| 615 |
+
"facilities": [
|
| 616 |
+
"public toilets",
|
| 617 |
+
"accessible toilet",
|
| 618 |
+
"baby changing",
|
| 619 |
+
"wheelchair access",
|
| 620 |
+
"free Wi-Fi",
|
| 621 |
+
"printing"
|
| 622 |
+
],
|
| 623 |
+
"libraries_unlocked": true
|
| 624 |
+
},
|
| 625 |
+
{
|
| 626 |
+
"name": "Tenbury Wells Library",
|
| 627 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/tenbury-wells-library",
|
| 628 |
+
"address": "24 Teme Street Tenbury Wells WR15 8AA United Kingdom",
|
| 629 |
+
"hours": {
|
| 630 |
+
"Monday": {
|
| 631 |
+
"staffed": "1:00pm to 4:30pm",
|
| 632 |
+
"unlocked": "8:00am to 8:00pm"
|
| 633 |
+
},
|
| 634 |
+
"Tuesday": {
|
| 635 |
+
"staffed": "10:00am to 4:30pm",
|
| 636 |
+
"unlocked": "8:00am to 8:00pm"
|
| 637 |
+
},
|
| 638 |
+
"Wednesday": {
|
| 639 |
+
"staffed": "1:00pm to 4:30pm",
|
| 640 |
+
"unlocked": "8:00am to 8:00pm"
|
| 641 |
+
},
|
| 642 |
+
"Thursday": {
|
| 643 |
+
"staffed": "10:00am to 4:30pm",
|
| 644 |
+
"unlocked": "8:00am to 8:00pm"
|
| 645 |
+
},
|
| 646 |
+
"Friday": {
|
| 647 |
+
"staffed": "10:00am to 1:00pm",
|
| 648 |
+
"unlocked": "8:00am to 8:00pm"
|
| 649 |
+
},
|
| 650 |
+
"Saturday": {
|
| 651 |
+
"staffed": "10:00am to 1:00pm",
|
| 652 |
+
"unlocked": "8:00am to 8:00pm"
|
| 653 |
+
}
|
| 654 |
+
},
|
| 655 |
+
"facilities": [
|
| 656 |
+
"public toilets",
|
| 657 |
+
"accessible toilet",
|
| 658 |
+
"wheelchair access",
|
| 659 |
+
"free Wi-Fi",
|
| 660 |
+
"meeting rooms",
|
| 661 |
+
"printing"
|
| 662 |
+
],
|
| 663 |
+
"libraries_unlocked": true
|
| 664 |
+
},
|
| 665 |
+
{
|
| 666 |
+
"name": "The Hive",
|
| 667 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/hive",
|
| 668 |
+
"address": "Sawmill Cl The Butts Worcester WR1 3PD United Kingdom",
|
| 669 |
+
"hours": {
|
| 670 |
+
"Monday": {
|
| 671 |
+
"staffed": "8:30am to 10:00pm"
|
| 672 |
+
},
|
| 673 |
+
"Tuesday": {
|
| 674 |
+
"staffed": "8:30am to 10:00pm"
|
| 675 |
+
},
|
| 676 |
+
"Wednesday": {
|
| 677 |
+
"staffed": "8:30am to 10:00pm"
|
| 678 |
+
},
|
| 679 |
+
"Thursday": {
|
| 680 |
+
"staffed": "8:30am to 10:00pm"
|
| 681 |
+
},
|
| 682 |
+
"Friday": {
|
| 683 |
+
"staffed": "8:30am to 10:00pm"
|
| 684 |
+
},
|
| 685 |
+
"Saturday": {
|
| 686 |
+
"staffed": "8:30am to 10:00pm"
|
| 687 |
+
},
|
| 688 |
+
"Sunday": {
|
| 689 |
+
"staffed": "8:30am to 10:00pm"
|
| 690 |
+
}
|
| 691 |
+
},
|
| 692 |
+
"facilities": [],
|
| 693 |
+
"libraries_unlocked": false
|
| 694 |
+
},
|
| 695 |
+
{
|
| 696 |
+
"name": "Upton-Upon-Severn Library",
|
| 697 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/upton-upon-severn-library",
|
| 698 |
+
"address": "School Lane Upton-upon-Severn WR8 0LE United Kingdom",
|
| 699 |
+
"hours": {
|
| 700 |
+
"Monday": {
|
| 701 |
+
"staffed": "9:30am to 4:30pm"
|
| 702 |
+
},
|
| 703 |
+
"Tuesday": {
|
| 704 |
+
"staffed": "Closed"
|
| 705 |
+
},
|
| 706 |
+
"Wednesday": {
|
| 707 |
+
"staffed": "9:30am to 4:30pm"
|
| 708 |
+
},
|
| 709 |
+
"Thursday": {
|
| 710 |
+
"staffed": "Closed"
|
| 711 |
+
},
|
| 712 |
+
"Friday": {
|
| 713 |
+
"staffed": "9:30am to 4:30pm"
|
| 714 |
+
},
|
| 715 |
+
"Saturday": {
|
| 716 |
+
"staffed": "9:30am to 1:00pm"
|
| 717 |
+
}
|
| 718 |
+
},
|
| 719 |
+
"facilities": [
|
| 720 |
+
"public toilets",
|
| 721 |
+
"accessible toilet",
|
| 722 |
+
"baby changing",
|
| 723 |
+
"wheelchair access",
|
| 724 |
+
"free Wi-Fi",
|
| 725 |
+
"study space",
|
| 726 |
+
"meeting rooms",
|
| 727 |
+
"printing"
|
| 728 |
+
],
|
| 729 |
+
"libraries_unlocked": false
|
| 730 |
+
},
|
| 731 |
+
{
|
| 732 |
+
"name": "Warndon Library",
|
| 733 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/warndon-library",
|
| 734 |
+
"address": "The Fairfield Centre Carnforth Drive Worcester WR4 9HG United Kingdom",
|
| 735 |
+
"hours": {
|
| 736 |
+
"Monday": {
|
| 737 |
+
"staffed": "9:30am to 5:00pm"
|
| 738 |
+
},
|
| 739 |
+
"Tuesday": {
|
| 740 |
+
"staffed": "9:30am to 5:00pm"
|
| 741 |
+
},
|
| 742 |
+
"Wednesday": {
|
| 743 |
+
"staffed": "9:30am to 5:00pm"
|
| 744 |
+
},
|
| 745 |
+
"Thursday": {
|
| 746 |
+
"staffed": "9:30am to 5:00pm"
|
| 747 |
+
},
|
| 748 |
+
"Friday": {
|
| 749 |
+
"staffed": "9:30am to 5:00pm"
|
| 750 |
+
},
|
| 751 |
+
"Saturday": {
|
| 752 |
+
"staffed": "9:30am to 1:00pm"
|
| 753 |
+
}
|
| 754 |
+
},
|
| 755 |
+
"facilities": [
|
| 756 |
+
"public toilets",
|
| 757 |
+
"accessible toilet",
|
| 758 |
+
"baby changing",
|
| 759 |
+
"wheelchair access",
|
| 760 |
+
"free Wi-Fi",
|
| 761 |
+
"study space",
|
| 762 |
+
"printing"
|
| 763 |
+
],
|
| 764 |
+
"libraries_unlocked": false
|
| 765 |
+
},
|
| 766 |
+
{
|
| 767 |
+
"name": "Welland Library",
|
| 768 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/welland-library",
|
| 769 |
+
"address": "Welland Village Hall Marlbank Road Welland WR13 6LA United Kingdom",
|
| 770 |
+
"hours": {
|
| 771 |
+
"Monday": {
|
| 772 |
+
"staffed": "2:00pm to 4:00pm"
|
| 773 |
+
},
|
| 774 |
+
"Tuesday": {
|
| 775 |
+
"staffed": "8:30am to 9:45am"
|
| 776 |
+
},
|
| 777 |
+
"Wednesday": {
|
| 778 |
+
"staffed": "Closed"
|
| 779 |
+
},
|
| 780 |
+
"Thursday": {
|
| 781 |
+
"staffed": "10:00am to 12:00pm"
|
| 782 |
+
},
|
| 783 |
+
"Friday": {
|
| 784 |
+
"staffed": "Closed"
|
| 785 |
+
},
|
| 786 |
+
"Saturday": {
|
| 787 |
+
"staffed": "Closed"
|
| 788 |
+
}
|
| 789 |
+
},
|
| 790 |
+
"facilities": [
|
| 791 |
+
"public toilets",
|
| 792 |
+
"accessible toilet",
|
| 793 |
+
"wheelchair access",
|
| 794 |
+
"free Wi-Fi"
|
| 795 |
+
],
|
| 796 |
+
"libraries_unlocked": false
|
| 797 |
+
},
|
| 798 |
+
{
|
| 799 |
+
"name": "Woodrow Library",
|
| 800 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/woodrow-library",
|
| 801 |
+
"address": "25 Local Centre Studley Road Redditch B98 7RY United Kingdom",
|
| 802 |
+
"hours": {
|
| 803 |
+
"Monday": {
|
| 804 |
+
"staffed": "9:30am to 5:00pm"
|
| 805 |
+
},
|
| 806 |
+
"Tuesday": {
|
| 807 |
+
"staffed": "Closed"
|
| 808 |
+
},
|
| 809 |
+
"Wednesday": {
|
| 810 |
+
"staffed": "9:30am to 5:00pm"
|
| 811 |
+
},
|
| 812 |
+
"Thursday": {
|
| 813 |
+
"staffed": "9:30am to 5:00pm"
|
| 814 |
+
},
|
| 815 |
+
"Friday": {
|
| 816 |
+
"staffed": "12:30pm to 5:00pm"
|
| 817 |
+
},
|
| 818 |
+
"Saturday": {
|
| 819 |
+
"staffed": "9:00am to 1:00pm"
|
| 820 |
+
}
|
| 821 |
+
},
|
| 822 |
+
"facilities": [
|
| 823 |
+
"public toilets",
|
| 824 |
+
"accessible toilet",
|
| 825 |
+
"baby changing",
|
| 826 |
+
"wheelchair access",
|
| 827 |
+
"free Wi-Fi",
|
| 828 |
+
"meeting rooms",
|
| 829 |
+
"printing"
|
| 830 |
+
],
|
| 831 |
+
"libraries_unlocked": false
|
| 832 |
+
},
|
| 833 |
+
{
|
| 834 |
+
"name": "Wythall Library",
|
| 835 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/wythall-library",
|
| 836 |
+
"address": "Woodrush Community Hub Shawhurst Lane Hollywood Birmingham B47 5JW United Kingdom",
|
| 837 |
+
"hours": {
|
| 838 |
+
"Monday": {
|
| 839 |
+
"staffed": "9:30am to 1:00pm, 2:00pm to 5:00pm"
|
| 840 |
+
},
|
| 841 |
+
"Tuesday": {
|
| 842 |
+
"staffed": "9:30am to 1:00pm, 2:00pm to 5:00pm"
|
| 843 |
+
},
|
| 844 |
+
"Wednesday": {
|
| 845 |
+
"staffed": "9:30am to 1:00pm, 2:00pm to 5:00pm"
|
| 846 |
+
},
|
| 847 |
+
"Thursday": {
|
| 848 |
+
"staffed": "Closed"
|
| 849 |
+
},
|
| 850 |
+
"Friday": {
|
| 851 |
+
"staffed": "9:30am to 1:00pm, 2:00pm to 5:00pm"
|
| 852 |
+
},
|
| 853 |
+
"Saturday": {
|
| 854 |
+
"staffed": "9:30am to 1:00pm, 2:00pm to 4:00pm"
|
| 855 |
+
}
|
| 856 |
+
},
|
| 857 |
+
"facilities": [
|
| 858 |
+
"accessible toilet",
|
| 859 |
+
"baby changing",
|
| 860 |
+
"wheelchair access",
|
| 861 |
+
"free Wi-Fi",
|
| 862 |
+
"cafΓ©",
|
| 863 |
+
"printing"
|
| 864 |
+
],
|
| 865 |
+
"libraries_unlocked": false
|
| 866 |
+
}
|
| 867 |
+
],
|
| 868 |
+
"online_hub": [
|
| 869 |
+
{
|
| 870 |
+
"name": "Access to Research",
|
| 871 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/access-research",
|
| 872 |
+
"summary": "Free online access to academic journal articles on Worcestershire library computers.",
|
| 873 |
+
"what_you_need": [
|
| 874 |
+
"Suitable for students and independent researchers but it is available to all members of the public.",
|
| 875 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 876 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 877 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 878 |
+
]
|
| 879 |
+
},
|
| 880 |
+
{
|
| 881 |
+
"name": "Ancestry",
|
| 882 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/ancestry",
|
| 883 |
+
"summary": "The genealogy resource is a great place to research your family history and access billions of records from census data, directories, photographs and family trees.",
|
| 884 |
+
"what_you_need": [
|
| 885 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 886 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 887 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 888 |
+
]
|
| 889 |
+
},
|
| 890 |
+
{
|
| 891 |
+
"name": "Borrowbox",
|
| 892 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/borrowbox",
|
| 893 |
+
"summary": "Access to thousands of free eBooks and eAudiobooks, content updated regularly.",
|
| 894 |
+
"what_you_need": [
|
| 895 |
+
"BorrowBox, gives you access to eBooks and eAudiobooks - the latest titles, award winners, non-fiction, and the classics, all of which are available 24 hours a day with a Worcestershire Libraries membership.",
|
| 896 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 897 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 898 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 899 |
+
]
|
| 900 |
+
},
|
| 901 |
+
{
|
| 902 |
+
"name": "British Film Institution (BFI Replay)",
|
| 903 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/british-film-institution-bfi-replay",
|
| 904 |
+
"summary": "BFI Replay is a free-to-access digital archive from the BFI (British Film Institute), exclusively available in UK public lending libraries.",
|
| 905 |
+
"what_you_need": [
|
| 906 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 907 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 908 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 909 |
+
]
|
| 910 |
+
},
|
| 911 |
+
{
|
| 912 |
+
"name": "COBRA (Business Support)",
|
| 913 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/cobra-business-support",
|
| 914 |
+
"summary": "COBRA (Complete Business Reference Adviser) is a comprehensive online business resource for individuals and organizations in the UK.",
|
| 915 |
+
"what_you_need": [
|
| 916 |
+
"COBRA is accessible through Worcestershire Libraries and other libraries across the UK, and it can be accessed online with a library card.",
|
| 917 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 918 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 919 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 920 |
+
]
|
| 921 |
+
},
|
| 922 |
+
{
|
| 923 |
+
"name": "Digital library membership",
|
| 924 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/digital-library-membership",
|
| 925 |
+
"summary": "Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press. To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 926 |
+
"what_you_need": [
|
| 927 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 928 |
+
"If you're not yet a library member, instant access to our online library is available to all Worcestershire residents.",
|
| 929 |
+
"Apply Apply for a Digital Membership below: Apply for Digital Membership You can easily upgrade to full library membership by visiting a library and providing proof of your name and address.",
|
| 930 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 931 |
+
]
|
| 932 |
+
},
|
| 933 |
+
{
|
| 934 |
+
"name": "EBSCO",
|
| 935 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/ebsco",
|
| 936 |
+
"summary": "Reliable information for a variety of research.",
|
| 937 |
+
"what_you_need": [
|
| 938 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 939 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 940 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 941 |
+
]
|
| 942 |
+
},
|
| 943 |
+
{
|
| 944 |
+
"name": "ESPACENET",
|
| 945 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/espacenet",
|
| 946 |
+
"summary": "Espacenet is a free online patent search tool offered by the European Patent Office (EPO).",
|
| 947 |
+
"what_you_need": [
|
| 948 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 949 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 950 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 951 |
+
]
|
| 952 |
+
},
|
| 953 |
+
{
|
| 954 |
+
"name": "Online events",
|
| 955 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/online-events",
|
| 956 |
+
"summary": "Worcestershire libraries provide digital events that allow you to connect with others and expand your skills and interests.",
|
| 957 |
+
"what_you_need": []
|
| 958 |
+
},
|
| 959 |
+
{
|
| 960 |
+
"name": "Oxford Dictionary of National Biography",
|
| 961 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/oxford-dictionary-national-biography",
|
| 962 |
+
"summary": "Discover the lives that shaped history.",
|
| 963 |
+
"what_you_need": [
|
| 964 |
+
"It is helpful for students, researchers, and anyone who loves learning."
|
| 965 |
+
]
|
| 966 |
+
},
|
| 967 |
+
{
|
| 968 |
+
"name": "Oxford English Dictionary",
|
| 969 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/oxford-english-dictionary",
|
| 970 |
+
"summary": "The Oxford English Dictionary is the best guide to the meaning, history, and pronunciation of over 600,000 words, old and new.",
|
| 971 |
+
"what_you_need": [
|
| 972 |
+
"It is helpful for school, work, and anyone who loves learning."
|
| 973 |
+
]
|
| 974 |
+
},
|
| 975 |
+
{
|
| 976 |
+
"name": "Oxford Reference",
|
| 977 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/oxford-reference",
|
| 978 |
+
"summary": "A trusted online collection of dictionaries, facts, and reference entries across many subjects.",
|
| 979 |
+
"what_you_need": [
|
| 980 |
+
"Students can use it for schoolwork, writers can check meanings and explanations, and anyone can read reliable information without looking in lots of different places."
|
| 981 |
+
]
|
| 982 |
+
},
|
| 983 |
+
{
|
| 984 |
+
"name": "Oxford Research Encyclopaedias",
|
| 985 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/oxford-research-encyclopaedias",
|
| 986 |
+
"summary": "Easy-to-understand guides for study and research.",
|
| 987 |
+
"what_you_need": [
|
| 988 |
+
"The Oxford Research Encyclopaedias are free for Worcestershire library users."
|
| 989 |
+
]
|
| 990 |
+
},
|
| 991 |
+
{
|
| 992 |
+
"name": "Pressreader",
|
| 993 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/pressreader",
|
| 994 |
+
"summary": "View more than 7,000 of the top newspapers and magazines from around the globe.",
|
| 995 |
+
"what_you_need": [
|
| 996 |
+
"Download the PressReader app Access PressReader online Support Open the section below for support to access PressReader.",
|
| 997 |
+
"To maintain access to the PressReader you'll need to confirm your library membership and keep your account active.",
|
| 998 |
+
"If you let the 30-day period expire, a pop-up will prompt you to enter your library PIN, as in the PressReader app example below: If you miss this prompt, don't worry, simply log out by going to More, then Accounts.",
|
| 999 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 1000 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library."
|
| 1001 |
+
]
|
| 1002 |
+
},
|
| 1003 |
+
{
|
| 1004 |
+
"name": "The Times Digital Archive",
|
| 1005 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/times-digital-archive",
|
| 1006 |
+
"summary": "Search the archive to explore 200 years of history as it appeared in the pages of The Times, from 1785 to 1985.",
|
| 1007 |
+
"what_you_need": [
|
| 1008 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 1009 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 1010 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 1011 |
+
]
|
| 1012 |
+
},
|
| 1013 |
+
{
|
| 1014 |
+
"name": "Theory Test Pro",
|
| 1015 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/theory-test-pro",
|
| 1016 |
+
"summary": "If you are learning to drive, you can access Theory Test Pro for FREE with your library membership.",
|
| 1017 |
+
"what_you_need": [
|
| 1018 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 1019 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 1020 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 1021 |
+
]
|
| 1022 |
+
},
|
| 1023 |
+
{
|
| 1024 |
+
"name": "Which?",
|
| 1025 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub/which",
|
| 1026 |
+
"summary": "The UKβs most trusted source for independent product reviews, consumer advice, and money-saving tips.",
|
| 1027 |
+
"what_you_need": [
|
| 1028 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 1029 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 1030 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 1031 |
+
]
|
| 1032 |
+
}
|
| 1033 |
+
],
|
| 1034 |
+
"services": [
|
| 1035 |
+
{
|
| 1036 |
+
"title": "About BIPC Worcestershire",
|
| 1037 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/start-or-grow-business/about-bipc-worcestershire",
|
| 1038 |
+
"category": "business",
|
| 1039 |
+
"summary": "Providing free and accessible business advice, support, information and resources you can trust to help you on your business journey.",
|
| 1040 |
+
"what_you_need": [],
|
| 1041 |
+
"how_to": []
|
| 1042 |
+
},
|
| 1043 |
+
{
|
| 1044 |
+
"title": "Business database reference resources",
|
| 1045 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/start-or-grow-business/business-database-reference-resources",
|
| 1046 |
+
"category": "business",
|
| 1047 |
+
"summary": "The Business & IP Centre (BIPC) Worcestershire provides free access to a wide selection of subscription only databases and up to date business information.",
|
| 1048 |
+
"what_you_need": [
|
| 1049 |
+
"Resources available All are available to Worcestershire library members either from public computers in library buildings or remotely using your own digital devices.",
|
| 1050 |
+
"Available online to all Worcestershire library members Access Cobra and sign in using your library card number An encyclopaedia of practical information for starting, running and managing a small business."
|
| 1051 |
+
],
|
| 1052 |
+
"how_to": []
|
| 1053 |
+
},
|
| 1054 |
+
{
|
| 1055 |
+
"title": "Business facilities at Worcestershire libraries",
|
| 1056 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/start-or-grow-business/business-facilities-worcestershire-libraries",
|
| 1057 |
+
"category": "business",
|
| 1058 |
+
"summary": "The BIPC hubs provide accessible and welcoming spaces for business purposes; with access to PCs, desks, Wi-Fi and bookable meeting rooms and spaces for hire.",
|
| 1059 |
+
"what_you_need": [],
|
| 1060 |
+
"how_to": []
|
| 1061 |
+
},
|
| 1062 |
+
{
|
| 1063 |
+
"title": "Business networking",
|
| 1064 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/start-or-grow-business/business-networking",
|
| 1065 |
+
"category": "business",
|
| 1066 |
+
"summary": "Connect with other local businesses and start-ups in an informal environment.",
|
| 1067 |
+
"what_you_need": [],
|
| 1068 |
+
"how_to": []
|
| 1069 |
+
},
|
| 1070 |
+
{
|
| 1071 |
+
"title": "Explore free online library resources for learning, research and business",
|
| 1072 |
+
"url": "https://www.worcestershire.gov.uk/news/explore-free-online-library-resources-learning-research-and-business",
|
| 1073 |
+
"category": "business",
|
| 1074 |
+
"summary": "A Worcestershire library membership is the key to a world of FREE digital resources, from books and newspapers to trusted online reference tools.",
|
| 1075 |
+
"what_you_need": [],
|
| 1076 |
+
"how_to": []
|
| 1077 |
+
},
|
| 1078 |
+
{
|
| 1079 |
+
"title": "Get business books online",
|
| 1080 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/start-or-grow-business/get-business-books-online",
|
| 1081 |
+
"category": "business",
|
| 1082 |
+
"summary": "Reading and sharing business books is a great way for entrepreneurs to stay informed on business trends, learn new skills and develop innovative ways of working.",
|
| 1083 |
+
"what_you_need": [
|
| 1084 |
+
"These are available to all Worcestershire residents with a library card.",
|
| 1085 |
+
"If you are not already a library member a digital library membership is available to all Worcestershire residents and gives you free access to eBooks, eAudiobooks, eMagazines.",
|
| 1086 |
+
"To apply for digital membership and download the BorrowBox app visit Worcestershire Libraries Online Library ."
|
| 1087 |
+
],
|
| 1088 |
+
"how_to": []
|
| 1089 |
+
},
|
| 1090 |
+
{
|
| 1091 |
+
"title": "Protect your business",
|
| 1092 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/start-or-grow-business/protect-your-business",
|
| 1093 |
+
"category": "business",
|
| 1094 |
+
"summary": "Learn how you can protect your business and ideas.",
|
| 1095 |
+
"what_you_need": [
|
| 1096 |
+
"Anyone can have IP.",
|
| 1097 |
+
"They also do free IP webinars that anyone can attend."
|
| 1098 |
+
],
|
| 1099 |
+
"how_to": []
|
| 1100 |
+
},
|
| 1101 |
+
{
|
| 1102 |
+
"title": "Start or grow a business",
|
| 1103 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/start-or-grow-business",
|
| 1104 |
+
"category": "business",
|
| 1105 |
+
"summary": "Business and IP Centre Worcestershire services that support enterprise, entrepreneurship and business growth.",
|
| 1106 |
+
"what_you_need": [],
|
| 1107 |
+
"how_to": []
|
| 1108 |
+
},
|
| 1109 |
+
{
|
| 1110 |
+
"title": "2026 Libraries closing dates",
|
| 1111 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library/2026-libraries-closing-dates",
|
| 1112 |
+
"category": "general",
|
| 1113 |
+
"summary": "All libraries including Libraries Unlocked hours and Jobcentre Plus will be closed on the following dates.",
|
| 1114 |
+
"what_you_need": [],
|
| 1115 |
+
"how_to": []
|
| 1116 |
+
},
|
| 1117 |
+
{
|
| 1118 |
+
"title": "Acclaimed Authors Set to Visit Worcestershireβs Libraries This Autumn",
|
| 1119 |
+
"url": "https://www.worcestershire.gov.uk/news/acclaimed-authors-set-visit-worcestershires-libraries-autumn",
|
| 1120 |
+
"category": "general",
|
| 1121 |
+
"summary": "Libraries across Worcestershire are set to host an exciting season of FREE literary events this autumn. From spine-chilling crime fiction to groundbreaking history and immersive fantasy worlds, the programme offers something for every reader.",
|
| 1122 |
+
"what_you_need": [],
|
| 1123 |
+
"how_to": []
|
| 1124 |
+
},
|
| 1125 |
+
{
|
| 1126 |
+
"title": "Alvechurch Library - New layout for all library pages V1",
|
| 1127 |
+
"url": "https://www.worcestershire.gov.uk/library/alvechurch-library-new-layout-all-library-pages-v1",
|
| 1128 |
+
"category": "general",
|
| 1129 |
+
"summary": "Birmingham Road Alvechurch Birmingham B48 7TA United Kingdom",
|
| 1130 |
+
"what_you_need": [],
|
| 1131 |
+
"how_to": []
|
| 1132 |
+
},
|
| 1133 |
+
{
|
| 1134 |
+
"title": "Alvechurch Library - New layout for all library pages V2",
|
| 1135 |
+
"url": "https://www.worcestershire.gov.uk/library/alvechurch-library-new-layout-all-library-pages-v2",
|
| 1136 |
+
"category": "general",
|
| 1137 |
+
"summary": "Birmingham Road Alvechurch Birmingham B48 7TA United Kingdom",
|
| 1138 |
+
"what_you_need": [],
|
| 1139 |
+
"how_to": []
|
| 1140 |
+
},
|
| 1141 |
+
{
|
| 1142 |
+
"title": "Coming Soon: Free Dementia Events at Worcestershire's Libraries",
|
| 1143 |
+
"url": "https://www.worcestershire.gov.uk/news/coming-soon-free-dementia-events-worcestershires-libraries",
|
| 1144 |
+
"category": "general",
|
| 1145 |
+
"summary": "To mark Dementia Action Week, Worcestershire Libraries are delighted to offer a range of free, inclusive events for people living with dementia, their families, carers, professionals, and the wider community.",
|
| 1146 |
+
"what_you_need": [],
|
| 1147 |
+
"how_to": []
|
| 1148 |
+
},
|
| 1149 |
+
{
|
| 1150 |
+
"title": "Discover Volunteering Opportunities at your local library",
|
| 1151 |
+
"url": "https://www.worcestershire.gov.uk/news/discover-volunteering-opportunities-your-local-library",
|
| 1152 |
+
"category": "general",
|
| 1153 |
+
"summary": "Libraries across Worcestershire are on the lookout for volunteers, and the people who've already signed up say it's changed their lives!",
|
| 1154 |
+
"what_you_need": [],
|
| 1155 |
+
"how_to": []
|
| 1156 |
+
},
|
| 1157 |
+
{
|
| 1158 |
+
"title": "Discover the stories that shaped our town this May at Kidderminster Library!",
|
| 1159 |
+
"url": "https://www.worcestershire.gov.uk/news/discover-stories-shaped-our-town-may-kidderminster-library",
|
| 1160 |
+
"category": "general",
|
| 1161 |
+
"summary": "To mark Local & Community History Month, weβre hosting a fantastic programme of FREE events celebrating the rich heritage of Kidderminster and the surrounding areas.",
|
| 1162 |
+
"what_you_need": [],
|
| 1163 |
+
"how_to": []
|
| 1164 |
+
},
|
| 1165 |
+
{
|
| 1166 |
+
"title": "Early years information library",
|
| 1167 |
+
"url": "https://www.worcestershire.gov.uk/council-services/schools-education-and-learning/virtual-school/early-years-information-library",
|
| 1168 |
+
"category": "general",
|
| 1169 |
+
"summary": "Additional support and resources for pupils in the early years foundation stage, from the age of two.",
|
| 1170 |
+
"what_you_need": [],
|
| 1171 |
+
"how_to": []
|
| 1172 |
+
},
|
| 1173 |
+
{
|
| 1174 |
+
"title": "Exciting STEAM themed event coming to The Hive Library!",
|
| 1175 |
+
"url": "https://www.worcestershire.gov.uk/news/exciting-steam-themed-event-coming-hive-library",
|
| 1176 |
+
"category": "general",
|
| 1177 |
+
"summary": "STEAMfest, a thrilling event celebrating Science, Technology, Engineering, Arts, and Mathematics, is coming to The Hive Library on Saturday 25 January, from 10:00am to 3:00pm.",
|
| 1178 |
+
"what_you_need": [
|
| 1179 |
+
"\" STEAMfest is open to everyone will be a fantastic day out for families, students, and anyone curious about STEAM."
|
| 1180 |
+
],
|
| 1181 |
+
"how_to": []
|
| 1182 |
+
},
|
| 1183 |
+
{
|
| 1184 |
+
"title": "Find a library",
|
| 1185 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/find-library",
|
| 1186 |
+
"category": "general",
|
| 1187 |
+
"summary": "Find details of local library locations, opening times and facilities and of mobile library routes and timetables.",
|
| 1188 |
+
"what_you_need": [],
|
| 1189 |
+
"how_to": []
|
| 1190 |
+
},
|
| 1191 |
+
{
|
| 1192 |
+
"title": "Great news for Malvernβs library members as Libraries Unlocked launches next week",
|
| 1193 |
+
"url": "https://www.worcestershire.gov.uk/news/great-news-malverns-library-members-libraries-unlocked-launches-next-week",
|
| 1194 |
+
"category": "general",
|
| 1195 |
+
"summary": "Libraries Unlocked, the new service will go-live at Malvern Library on Monday 19 August 2024.",
|
| 1196 |
+
"what_you_need": [
|
| 1197 |
+
"Libraries Unlocked is a new service which provides more flexibility for customers and community groups to use their local library at times that are convenient to them, between 8am and 8pm Monday to Saturday.",
|
| 1198 |
+
"βAcross Worcestershire, libraries are at the heart of community life, and I am glad that we are developing new approaches to accommodate residents' busy lives.",
|
| 1199 |
+
"\" Libraries Unlocked membership is for ages 15 and over.",
|
| 1200 |
+
"Library members who wish to upgrade to Libraries Unlocked membership are required to complete a short induction.",
|
| 1201 |
+
"To arrange a Libraries Unlocked induction visit Malvern Library and speak to a member of library staff or call 01905 822722 to make an appointment."
|
| 1202 |
+
],
|
| 1203 |
+
"how_to": []
|
| 1204 |
+
},
|
| 1205 |
+
{
|
| 1206 |
+
"title": "Hire a library meeting room",
|
| 1207 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/hire-library-meeting-room",
|
| 1208 |
+
"category": "general",
|
| 1209 |
+
"summary": "Find details of library meeting rooms and other library spaces for hire.",
|
| 1210 |
+
"what_you_need": [
|
| 1211 |
+
"To do this you will need to apply directly to your local library in writing marking your envelope 'Art Application'."
|
| 1212 |
+
],
|
| 1213 |
+
"how_to": []
|
| 1214 |
+
},
|
| 1215 |
+
{
|
| 1216 |
+
"title": "Hop into Spring with Easter-themed activities at your local library!",
|
| 1217 |
+
"url": "https://www.worcestershire.gov.uk/news/hop-spring-easter-themed-activities-your-local-library",
|
| 1218 |
+
"category": "general",
|
| 1219 |
+
"summary": "Looking for some egg-citing family fun this Easter? Worcestershireβs Libraries has you covered with a variety of cracking FREE activities for both the young and young at heart.",
|
| 1220 |
+
"what_you_need": [],
|
| 1221 |
+
"how_to": []
|
| 1222 |
+
},
|
| 1223 |
+
{
|
| 1224 |
+
"title": "Improve Your Digital Skills at your Local Library",
|
| 1225 |
+
"url": "https://www.worcestershire.gov.uk/news/improve-your-digital-skills-your-local-library",
|
| 1226 |
+
"category": "general",
|
| 1227 |
+
"summary": "Do you know someone with little or no experience using a computer? Worcestershire County Councilβs Library service is here to help them build digital skills during Get Online Week.",
|
| 1228 |
+
"what_you_need": [
|
| 1229 |
+
"We are committed to ensuring our local communities have the skills and confidence to access digital information and services safely and open new opportunities for personal and professional growth.",
|
| 1230 |
+
"All our libraries provide free access to digital equipment, including desktop computers and printing facilities making them the perfect support centres for anyone needing digital assistance."
|
| 1231 |
+
],
|
| 1232 |
+
"how_to": []
|
| 1233 |
+
},
|
| 1234 |
+
{
|
| 1235 |
+
"title": "Libraries",
|
| 1236 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries",
|
| 1237 |
+
"category": "general",
|
| 1238 |
+
"summary": "Discover an extensive collection of books and resources and a wealth of services and activities that encourage reading, learning and aspiration, improve skills and confidence and promote wellbeing and independence.",
|
| 1239 |
+
"what_you_need": [
|
| 1240 |
+
"Your library membership Join the library, sign into your account, search the catalogue, renew and reserve books, pay fees and charges and book a computer.",
|
| 1241 |
+
"Libraries Unlocked A new library service offering longer opening hours and more flexibility for customers and community groups to use their local library at times that are convenient to them.",
|
| 1242 |
+
"Memories and Me Memory Bags are available to loan for carers and families of individuals living with dementia through the Memories and Me project.",
|
| 1243 |
+
"Library strategy A vision for the future of Worcestershire Libraries explaining how library services are evolving to meet the changing needs of residents."
|
| 1244 |
+
],
|
| 1245 |
+
"how_to": []
|
| 1246 |
+
},
|
| 1247 |
+
{
|
| 1248 |
+
"title": "Libraries Unlocked",
|
| 1249 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/libraries-unlocked",
|
| 1250 |
+
"category": "general",
|
| 1251 |
+
"summary": "A libraries unlocked membership allows customers to enter specific libraries outside of core staffed opening hours.",
|
| 1252 |
+
"what_you_need": [
|
| 1253 |
+
"New technology allows customers with Libraries Unlocked membership to enter the library using their library card outside core staffed opening hours and to use library services independently when staff are not present.",
|
| 1254 |
+
"Apply for a Libraries Unlocked membership Libraries Unlocked is live in Bromsgrove, Droitwich, Evesham, Kidderminster, Malvern, Pershore, Redditch, Rubery, St John's, Stourport and Tenbury Libraries."
|
| 1255 |
+
],
|
| 1256 |
+
"how_to": [
|
| 1257 |
+
"visit one of the Libraries Unlocked sites during their core staffed hours - please check the opening times using the links below on our website before planning your visit",
|
| 1258 |
+
"complete a brief induction with library staff - they'll guide you through the process and explain the operational procedures"
|
| 1259 |
+
]
|
| 1260 |
+
},
|
| 1261 |
+
{
|
| 1262 |
+
"title": "Libraries Unlocked Launches Next Week at Redditch Library!",
|
| 1263 |
+
"url": "https://www.worcestershire.gov.uk/news/libraries-unlocked-launches-next-week-redditch-library",
|
| 1264 |
+
"category": "general",
|
| 1265 |
+
"summary": "People in Redditch will soon have more freedom to use their local library at a time to suit them.",
|
| 1266 |
+
"what_you_need": [
|
| 1267 |
+
"To continue enjoying full access to library services, including use of the building during extended hours, customers will need to upgrade their membership by completing a short induction.",
|
| 1268 |
+
"βProviding greater access to libraries gives residents more freedom to use them in ways that suit their lives, and the initiative has already had a fantastic impact in other parts of the county.",
|
| 1269 |
+
"βTo arrange an induction, please visit Redditch Library and speak to a member of staff, or call 01905 822722 to make an appointment.",
|
| 1270 |
+
"Customers have described Libraries Unlocked as: βA brilliant idea β itβs enabled us as a family to access the library more,β and βThe best thing Worcestershire Libraries have done since the introduction of self-service."
|
| 1271 |
+
],
|
| 1272 |
+
"how_to": []
|
| 1273 |
+
},
|
| 1274 |
+
{
|
| 1275 |
+
"title": "Libraries Unlocked Launches Next Week at Tenbury Library!",
|
| 1276 |
+
"url": "https://www.worcestershire.gov.uk/news/libraries-unlocked-launches-next-week-tenbury-library",
|
| 1277 |
+
"category": "general",
|
| 1278 |
+
"summary": "People in Tenbury will soon have more freedom to use their local library at a time to suit them.",
|
| 1279 |
+
"what_you_need": [
|
| 1280 |
+
"To continue enjoying full access to library services, including use of the building during extended hours, customers will need to upgrade their membership by completing a short induction.",
|
| 1281 |
+
"βThis upgrade gives residents more freedom to use the library in a way that suits their lives, and itβs already had a fantastic impact in other parts of the county.",
|
| 1282 |
+
"β To arrange an induction, please visit Tenbury Library and speak to a member of staff, or call 01905 822722 to make an appointment.",
|
| 1283 |
+
"Customers have described Libraries Unlocked as: βA brilliant idea β itβs enabled us as a family to access the library more,β and βThe best thing Worcestershire Libraries have done since the introduction of self-service."
|
| 1284 |
+
],
|
| 1285 |
+
"how_to": []
|
| 1286 |
+
},
|
| 1287 |
+
{
|
| 1288 |
+
"title": "Libraries Unlocked is coming soon to Redditch Library!",
|
| 1289 |
+
"url": "https://www.worcestershire.gov.uk/news/libraries-unlocked-coming-soon-redditch-library",
|
| 1290 |
+
"category": "general",
|
| 1291 |
+
"summary": "Libraries Unlocked is a free membership upgrade that allows customers aged 15+ and local community groups to access the library outside staffed hours, from 8am to 8pm, six days a week.",
|
| 1292 |
+
"what_you_need": [
|
| 1293 |
+
"To continue enjoying full access to library services, including use of the building during extended hours, customers will need to upgrade their membership by completing a short induction.",
|
| 1294 |
+
"30pm These sessions are a chance to find out more about how Libraries Unlocked works and to complete a short face-to-face induction with library staff to upgrade your membership.",
|
| 1295 |
+
"βThis upgrade gives residents more freedom to use the library in a way that suits their lives, and itβs already had a fantastic impact in other parts of the county."
|
| 1296 |
+
],
|
| 1297 |
+
"how_to": []
|
| 1298 |
+
},
|
| 1299 |
+
{
|
| 1300 |
+
"title": "Libraries Unlocked is coming soon to Tenbury Library!",
|
| 1301 |
+
"url": "https://www.worcestershire.gov.uk/news/libraries-unlocked-coming-soon-tenbury-library",
|
| 1302 |
+
"category": "general",
|
| 1303 |
+
"summary": "Libraries Unlocked is a free membership upgrade that allows customers aged 15+ and local community groups to access the library outside staffed hours, from 8am to 8pm, six days a week.",
|
| 1304 |
+
"what_you_need": [
|
| 1305 |
+
"To continue enjoying full access to library services, including use of the building during extended hours, customers will need to upgrade their membership by completing a short induction.",
|
| 1306 |
+
"βThis upgrade gives residents more freedom to use the library in a way that suits their lives, and itβs already had a fantastic impact in other parts of the county."
|
| 1307 |
+
],
|
| 1308 |
+
"how_to": []
|
| 1309 |
+
},
|
| 1310 |
+
{
|
| 1311 |
+
"title": "Library events and activities",
|
| 1312 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/library-events-and-activities",
|
| 1313 |
+
"category": "general",
|
| 1314 |
+
"summary": "Find the latest information on events and activities taking place in your local library.",
|
| 1315 |
+
"what_you_need": [],
|
| 1316 |
+
"how_to": []
|
| 1317 |
+
},
|
| 1318 |
+
{
|
| 1319 |
+
"title": "Library volunteering provides a new chapter for retiree Paul",
|
| 1320 |
+
"url": "https://www.worcestershire.gov.uk/news/library-volunteering-provides-new-chapter-retiree-paul",
|
| 1321 |
+
"category": "general",
|
| 1322 |
+
"summary": "When Paul retired, he never imagined helping out at The Hive, Worcester would become a highlight of this new chapter.",
|
| 1323 |
+
"what_you_need": [
|
| 1324 |
+
"I encourage anyone considering volunteering to give it a try."
|
| 1325 |
+
],
|
| 1326 |
+
"how_to": []
|
| 1327 |
+
},
|
| 1328 |
+
{
|
| 1329 |
+
"title": "Memories and Me",
|
| 1330 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/memories-and-me",
|
| 1331 |
+
"category": "general",
|
| 1332 |
+
"summary": "Memory Bags are available to loan for carers and families of individuals living with dementia through the Memories and Me project.",
|
| 1333 |
+
"what_you_need": [],
|
| 1334 |
+
"how_to": []
|
| 1335 |
+
},
|
| 1336 |
+
{
|
| 1337 |
+
"title": "New library book collection to guide families through pregnancy and early parenthood",
|
| 1338 |
+
"url": "https://www.worcestershire.gov.uk/news/new-library-book-collection-guide-families-through-pregnancy-and-early-parenthood",
|
| 1339 |
+
"category": "general",
|
| 1340 |
+
"summary": "Worcestershireβs libraries are pleased to introduce a new collection by The Reading Agency: 'Reading Well For Families'.",
|
| 1341 |
+
"what_you_need": [],
|
| 1342 |
+
"how_to": []
|
| 1343 |
+
},
|
| 1344 |
+
{
|
| 1345 |
+
"title": "No Printer? No Problem. Libraries Launch βPrint Your Wayβ!",
|
| 1346 |
+
"url": "https://www.worcestershire.gov.uk/news/no-printer-no-problem-libraries-launch-print-your-way",
|
| 1347 |
+
"category": "general",
|
| 1348 |
+
"summary": "Need to print something but donβt have a printer?",
|
| 1349 |
+
"what_you_need": [
|
| 1350 |
+
"β Print Your Way is available to anyone with full library membership.",
|
| 1351 |
+
"If youβre not yet a library member or currently have digital-only membership, you can easily sign up or upgrade by visiting your local library and speaking to a member of staff."
|
| 1352 |
+
],
|
| 1353 |
+
"how_to": []
|
| 1354 |
+
},
|
| 1355 |
+
{
|
| 1356 |
+
"title": "Online library hub",
|
| 1357 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/online-library-hub",
|
| 1358 |
+
"category": "general",
|
| 1359 |
+
"summary": "Enjoy free eBooks, eAudiobooks, eNewspapers and eMagazines, along with access to Ancestry, Times Digital Archive, Oxford University Press, and more.",
|
| 1360 |
+
"what_you_need": [
|
| 1361 |
+
"Digital library membership Access free eBooks, eAudiobooks, eNewspapers and eMagazines, along with Times Digital Archive and Oxford University Press.",
|
| 1362 |
+
"To gain access to the rest of libraries content, please upgrade to a full membership in library.",
|
| 1363 |
+
"Theory Test Pro If you are learning to drive, you can access Theory Test Pro for FREE with your library membership.",
|
| 1364 |
+
"Related Your library membership Join the library, sign into your account, search the catalogue, renew and reserve books, pay fees and charges and book a computer.",
|
| 1365 |
+
"Join the library Library membership is free for everyone; join digitally now or visit your local library for full membership."
|
| 1366 |
+
],
|
| 1367 |
+
"how_to": []
|
| 1368 |
+
},
|
| 1369 |
+
{
|
| 1370 |
+
"title": "Rooms for hire at your local library β book online today",
|
| 1371 |
+
"url": "https://www.worcestershire.gov.uk/news/rooms-hire-your-local-library-book-online-today",
|
| 1372 |
+
"category": "general",
|
| 1373 |
+
"summary": "Looking for a convenient, affordable venue for your next meeting or event? Your local library has you covered!",
|
| 1374 |
+
"what_you_need": [
|
| 1375 |
+
"For more information and to sign up visit our Libraries Unlocked webpages ."
|
| 1376 |
+
],
|
| 1377 |
+
"how_to": []
|
| 1378 |
+
},
|
| 1379 |
+
{
|
| 1380 |
+
"title": "Spine-tingling events this autumn at your local library",
|
| 1381 |
+
"url": "https://www.worcestershire.gov.uk/news/spine-tingling-events-autumn-your-local-library",
|
| 1382 |
+
"category": "general",
|
| 1383 |
+
"summary": "As the nights grow longer and the shadows stretch deeper, join Worcestershireβs libraries for a series of spine-tingling events!",
|
| 1384 |
+
"what_you_need": [],
|
| 1385 |
+
"how_to": []
|
| 1386 |
+
},
|
| 1387 |
+
{
|
| 1388 |
+
"title": "Survey launched to help shape library services across the county",
|
| 1389 |
+
"url": "https://www.worcestershire.gov.uk/news/survey-launched-help-shape-library-services-across-county",
|
| 1390 |
+
"category": "general",
|
| 1391 |
+
"summary": "Libraries are not only places to borrow books. They also provide vital community spaces for learning, connecting with others and supporting wellbeing. But what else could they offer?",
|
| 1392 |
+
"what_you_need": [
|
| 1393 |
+
"Responses will help inform how library services are developed over the next two years, ensuring they continue to support residents, families and communities in the ways that matter most."
|
| 1394 |
+
],
|
| 1395 |
+
"how_to": []
|
| 1396 |
+
},
|
| 1397 |
+
{
|
| 1398 |
+
"title": "Worcestershire Libraries to Host Second βPower of Libraries Conferenceβ for Early Years Professionals",
|
| 1399 |
+
"url": "https://www.worcestershire.gov.uk/news/worcestershire-libraries-host-second-power-libraries-conference-early-years-professionals",
|
| 1400 |
+
"category": "general",
|
| 1401 |
+
"summary": "Worcestershire Libraries Power of Libraries Conference is back for a second year.",
|
| 1402 |
+
"what_you_need": [],
|
| 1403 |
+
"how_to": []
|
| 1404 |
+
},
|
| 1405 |
+
{
|
| 1406 |
+
"title": "Worcestershire libraries get prepared for emergencies this βSave a Life Septemberβ",
|
| 1407 |
+
"url": "https://www.worcestershire.gov.uk/news/worcestershire-libraries-get-prepared-emergencies-save-life-september",
|
| 1408 |
+
"category": "general",
|
| 1409 |
+
"summary": "Life-saving trauma kits have been installed in libraries across Worcestershire for use in emergencies.",
|
| 1410 |
+
"what_you_need": [
|
| 1411 |
+
"First aid training is useful, but you donβt have to be a First Aider to use them.",
|
| 1412 |
+
"Anyone can access the kit in an emergency situation."
|
| 1413 |
+
],
|
| 1414 |
+
"how_to": []
|
| 1415 |
+
},
|
| 1416 |
+
{
|
| 1417 |
+
"title": "Worcestershire library closures announced over Easter holidays",
|
| 1418 |
+
"url": "https://www.worcestershire.gov.uk/news/worcestershire-library-closures-announced-over-easter-holidays",
|
| 1419 |
+
"category": "general",
|
| 1420 |
+
"summary": "All libraries including Libraries Unlocked hours and Jobcentre Plus will not be open on Good Friday 18 April, Easter Sunday 20 April or Easter Monday 21 April.",
|
| 1421 |
+
"what_you_need": [],
|
| 1422 |
+
"how_to": []
|
| 1423 |
+
},
|
| 1424 |
+
{
|
| 1425 |
+
"title": "Worcestershireβs Free Online Library - Perfect for the Festive Season",
|
| 1426 |
+
"url": "https://www.worcestershire.gov.uk/news/worcestershires-free-online-library-perfect-festive-season",
|
| 1427 |
+
"category": "general",
|
| 1428 |
+
"summary": "This festive season, Worcestershire County Councilβs Library Service is helping residents cosy up with a world of free digital content.",
|
| 1429 |
+
"what_you_need": [
|
| 1430 |
+
"Apply for a Digital Library Membership on the dedicated pages of our website.",
|
| 1431 |
+
"You can easily upgrade to full library membership by visiting a library and providing proof of your name and address."
|
| 1432 |
+
],
|
| 1433 |
+
"how_to": []
|
| 1434 |
+
},
|
| 1435 |
+
{
|
| 1436 |
+
"title": "Worcestershireβs Libraries inspire local primary school teachers",
|
| 1437 |
+
"url": "https://www.worcestershire.gov.uk/news/worcestershires-libraries-inspire-local-primary-school-teachers",
|
| 1438 |
+
"category": "general",
|
| 1439 |
+
"summary": "Worcestershireβs libraries are sparking a love of reading in children, and that message took centre stage at the recent βPower of Librariesβ conference.",
|
| 1440 |
+
"what_you_need": [],
|
| 1441 |
+
"how_to": []
|
| 1442 |
+
},
|
| 1443 |
+
{
|
| 1444 |
+
"title": "Worcestershireβs Library Service at Home is changing lives",
|
| 1445 |
+
"url": "https://www.worcestershire.gov.uk/news/worcestershires-library-service-home-changing-lives",
|
| 1446 |
+
"category": "general",
|
| 1447 |
+
"summary": "For those facing challenges like health or mobility issues, Worcestershireβs Library Service at Home is transforming lives by bringing the library experience directly to your door.",
|
| 1448 |
+
"what_you_need": [],
|
| 1449 |
+
"how_to": []
|
| 1450 |
+
},
|
| 1451 |
+
{
|
| 1452 |
+
"title": "Adult Learning Courses in libraries",
|
| 1453 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learn-upskill-and-find-work/adult-learning-courses-libraries",
|
| 1454 |
+
"category": "learning",
|
| 1455 |
+
"summary": "Libraries host a range of Adult Learning courses across Worcestershire.",
|
| 1456 |
+
"what_you_need": [],
|
| 1457 |
+
"how_to": []
|
| 1458 |
+
},
|
| 1459 |
+
{
|
| 1460 |
+
"title": "Book a visit",
|
| 1461 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learning-outside-classroom/book-visit",
|
| 1462 |
+
"category": "learning",
|
| 1463 |
+
"summary": "Worcestershire Libraries offer a wide range of engaging visits for groups of children and young people in education or pre-school.",
|
| 1464 |
+
"what_you_need": [],
|
| 1465 |
+
"how_to": []
|
| 1466 |
+
},
|
| 1467 |
+
{
|
| 1468 |
+
"title": "Digital Inclusion - Helping You Online",
|
| 1469 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learn-upskill-and-find-work/digital-inclusion-helping-you-online",
|
| 1470 |
+
"category": "learning",
|
| 1471 |
+
"summary": "Ensuring local communities have the skills and confidence to access digital information and services and to feel safe online.",
|
| 1472 |
+
"what_you_need": [
|
| 1473 |
+
"Library staff are also on hand to help should you need assistance.",
|
| 1474 |
+
"Anyone seeking support to get started with Learn My Way can simply visit their local library and ask the staff for help with signing up."
|
| 1475 |
+
],
|
| 1476 |
+
"how_to": []
|
| 1477 |
+
},
|
| 1478 |
+
{
|
| 1479 |
+
"title": "Early Years and Reception",
|
| 1480 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learning-outside-classroom/early-years-and-reception",
|
| 1481 |
+
"category": "learning",
|
| 1482 |
+
"summary": "Libraries develop key literacy and communication skills to prepare children for the classroom.",
|
| 1483 |
+
"what_you_need": [],
|
| 1484 |
+
"how_to": []
|
| 1485 |
+
},
|
| 1486 |
+
{
|
| 1487 |
+
"title": "Job clubs",
|
| 1488 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learn-upskill-and-find-work/job-clubs",
|
| 1489 |
+
"category": "learning",
|
| 1490 |
+
"summary": "Job clubs provide practical weekly support for people currently looking for work. This is a free drop-in service, so no appointment is required.",
|
| 1491 |
+
"what_you_need": [
|
| 1492 |
+
"The clubs also provide help and support to use government and local sources of information such as universal job match, national job agencies and websites."
|
| 1493 |
+
],
|
| 1494 |
+
"how_to": []
|
| 1495 |
+
},
|
| 1496 |
+
{
|
| 1497 |
+
"title": "Key Stage 1 and 2 (years 1 to 6)",
|
| 1498 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learning-outside-classroom/key-stage-1-and-2-years-1-6",
|
| 1499 |
+
"category": "learning",
|
| 1500 |
+
"summary": "Libraries promote a love of reading and learning, inspire curiosity and ambition and encourage socialising, cognitive functioning and the evaluation of ideas.",
|
| 1501 |
+
"what_you_need": [
|
| 1502 |
+
"Little Sprouts A gardening and nature group for families with children aged 4-8 years."
|
| 1503 |
+
],
|
| 1504 |
+
"how_to": []
|
| 1505 |
+
},
|
| 1506 |
+
{
|
| 1507 |
+
"title": "Key Stage 3 and 4 (years 7 to 11)",
|
| 1508 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learning-outside-classroom/key-stage-3-and-4-years-7-11",
|
| 1509 |
+
"category": "learning",
|
| 1510 |
+
"summary": "Libraries nurture personal growth, enrich the learning journey, promote a healthy approach to study and boost workplace skills and confidence.",
|
| 1511 |
+
"what_you_need": [],
|
| 1512 |
+
"how_to": []
|
| 1513 |
+
},
|
| 1514 |
+
{
|
| 1515 |
+
"title": "Learn, upskill and find work",
|
| 1516 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learn-upskill-and-find-work",
|
| 1517 |
+
"category": "learning",
|
| 1518 |
+
"summary": "Promoting learning, getting people online, supporting digital inclusion, and helping people into work.",
|
| 1519 |
+
"what_you_need": [
|
| 1520 |
+
"Digital Inclusion - Helping You Online Ensuring local communities have the skills and confidence to access digital information and services and to feel safe online."
|
| 1521 |
+
],
|
| 1522 |
+
"how_to": []
|
| 1523 |
+
},
|
| 1524 |
+
{
|
| 1525 |
+
"title": "Learning Outside the Classroom",
|
| 1526 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learning-outside-classroom",
|
| 1527 |
+
"category": "learning",
|
| 1528 |
+
"summary": "Discover the vital role that libraries play supporting childrenβs and young peopleβs education from pre-school to final exams.",
|
| 1529 |
+
"what_you_need": [],
|
| 1530 |
+
"how_to": []
|
| 1531 |
+
},
|
| 1532 |
+
{
|
| 1533 |
+
"title": "Online job-seeking support",
|
| 1534 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learn-upskill-and-find-work/online-job-seeking-support",
|
| 1535 |
+
"category": "learning",
|
| 1536 |
+
"summary": "Digital resources to assist with finding work and improving skills for the workplace.",
|
| 1537 |
+
"what_you_need": [
|
| 1538 |
+
"Building Better Opportunities project - YMCA Worcestershire - Tailored 1-1 support for anyone 16+ who is unemployed and looking to find work.",
|
| 1539 |
+
"Register your interest in volunteering via BARN and access volunteering opportunities in the local area.",
|
| 1540 |
+
"The Prince's Trust - A youth charity that helps young people aged 11 to 30 get into jobs, education and training."
|
| 1541 |
+
],
|
| 1542 |
+
"how_to": []
|
| 1543 |
+
},
|
| 1544 |
+
{
|
| 1545 |
+
"title": "Sixth form and college library support",
|
| 1546 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learning-outside-classroom/sixth-form-and-college-library-support",
|
| 1547 |
+
"category": "learning",
|
| 1548 |
+
"summary": "Libraries prepare students for exams, further study and the workplace.",
|
| 1549 |
+
"what_you_need": [
|
| 1550 |
+
"If you would prefer your students to use a footnote system for referencing, we can introduce the Modern Humanities Research Association (MHRA) system instead of Harvard.",
|
| 1551 |
+
"Your students will need digital library membership or a Worcestershire library card to use COBRA and complete the activities for this session."
|
| 1552 |
+
],
|
| 1553 |
+
"how_to": []
|
| 1554 |
+
},
|
| 1555 |
+
{
|
| 1556 |
+
"title": "Study Happy",
|
| 1557 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learn-upskill-and-find-work/study-happy",
|
| 1558 |
+
"category": "learning",
|
| 1559 |
+
"summary": "βStudy Happyβ, a partnership between Worcestershire Libraries and The University of Worcester, is all about helping students to reach their academic goals while also prioritising their wellbeing.",
|
| 1560 |
+
"what_you_need": [
|
| 1561 |
+
"Study Happy resource list Resources are available to help improve your studying, help you to have a healthy mindset and suggest some interesting ways to relax after a busy studying session."
|
| 1562 |
+
],
|
| 1563 |
+
"how_to": []
|
| 1564 |
+
},
|
| 1565 |
+
{
|
| 1566 |
+
"title": "Volunteering, training and work experience",
|
| 1567 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/learn-upskill-and-find-work/volunteering-training-and-work-experience",
|
| 1568 |
+
"category": "learning",
|
| 1569 |
+
"summary": "Work experience, volunteering and training placements are available at libraries throughout Worcestershire.",
|
| 1570 |
+
"what_you_need": [],
|
| 1571 |
+
"how_to": []
|
| 1572 |
+
},
|
| 1573 |
+
{
|
| 1574 |
+
"title": "Book a computer",
|
| 1575 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/book-computer",
|
| 1576 |
+
"category": "membership",
|
| 1577 |
+
"summary": "Computer sessions are available on demand or can be pre-booked.",
|
| 1578 |
+
"what_you_need": [],
|
| 1579 |
+
"how_to": []
|
| 1580 |
+
},
|
| 1581 |
+
{
|
| 1582 |
+
"title": "Emails and updates",
|
| 1583 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/emails-and-updates",
|
| 1584 |
+
"category": "membership",
|
| 1585 |
+
"summary": "Find out more about email communications, marketing and how to change settings.",
|
| 1586 |
+
"what_you_need": [],
|
| 1587 |
+
"how_to": [
|
| 1588 |
+
"Receive notices only related to your loans, returns, reservations and important service updates.",
|
| 1589 |
+
"Opt to also receive marketing updates on new library services, events and activities.",
|
| 1590 |
+
"Remove all email permissions from your account - this option will stop all emails."
|
| 1591 |
+
]
|
| 1592 |
+
},
|
| 1593 |
+
{
|
| 1594 |
+
"title": "Join the library",
|
| 1595 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/join-library",
|
| 1596 |
+
"category": "membership",
|
| 1597 |
+
"summary": "Library membership is free for everyone; join digitally now or visit your local library for full membership.",
|
| 1598 |
+
"what_you_need": [
|
| 1599 |
+
"Become a library member The range of different library membership options are described below.",
|
| 1600 |
+
"Digital library membership Worcestershire library members have free access to a wide range of digital resources including eBooks, eAudiobooks, eMagazines and subscription only E-resources, completely free of charge.",
|
| 1601 |
+
"Instant membership gives you access to library computers and Wi-Fi for free and allows you to borrow 2 books.",
|
| 1602 |
+
"All library members who are 15 and over are eligible to upgrade to free Libraries Unlocked membership.",
|
| 1603 |
+
"Libraries Unlocked membership Libraries service behaviour guidelines and byelaws Worcestershire Libraries provides a safe and welcoming space."
|
| 1604 |
+
],
|
| 1605 |
+
"how_to": []
|
| 1606 |
+
},
|
| 1607 |
+
{
|
| 1608 |
+
"title": "Library Service at Home",
|
| 1609 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/library-service-home",
|
| 1610 |
+
"category": "membership",
|
| 1611 |
+
"summary": "A volunteer run service that helps to ensure that everyone can continue to enjoy reading from the comfort of their own home.",
|
| 1612 |
+
"what_you_need": [],
|
| 1613 |
+
"how_to": []
|
| 1614 |
+
},
|
| 1615 |
+
{
|
| 1616 |
+
"title": "Login to my library account",
|
| 1617 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/login-my-library-account",
|
| 1618 |
+
"category": "membership",
|
| 1619 |
+
"summary": "Login to your library account to renew your books, reserve books, cancel reservations and change your PIN.",
|
| 1620 |
+
"what_you_need": [
|
| 1621 |
+
"How to login To log into your account: you will need your PIN (personal identification number) in order to renew your books, log into your account or borrow eBooks.",
|
| 1622 |
+
"If you want to sign up for, or no longer want to receive marketing e-mails from Worcestershire Libraries you can subscribe or unsubscribe by changing the marketing preferences in your library account."
|
| 1623 |
+
],
|
| 1624 |
+
"how_to": []
|
| 1625 |
+
},
|
| 1626 |
+
{
|
| 1627 |
+
"title": "Mobile library",
|
| 1628 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/mobile-library",
|
| 1629 |
+
"category": "membership",
|
| 1630 |
+
"summary": "The mobile library visits 160 villages in Worcestershire every four to five weeks. It brings library services to customers who may be unable to travel to a static library.",
|
| 1631 |
+
"what_you_need": [],
|
| 1632 |
+
"how_to": []
|
| 1633 |
+
},
|
| 1634 |
+
{
|
| 1635 |
+
"title": "Pay fees and charges",
|
| 1636 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/pay-fees-and-charges",
|
| 1637 |
+
"category": "membership",
|
| 1638 |
+
"summary": "Borrowing library books is free, but if you keep the books past their due date, late fees will apply. A full list of fees and charges can be found here.",
|
| 1639 |
+
"what_you_need": [
|
| 1640 |
+
"Replacement library card charges If you have lost your library card, please visit a Worcestershire library and let a member of staff know and they can give you a replacement card."
|
| 1641 |
+
],
|
| 1642 |
+
"how_to": []
|
| 1643 |
+
},
|
| 1644 |
+
{
|
| 1645 |
+
"title": "Printing and photocopying services",
|
| 1646 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/printing-and-photocopying-services",
|
| 1647 |
+
"category": "membership",
|
| 1648 |
+
"summary": "Worcestershireβs libraries offer convenient, self-service printing and photocopying at all our locations.",
|
| 1649 |
+
"what_you_need": [
|
| 1650 |
+
"Libraries photocopying and printing service is a low-cost, accessible option for everyday printing or photocopying, whether you need a single page or a larger document.",
|
| 1651 |
+
"Print Your Way is available to anyone with full library membership.",
|
| 1652 |
+
"If youβre not yet a library member or you have a digital library membership only, it is easy to sign up for full library membership by visiting your local library and speaking to library staff.",
|
| 1653 |
+
"How to use the Print Your Way service 1.",
|
| 1654 |
+
"Make sure youβre a full library member If youβre not already a full library member, visit a library to sign up for full membership with library staff."
|
| 1655 |
+
],
|
| 1656 |
+
"how_to": []
|
| 1657 |
+
},
|
| 1658 |
+
{
|
| 1659 |
+
"title": "Renew a loan",
|
| 1660 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/renew-loan",
|
| 1661 |
+
"category": "membership",
|
| 1662 |
+
"summary": "Renew a loan online to extend your loan period.",
|
| 1663 |
+
"what_you_need": [],
|
| 1664 |
+
"how_to": []
|
| 1665 |
+
},
|
| 1666 |
+
{
|
| 1667 |
+
"title": "Reserve your library books",
|
| 1668 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership/reserve-your-library-books",
|
| 1669 |
+
"category": "membership",
|
| 1670 |
+
"summary": "Items can be reserved online via the library catalogue.",
|
| 1671 |
+
"what_you_need": [],
|
| 1672 |
+
"how_to": []
|
| 1673 |
+
},
|
| 1674 |
+
{
|
| 1675 |
+
"title": "Your library membership",
|
| 1676 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/your-library-membership",
|
| 1677 |
+
"category": "membership",
|
| 1678 |
+
"summary": "Join the library, sign into your account, search the catalogue, renew and reserve books, pay fees and charges and book a computer.",
|
| 1679 |
+
"what_you_need": [
|
| 1680 |
+
"Emergency Road Closures in Worcestershire Your library membership Your library membership Join the library, sign into your account, search the catalogue, renew and reserve books, pay fees and charges and book a computer.",
|
| 1681 |
+
"Join the library Library membership is free for everyone, join digitally now or visit your local library for full membership.",
|
| 1682 |
+
"Login to your account Login to your library account to renew or reserve books, update your account details or sign up to receive email updates on library events and activities.",
|
| 1683 |
+
"The strategy shapes how library services are evolving to meet the changing needs of residents over the next five years."
|
| 1684 |
+
],
|
| 1685 |
+
"how_to": []
|
| 1686 |
+
},
|
| 1687 |
+
{
|
| 1688 |
+
"title": "'Read & Unwind Time' at your local library",
|
| 1689 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/read-and-discover/read-unwind-time-your-local-library",
|
| 1690 |
+
"category": "reading",
|
| 1691 |
+
"summary": "A time to set aside for the pleasure of reading.",
|
| 1692 |
+
"what_you_need": [
|
| 1693 |
+
"Itβs easy to join in.",
|
| 1694 |
+
"At Libraries Unlocked locations you will need to have upgraded to Libraries Unlocked membership to enjoy Read and Unwind Time outside core staffing hours.",
|
| 1695 |
+
"If you havenβt already done so, please arrange a Libraries Unlocked induction and free membership upgrade with local library staff before attending your first Read & Unwind Time."
|
| 1696 |
+
],
|
| 1697 |
+
"how_to": []
|
| 1698 |
+
},
|
| 1699 |
+
{
|
| 1700 |
+
"title": "Ask for a Book",
|
| 1701 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/read-and-discover/ask-book",
|
| 1702 |
+
"category": "reading",
|
| 1703 |
+
"summary": "Ask for a Book is new online personalised book recommendation service offering reading suggestions which can be collected from the readerβs local library.",
|
| 1704 |
+
"what_you_need": [
|
| 1705 |
+
"Any adult can sign-up to join the service, and request a curated selection of up to three books to be chosen specifically for them.",
|
| 1706 |
+
"Register on the Ask for a Book website to discover what recommendations we have in store for you."
|
| 1707 |
+
],
|
| 1708 |
+
"how_to": []
|
| 1709 |
+
},
|
| 1710 |
+
{
|
| 1711 |
+
"title": "Read and discover",
|
| 1712 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/read-and-discover",
|
| 1713 |
+
"category": "reading",
|
| 1714 |
+
"summary": "Develop a love of reading, imagination and discovery and improving literacy through seasonal challenges, themed collections and competitions.",
|
| 1715 |
+
"what_you_need": [],
|
| 1716 |
+
"how_to": []
|
| 1717 |
+
},
|
| 1718 |
+
{
|
| 1719 |
+
"title": "Reading well",
|
| 1720 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/read-and-discover/reading-well",
|
| 1721 |
+
"category": "reading",
|
| 1722 |
+
"summary": "Reading Well is a dedicated collection of books that provides information and support for managing mental health and wellbeing for children, young people and adults.",
|
| 1723 |
+
"what_you_need": [
|
| 1724 |
+
"Reading Well books are available to borrow from all Worcestershire libraries."
|
| 1725 |
+
],
|
| 1726 |
+
"how_to": []
|
| 1727 |
+
},
|
| 1728 |
+
{
|
| 1729 |
+
"title": "Worcestershire Reading Groups Recommend",
|
| 1730 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/read-and-discover/worcestershire-reading-groups-recommend",
|
| 1731 |
+
"category": "reading",
|
| 1732 |
+
"summary": "Recommended Reads by Worcestershire book groups.",
|
| 1733 |
+
"what_you_need": [
|
| 1734 |
+
"All titles are widely available through libraries across Worcestershire, making it easy for groups to access and enjoy them together."
|
| 1735 |
+
],
|
| 1736 |
+
"how_to": []
|
| 1737 |
+
},
|
| 1738 |
+
{
|
| 1739 |
+
"title": "Aged 13β24? Volunteer with Worcestershireβs Libraries this summer",
|
| 1740 |
+
"url": "https://www.worcestershire.gov.uk/news/aged-13-24-volunteer-worcestershires-libraries-summer",
|
| 1741 |
+
"category": "seasonal",
|
| 1742 |
+
"summary": "Libraries in Worcestershire are on the lookout for enthusiastic young people aged 13 to 24 to volunteer during this yearβs Summer Reading Challenge, as part of celebrations for the National Year of Reading 2026",
|
| 1743 |
+
"what_you_need": [
|
| 1744 |
+
"To sign up online, please visit the Summer Reading Challenge webpage."
|
| 1745 |
+
],
|
| 1746 |
+
"how_to": []
|
| 1747 |
+
},
|
| 1748 |
+
{
|
| 1749 |
+
"title": "Celebrate World Book Day at your local library",
|
| 1750 |
+
"url": "https://www.worcestershire.gov.uk/news/celebrate-world-book-day-your-local-library",
|
| 1751 |
+
"category": "seasonal",
|
| 1752 |
+
"summary": "Libraries across Worcestershire are inviting families to celebrate the magic of reading this World Book Day, as part of the National Year of Reading.",
|
| 1753 |
+
"what_you_need": [
|
| 1754 |
+
"For children aged 4 to 9 years."
|
| 1755 |
+
],
|
| 1756 |
+
"how_to": []
|
| 1757 |
+
},
|
| 1758 |
+
{
|
| 1759 |
+
"title": "Free family fun this summer with Worcestershire Libraries & National Trust Croome",
|
| 1760 |
+
"url": "https://www.worcestershire.gov.uk/news/free-family-fun-summer-worcestershire-libraries-national-trust-croome",
|
| 1761 |
+
"category": "seasonal",
|
| 1762 |
+
"summary": "This summer, Worcestershireβs libraries and National Trust Croome are teaming up to offer families an unforgettable experience of reading, creativity, and outdoor play, with an exciting programme of Summer Reading Challenge events and the National Trustβs Summer of Play.",
|
| 1763 |
+
"what_you_need": [
|
| 1764 |
+
"All Summer Reading Challenge sessions and events are free to attend."
|
| 1765 |
+
],
|
| 1766 |
+
"how_to": []
|
| 1767 |
+
},
|
| 1768 |
+
{
|
| 1769 |
+
"title": "Get involved with the National Year of Reading 2026",
|
| 1770 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/read-and-discover/get-involved-national-year-reading-2026",
|
| 1771 |
+
"category": "seasonal",
|
| 1772 |
+
"summary": "Through stories, events and welcoming spaces, Worcestershire libraries help people of all ages discover, enjoy and share a love of books.",
|
| 1773 |
+
"what_you_need": [],
|
| 1774 |
+
"how_to": []
|
| 1775 |
+
},
|
| 1776 |
+
{
|
| 1777 |
+
"title": "Get school ready with Worcestershire's libraries",
|
| 1778 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/read-and-discover/get-school-ready-worcestershires-libraries",
|
| 1779 |
+
"category": "seasonal",
|
| 1780 |
+
"summary": "Is your little one starting school in September? Are you wondering if they are ready? Worcestershire's libraries is here to support the journey!",
|
| 1781 |
+
"what_you_need": [],
|
| 1782 |
+
"how_to": []
|
| 1783 |
+
},
|
| 1784 |
+
{
|
| 1785 |
+
"title": "Improve Your Digital Skills at Worcestershire Libraries This Summer!",
|
| 1786 |
+
"url": "https://www.worcestershire.gov.uk/news/improve-your-digital-skills-worcestershire-libraries-summer",
|
| 1787 |
+
"category": "seasonal",
|
| 1788 |
+
"summary": "Worcestershire County Councilβs Library service is excited to announce new Learn My Way introduction sessions and support for people of all ages who are looking to improve their digital skills.",
|
| 1789 |
+
"what_you_need": [
|
| 1790 |
+
"We are committed to ensuring our local communities have the skills and confidence to access digital information and services safely.",
|
| 1791 |
+
"Anyone seeking support to get started with Learn My Way can simply visit their local library and ask the staff for help with signing up."
|
| 1792 |
+
],
|
| 1793 |
+
"how_to": []
|
| 1794 |
+
},
|
| 1795 |
+
{
|
| 1796 |
+
"title": "Libraries βGo All Inβ for the National Year of Reading 2026",
|
| 1797 |
+
"url": "https://www.worcestershire.gov.uk/news/libraries-go-all-national-year-reading-2026",
|
| 1798 |
+
"category": "seasonal",
|
| 1799 |
+
"summary": "Libraries bring people together. Through stories, events and welcoming spaces, local libraries support people of all ages to discover, enjoy and share a love of reading.",
|
| 1800 |
+
"what_you_need": [
|
| 1801 |
+
"In addition, libraries are one of the few places you can visit without any cost, as membership is FREE for everyone who lives in Worcestershire."
|
| 1802 |
+
],
|
| 1803 |
+
"how_to": []
|
| 1804 |
+
},
|
| 1805 |
+
{
|
| 1806 |
+
"title": "Library Christmas & New Year Opening Times",
|
| 1807 |
+
"url": "https://www.worcestershire.gov.uk/news/library-christmas-new-year-opening-times",
|
| 1808 |
+
"category": "seasonal",
|
| 1809 |
+
"summary": "Opening Times for Worcestershire Libraries across the festive period.",
|
| 1810 |
+
"what_you_need": [],
|
| 1811 |
+
"how_to": []
|
| 1812 |
+
},
|
| 1813 |
+
{
|
| 1814 |
+
"title": "Library Christmas and New Year opening times",
|
| 1815 |
+
"url": "https://www.worcestershire.gov.uk/news/library-christmas-and-new-year-opening-times",
|
| 1816 |
+
"category": "seasonal",
|
| 1817 |
+
"summary": "Christmas and New Year Opening Times have been shared by Worcestershire Libraries.",
|
| 1818 |
+
"what_you_need": [],
|
| 1819 |
+
"how_to": []
|
| 1820 |
+
},
|
| 1821 |
+
{
|
| 1822 |
+
"title": "Summer Reading Challenge",
|
| 1823 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/read-and-discover/summer-reading-challenge",
|
| 1824 |
+
"category": "seasonal",
|
| 1825 |
+
"summary": "The Summer Reading Challenge is the UKβs biggest free reading for pleasure programme for children.",
|
| 1826 |
+
"what_you_need": [],
|
| 1827 |
+
"how_to": []
|
| 1828 |
+
},
|
| 1829 |
+
{
|
| 1830 |
+
"title": "Young Poet Laureate",
|
| 1831 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/read-and-discover/young-poet-laureate",
|
| 1832 |
+
"category": "seasonal",
|
| 1833 |
+
"summary": "Worcestershire Young Poet Laureate is run jointly by Severn Arts and Worcestershire Libraries, celebrating the talented voices of young local poets.",
|
| 1834 |
+
"what_you_need": [],
|
| 1835 |
+
"how_to": []
|
| 1836 |
+
},
|
| 1837 |
+
{
|
| 1838 |
+
"title": "Connect with others and boost your wellbeing",
|
| 1839 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/connect-others-and-boost-your-wellbeing",
|
| 1840 |
+
"category": "wellbeing",
|
| 1841 |
+
"summary": "Improve health and wellbeing through opportunities to connect, inform and build confidence and resources that support self-help.",
|
| 1842 |
+
"what_you_need": [
|
| 1843 |
+
"Readers Groups Informal groups of readers, who can connect socially while discussing books that have been borrowed from the Library.",
|
| 1844 |
+
"Signposting Library staff are experts in supporting customer to access a range of information, resources and to source additional services that might be of help to them."
|
| 1845 |
+
],
|
| 1846 |
+
"how_to": []
|
| 1847 |
+
},
|
| 1848 |
+
{
|
| 1849 |
+
"title": "Libraries offer a free warm space to its residents this winter",
|
| 1850 |
+
"url": "https://www.worcestershire.gov.uk/news/libraries-offer-free-warm-space-its-residents-winter",
|
| 1851 |
+
"category": "wellbeing",
|
| 1852 |
+
"summary": "As winter sets in, libraries across Worcestershire are here to provide everyone with a free, warm, and welcoming space.",
|
| 1853 |
+
"what_you_need": [
|
| 1854 |
+
"Everyone is welcome to use the library as a warm space, and the friendly, knowledgeable staff are always on hand to ensure visitors feel comfortable and well-supported."
|
| 1855 |
+
],
|
| 1856 |
+
"how_to": []
|
| 1857 |
+
},
|
| 1858 |
+
{
|
| 1859 |
+
"title": "Stay warm and connected at your local library this winter",
|
| 1860 |
+
"url": "https://www.worcestershire.gov.uk/news/stay-warm-and-connected-your-local-library-winter",
|
| 1861 |
+
"category": "wellbeing",
|
| 1862 |
+
"summary": "As the colder months return, libraries across Worcestershire offer free, warm, and welcoming spaces for everyone in the community. Whether youβre seeking a quiet place to read or study an opportunity to take part in a library group or activity, or support with digital skills, job searching or setting up a business, your local library has something for everyone.",
|
| 1863 |
+
"what_you_need": [],
|
| 1864 |
+
"how_to": []
|
| 1865 |
+
},
|
| 1866 |
+
{
|
| 1867 |
+
"title": "Warm welcome",
|
| 1868 |
+
"url": "https://www.worcestershire.gov.uk/council-services/libraries/warm-welcome",
|
| 1869 |
+
"category": "wellbeing",
|
| 1870 |
+
"summary": "Worcestershire Libraries are part of the Warm Welcome Network which lists venues that can provide a warm welcome for those struggling to heat their homes this winter.",
|
| 1871 |
+
"what_you_need": [
|
| 1872 |
+
"Libraries offer a wide range of free services to allow Worcestershire residents to stay connected: Welcoming and warm spaces to work, study, read and research or meet with friends.",
|
| 1873 |
+
"Library membership is free and library members can borrow books download e-books, audiobooks, e-magazines, e-newspapers and search online reference sources for free.",
|
| 1874 |
+
"Worcestershireβs 21 public libraries provide free access to computers, the internet and Wi-Fi and support from Digital Champions to develop IT skills and gain confidence to access online services."
|
| 1875 |
+
],
|
| 1876 |
+
"how_to": []
|
| 1877 |
+
}
|
| 1878 |
+
]
|
| 1879 |
+
}
|
library_sources.py
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
library_sources.py β live data-mining tools for Worcestershire Libraries.
|
| 3 |
+
|
| 4 |
+
Every function here pulls *live* data from official sources only:
|
| 5 |
+
- the SirsiDynix Enterprise catalogue (wcc.ent.sirsidynix.net.uk)
|
| 6 |
+
- worcestershire.gov.uk library pages
|
| 7 |
+
|
| 8 |
+
We deliberately do NOT scrape thehiveworcester.org β that content is
|
| 9 |
+
unreliable and often years out of date. Council + catalogue only.
|
| 10 |
+
|
| 11 |
+
No LLM, no Gradio in here β pure functions so they can be unit-tested
|
| 12 |
+
against the live sites on their own.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import html
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import difflib
|
| 22 |
+
import xml.etree.ElementTree as ET
|
| 23 |
+
from datetime import datetime, timezone, timedelta
|
| 24 |
+
from urllib.parse import quote_plus, urljoin
|
| 25 |
+
|
| 26 |
+
import requests
|
| 27 |
+
from bs4 import BeautifulSoup
|
| 28 |
+
|
| 29 |
+
# --------------------------------------------------------------------------- #
|
| 30 |
+
# Config
|
| 31 |
+
# --------------------------------------------------------------------------- #
|
| 32 |
+
|
| 33 |
+
GOV = "https://www.worcestershire.gov.uk"
|
| 34 |
+
CATALOGUE_RSS = "https://wcc.ent.sirsidynix.net.uk/client/rss/hitlist/wcc/qu="
|
| 35 |
+
CATALOGUE_SEARCH = (
|
| 36 |
+
"https://wcc.ent.sirsidynix.net.uk/client/en_GB/wcc/search/results?qu="
|
| 37 |
+
)
|
| 38 |
+
MOBILE_INDEX = f"{GOV}/council-services/libraries/your-library-membership/mobile-library"
|
| 39 |
+
EVENTS_URL = f"{GOV}/council-services/libraries/library-events-and-activities"
|
| 40 |
+
PRINTING_URL = f"{GOV}/council-services/libraries/printing-and-photocopying-services"
|
| 41 |
+
ONLINE_HUB = f"{GOV}/council-services/libraries/online-library-hub"
|
| 42 |
+
|
| 43 |
+
UA = (
|
| 44 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
| 45 |
+
"WorcsLibrariesAssistant/2.0 (+hackathon demo)"
|
| 46 |
+
)
|
| 47 |
+
HEADERS = {"User-Agent": UA, "Accept-Language": "en-GB,en;q=0.9"}
|
| 48 |
+
TIMEOUT = 15
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _now() -> str:
|
| 52 |
+
return datetime.now(timezone.utc).strftime("%H:%M on %d %b %Y UTC")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _get(url: str) -> requests.Response:
|
| 56 |
+
r = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
|
| 57 |
+
r.raise_for_status()
|
| 58 |
+
return r
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# --------------------------------------------------------------------------- #
|
| 62 |
+
# 1. Catalogue search (SirsiDynix Atom feed)
|
| 63 |
+
# --------------------------------------------------------------------------- #
|
| 64 |
+
|
| 65 |
+
ATOM = "{http://www.w3.org/2005/Atom}"
|
| 66 |
+
|
| 67 |
+
_FORMAT_ICON = {
|
| 68 |
+
"Books": "π",
|
| 69 |
+
"Large print": "π",
|
| 70 |
+
"Sound recording": "π§",
|
| 71 |
+
"Music recording": "π΅",
|
| 72 |
+
"Video disc": "π",
|
| 73 |
+
"DVD": "π",
|
| 74 |
+
"eBook": "π»",
|
| 75 |
+
"eAudiobook": "π§",
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _classify(fields: dict) -> tuple[str, bool]:
|
| 80 |
+
"""Return (friendly_format, is_digital)."""
|
| 81 |
+
fmt = fields.get("Format", "").strip()
|
| 82 |
+
ea = fields.get("Electronic Access", "")
|
| 83 |
+
digital = bool(ea) or "e" == fmt[:1].lower() and "ebook" in fmt.lower()
|
| 84 |
+
if "eAudiobook" in ea or "eAudiobook" in fmt:
|
| 85 |
+
return "eAudiobook", True
|
| 86 |
+
if "eBook" in ea or "eBook" in fmt or ea:
|
| 87 |
+
return ("eBook", True) if "audio" not in ea.lower() else ("eAudiobook", True)
|
| 88 |
+
return fmt or "Item", digital
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def search_catalogue(query: str, limit: int = 8) -> dict:
|
| 92 |
+
"""
|
| 93 |
+
Search the live Worcestershire library catalogue.
|
| 94 |
+
|
| 95 |
+
Returns {"query", "count", "items": [...], "search_url", "checked"}.
|
| 96 |
+
Each item: title, author, format, year, isbn, digital, detail_url.
|
| 97 |
+
"""
|
| 98 |
+
query = (query or "").strip()
|
| 99 |
+
if not query:
|
| 100 |
+
return {"query": "", "count": 0, "items": [], "error": "empty query"}
|
| 101 |
+
|
| 102 |
+
url = CATALOGUE_RSS + quote_plus(query)
|
| 103 |
+
r = _get(url)
|
| 104 |
+
items: list[dict] = []
|
| 105 |
+
try:
|
| 106 |
+
root = ET.fromstring(r.content)
|
| 107 |
+
except ET.ParseError as e:
|
| 108 |
+
return {"query": query, "count": 0, "items": [],
|
| 109 |
+
"error": f"feed parse error: {e}",
|
| 110 |
+
"search_url": CATALOGUE_SEARCH + quote_plus(query)}
|
| 111 |
+
|
| 112 |
+
for entry in root.findall(f"{ATOM}entry"):
|
| 113 |
+
title = (entry.findtext(f"{ATOM}title") or "").strip().rstrip(".")
|
| 114 |
+
cat_id = (entry.findtext(f"{ATOM}id") or "").strip()
|
| 115 |
+
detail = ""
|
| 116 |
+
for link in entry.findall(f"{ATOM}link"):
|
| 117 |
+
if link.get("rel") == "alternate":
|
| 118 |
+
detail = link.get("href", "")
|
| 119 |
+
break
|
| 120 |
+
|
| 121 |
+
content = entry.findtext(f"{ATOM}content") or ""
|
| 122 |
+
content = content.replace("<br/>", "\n").replace("<br>", "\n")
|
| 123 |
+
content = html.unescape(content).replace("\xa0", " ")
|
| 124 |
+
|
| 125 |
+
fields: dict[str, str] = {}
|
| 126 |
+
for seg in content.split("\n"):
|
| 127 |
+
seg = seg.strip()
|
| 128 |
+
if not seg:
|
| 129 |
+
continue
|
| 130 |
+
if seg.lower().startswith("by "):
|
| 131 |
+
fields["author"] = seg[3:].strip().rstrip(".")
|
| 132 |
+
continue
|
| 133 |
+
for label in ("Format", "Publication Date", "ISBN", "Edition",
|
| 134 |
+
"Language", "Electronic Access", "Call Number", "Series"):
|
| 135 |
+
if seg.startswith(label):
|
| 136 |
+
fields[label] = seg[len(label):].strip()
|
| 137 |
+
break
|
| 138 |
+
|
| 139 |
+
fmt, digital = _classify(fields)
|
| 140 |
+
items.append({
|
| 141 |
+
"title": title,
|
| 142 |
+
"author": fields.get("author", ""),
|
| 143 |
+
"format": fmt,
|
| 144 |
+
"icon": _FORMAT_ICON.get(fmt, "π¦"),
|
| 145 |
+
"year": fields.get("Publication Date", "").split()[0] if fields.get("Publication Date") else "",
|
| 146 |
+
"isbn": fields.get("ISBN", ""),
|
| 147 |
+
"digital": digital,
|
| 148 |
+
"detail_url": detail,
|
| 149 |
+
"catalogue_id": cat_id,
|
| 150 |
+
})
|
| 151 |
+
if len(items) >= limit:
|
| 152 |
+
break
|
| 153 |
+
|
| 154 |
+
return {
|
| 155 |
+
"query": query,
|
| 156 |
+
"count": len(items),
|
| 157 |
+
"total_hint": len(root.findall(f"{ATOM}entry")),
|
| 158 |
+
"items": items,
|
| 159 |
+
"search_url": CATALOGUE_SEARCH + quote_plus(query),
|
| 160 |
+
"checked": _now(),
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# --------------------------------------------------------------------------- #
|
| 165 |
+
# 2. Mobile library timetable
|
| 166 |
+
# --------------------------------------------------------------------------- #
|
| 167 |
+
|
| 168 |
+
_village_cache: dict[str, str] | None = None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _village_index() -> dict[str, str]:
|
| 172 |
+
"""Map normalised village name -> absolute timetable URL (cached)."""
|
| 173 |
+
global _village_cache
|
| 174 |
+
if _village_cache is not None:
|
| 175 |
+
return _village_cache
|
| 176 |
+
r = _get(MOBILE_INDEX)
|
| 177 |
+
soup = BeautifulSoup(r.text, "html.parser")
|
| 178 |
+
out: dict[str, str] = {}
|
| 179 |
+
for a in soup.select('a[href*="/mobile-library/"]'):
|
| 180 |
+
href = a.get("href", "")
|
| 181 |
+
slug = href.rstrip("/").split("/")[-1]
|
| 182 |
+
if not slug or slug == "mobile-library":
|
| 183 |
+
continue
|
| 184 |
+
name = slug.replace("-", " ").strip().lower()
|
| 185 |
+
out.setdefault(name, urljoin(GOV, href))
|
| 186 |
+
_village_cache = out
|
| 187 |
+
return out
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def mobile_library(place: str) -> dict:
|
| 191 |
+
"""
|
| 192 |
+
Find the mobile-library timetable for a village/stop.
|
| 193 |
+
|
| 194 |
+
Returns {"village", "date_of_operation", "stops":[{time,location}],
|
| 195 |
+
"email", "page_url", "checked"} or {"error", "suggestions"}.
|
| 196 |
+
"""
|
| 197 |
+
place = (place or "").strip().lower()
|
| 198 |
+
index = _village_index()
|
| 199 |
+
if not place:
|
| 200 |
+
return {"error": "no place given", "suggestions": sorted(index)[:12]}
|
| 201 |
+
|
| 202 |
+
# exact -> substring -> fuzzy
|
| 203 |
+
name = None
|
| 204 |
+
if place in index:
|
| 205 |
+
name = place
|
| 206 |
+
else:
|
| 207 |
+
subs = [v for v in index if place in v or v in place]
|
| 208 |
+
if subs:
|
| 209 |
+
name = sorted(subs, key=len)[0]
|
| 210 |
+
else:
|
| 211 |
+
close = difflib.get_close_matches(place, index.keys(), n=3, cutoff=0.6)
|
| 212 |
+
if close:
|
| 213 |
+
name = close[0]
|
| 214 |
+
|
| 215 |
+
if not name:
|
| 216 |
+
sugg = difflib.get_close_matches(place, index.keys(), n=6, cutoff=0.3)
|
| 217 |
+
return {
|
| 218 |
+
"error": f"No mobile-library stop found matching '{place}'.",
|
| 219 |
+
"suggestions": sugg or sorted(index)[:10],
|
| 220 |
+
"page_url": MOBILE_INDEX,
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
url = index[name]
|
| 224 |
+
soup = BeautifulSoup(_get(url).text, "html.parser")
|
| 225 |
+
main = soup.find("main") or soup
|
| 226 |
+
lines = [l.strip() for l in main.get_text("\n").split("\n") if l.strip()]
|
| 227 |
+
|
| 228 |
+
date_op = ""
|
| 229 |
+
for i, l in enumerate(lines):
|
| 230 |
+
if l.lower() == "date of operation" and i + 1 < len(lines):
|
| 231 |
+
date_op = lines[i + 1]
|
| 232 |
+
break
|
| 233 |
+
|
| 234 |
+
stop_re = re.compile(r"^\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm)?\s*to\s+.+-\s+.+", re.I)
|
| 235 |
+
stops = []
|
| 236 |
+
for l in lines:
|
| 237 |
+
if stop_re.match(l):
|
| 238 |
+
tpart, _, loc = l.partition(" - ")
|
| 239 |
+
stops.append({"time": tpart.strip(), "location": loc.strip()})
|
| 240 |
+
|
| 241 |
+
return {
|
| 242 |
+
"village": name.title(),
|
| 243 |
+
"date_of_operation": date_op,
|
| 244 |
+
"stops": stops,
|
| 245 |
+
"email": "mobilelibraries@worcestershire.gov.uk",
|
| 246 |
+
"page_url": url,
|
| 247 |
+
"checked": _now(),
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# --------------------------------------------------------------------------- #
|
| 252 |
+
# 3. Library events & activities
|
| 253 |
+
# --------------------------------------------------------------------------- #
|
| 254 |
+
|
| 255 |
+
def _clean_label(text: str) -> str:
|
| 256 |
+
"""'Time: 9:30am to 2:00pm' -> '9:30am to 2:00pm'."""
|
| 257 |
+
return re.sub(r"^[A-Za-z ]+:\s*", "", text or "").strip()
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def library_events(query: str | None = None, limit: int = 8) -> dict:
|
| 261 |
+
"""
|
| 262 |
+
Scrape upcoming library events/activities. Optional keyword filter.
|
| 263 |
+
|
| 264 |
+
Returns {"count", "events":[{name,when,time,location,url}], "page_url", "checked"}.
|
| 265 |
+
"""
|
| 266 |
+
soup = BeautifulSoup(_get(EVENTS_URL).text, "html.parser")
|
| 267 |
+
events: list[dict] = []
|
| 268 |
+
seen = set()
|
| 269 |
+
|
| 270 |
+
# Each event is a Drupal `views-row` that links to /events/<slug>.
|
| 271 |
+
for row in soup.select("div.views-row"):
|
| 272 |
+
anchor = row.find("a", href=re.compile(r"/events/[a-z0-9-]+"))
|
| 273 |
+
if not anchor:
|
| 274 |
+
continue
|
| 275 |
+
href = urljoin(GOV, anchor.get("href", ""))
|
| 276 |
+
if href in seen:
|
| 277 |
+
continue
|
| 278 |
+
seen.add(href)
|
| 279 |
+
|
| 280 |
+
title_el = row.select_one(".views-field-title")
|
| 281 |
+
name = (title_el.get_text(" ", strip=True) if title_el
|
| 282 |
+
else anchor.get_text(" ", strip=True))
|
| 283 |
+
|
| 284 |
+
time_el = row.select_one(".event-time")
|
| 285 |
+
days_el = row.select_one(".event-days")
|
| 286 |
+
loc_el = row.select_one(".views-field-field-location")
|
| 287 |
+
date_blob = row.select_one(".views-field-field-date-value")
|
| 288 |
+
next_date = ""
|
| 289 |
+
if date_blob:
|
| 290 |
+
m = re.search(r"Date:\s*(.+?)\s*(?:How often|Time:|$)",
|
| 291 |
+
date_blob.get_text(" ", strip=True))
|
| 292 |
+
if m:
|
| 293 |
+
next_date = m.group(1).strip()
|
| 294 |
+
|
| 295 |
+
events.append({
|
| 296 |
+
"name": name,
|
| 297 |
+
"next_date": next_date,
|
| 298 |
+
"when": _clean_label(days_el.get_text(" ", strip=True)) if days_el else "",
|
| 299 |
+
"time": _clean_label(time_el.get_text(" ", strip=True)) if time_el else "",
|
| 300 |
+
"location": _clean_label(loc_el.get_text(" ", strip=True)) if loc_el else "",
|
| 301 |
+
"url": href,
|
| 302 |
+
})
|
| 303 |
+
|
| 304 |
+
if query:
|
| 305 |
+
q = query.lower()
|
| 306 |
+
filt = [e for e in events
|
| 307 |
+
if q in e["name"].lower() or q in e["location"].lower()]
|
| 308 |
+
if filt:
|
| 309 |
+
events = filt
|
| 310 |
+
|
| 311 |
+
return {
|
| 312 |
+
"count": len(events[:limit]),
|
| 313 |
+
"events": events[:limit],
|
| 314 |
+
"page_url": EVENTS_URL,
|
| 315 |
+
"checked": _now(),
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# --------------------------------------------------------------------------- #
|
| 320 |
+
# 4. Printing β "Print Your Way"
|
| 321 |
+
# --------------------------------------------------------------------------- #
|
| 322 |
+
|
| 323 |
+
# Sourced from the official printing page (verified June 2026). Stable content,
|
| 324 |
+
# so served directly with a live source link rather than re-scraped each call.
|
| 325 |
+
PRINT_YOUR_WAY = {
|
| 326 |
+
"summary": (
|
| 327 |
+
"Print Your Way lets full library members send a print job from their own "
|
| 328 |
+
"phone, tablet or computer and collect it from any Worcestershire library "
|
| 329 |
+
"printer within 24 hours β great for job applications, forms, tickets and "
|
| 330 |
+
"returns labels."
|
| 331 |
+
),
|
| 332 |
+
"device_requirements": "Android 12+, iOS 16+, macOS 12 (Monterey)+, or Windows 11.",
|
| 333 |
+
"steps": [
|
| 334 |
+
"Be a full library member (free to join with a library card).",
|
| 335 |
+
"Top up your PaperCut print account at a self-service kiosk in any "
|
| 336 |
+
"Worcestershire library (some kiosks are cash-only, so check first).",
|
| 337 |
+
"One-time setup: download and follow the Print Your Way guide for your "
|
| 338 |
+
"device (Android / iOS / macOS / Windows).",
|
| 339 |
+
"Open your document, choose the mono or colour print queue, set your "
|
| 340 |
+
"options and send β authenticate with your library number and PIN.",
|
| 341 |
+
"Release the job at any public printer in any Worcestershire library "
|
| 342 |
+
"within 24 hours.",
|
| 343 |
+
],
|
| 344 |
+
"pricing": {
|
| 345 |
+
"A4 black & white": "15p per side",
|
| 346 |
+
"A4 colour": "50p per side",
|
| 347 |
+
"A3 black & white": "25p per side",
|
| 348 |
+
"A3 colour": "85p per side",
|
| 349 |
+
},
|
| 350 |
+
"page_url": PRINTING_URL,
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def printing_help() -> dict:
|
| 355 |
+
out = dict(PRINT_YOUR_WAY)
|
| 356 |
+
out["checked"] = _now()
|
| 357 |
+
return out
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
# --------------------------------------------------------------------------- #
|
| 361 |
+
# 5. Knowledge base β every library service page (built by build_kb.py)
|
| 362 |
+
# --------------------------------------------------------------------------- #
|
| 363 |
+
|
| 364 |
+
_KB = None
|
| 365 |
+
|
| 366 |
+
def kb() -> dict:
|
| 367 |
+
global _KB
|
| 368 |
+
if _KB is None:
|
| 369 |
+
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
| 370 |
+
"library_kb.json")
|
| 371 |
+
try:
|
| 372 |
+
with open(path, encoding="utf-8") as f:
|
| 373 |
+
_KB = json.load(f)
|
| 374 |
+
except FileNotFoundError:
|
| 375 |
+
_KB = {"branches": [], "online_hub": [], "services": [],
|
| 376 |
+
"membership_tiers": []}
|
| 377 |
+
return _KB
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# "What you need to sign up" β the eligibility that varies per service.
|
| 381 |
+
ELIGIBILITY = {
|
| 382 |
+
"borrow_physical": "Free **full membership** (join online + collect, or in any library).",
|
| 383 |
+
"borrow_digital": "Free **digital membership** β instant, just a Worcestershire postcode.",
|
| 384 |
+
"printing": "Full membership + top up a PaperCut account at a library kiosk.",
|
| 385 |
+
"mobile": "Free full membership β you can join on the van.",
|
| 386 |
+
"unlocked": "Full member, aged 15+, after a short one-off staff induction.",
|
| 387 |
+
"events": "Most events are free β just turn up; a few need booking.",
|
| 388 |
+
"visit": "Nothing at all β anyone can walk in for Wi-Fi, toilets and study space.",
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
|
| 392 |
+
"Saturday", "Sunday"]
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _uk_now() -> datetime:
|
| 396 |
+
try:
|
| 397 |
+
from zoneinfo import ZoneInfo
|
| 398 |
+
return datetime.now(ZoneInfo("Europe/London"))
|
| 399 |
+
except Exception:
|
| 400 |
+
return datetime.now(timezone.utc) + timedelta(hours=1) # BST approx
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def _to_minutes(t: str):
|
| 404 |
+
m = re.match(r"\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)", t, re.I)
|
| 405 |
+
if not m:
|
| 406 |
+
return None
|
| 407 |
+
h = int(m.group(1)) % 12
|
| 408 |
+
if m.group(3).lower() == "pm":
|
| 409 |
+
h += 12
|
| 410 |
+
return h * 60 + int(m.group(2) or 0)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _open_status(staffed: str, now_min: int):
|
| 414 |
+
if not staffed or "close" in staffed.lower():
|
| 415 |
+
return False, "closed today"
|
| 416 |
+
rng = re.search(r"(\d{1,2}(?::\d{2})?\s*(?:am|pm))\s*(?:to|-|β)\s*"
|
| 417 |
+
r"(\d{1,2}(?::\d{2})?\s*(?:am|pm))", staffed, re.I)
|
| 418 |
+
if not rng:
|
| 419 |
+
return None, staffed
|
| 420 |
+
a, b = _to_minutes(rng.group(1)), _to_minutes(rng.group(2))
|
| 421 |
+
if a is None or b is None:
|
| 422 |
+
return None, staffed
|
| 423 |
+
if a <= now_min < b:
|
| 424 |
+
return True, f"open now until {rng.group(2)}"
|
| 425 |
+
if now_min < a:
|
| 426 |
+
return False, f"opens at {rng.group(1)}"
|
| 427 |
+
return False, f"closed now (was open until {rng.group(2)})"
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def find_library(name: str | None = None) -> dict:
|
| 431 |
+
"""Branch hours ('open now?'), address and facilities (toilets/parkingβ¦)."""
|
| 432 |
+
branches = kb().get("branches", [])
|
| 433 |
+
if not (name or "").strip():
|
| 434 |
+
return {"branches": [{"name": b["name"], "address": b.get("address", ""),
|
| 435 |
+
"url": b["url"]} for b in branches], "checked": _now()}
|
| 436 |
+
names = {b["name"].lower(): b for b in branches}
|
| 437 |
+
q = name.strip().lower()
|
| 438 |
+
pick = next((b for n, b in names.items()
|
| 439 |
+
if q in n or n.replace(" library", "").strip() in q), None)
|
| 440 |
+
if not pick:
|
| 441 |
+
close = difflib.get_close_matches(q, names.keys(), n=1, cutoff=0.4)
|
| 442 |
+
pick = names[close[0]] if close else None
|
| 443 |
+
if not pick:
|
| 444 |
+
return {"error": f"No Worcestershire library found matching '{name}'.",
|
| 445 |
+
"suggestions": [b["name"] for b in branches[:8]],
|
| 446 |
+
"page_url": f"{GOV}/council-services/libraries/find-library"}
|
| 447 |
+
now = _uk_now()
|
| 448 |
+
today = DAY_NAMES[now.weekday()]
|
| 449 |
+
h = pick.get("hours", {}).get(today, {})
|
| 450 |
+
is_open, status = _open_status(h.get("staffed", ""), now.hour * 60 + now.minute)
|
| 451 |
+
return {
|
| 452 |
+
"name": pick["name"], "address": pick.get("address", ""),
|
| 453 |
+
"facilities": pick.get("facilities", []), "hours": pick.get("hours", {}),
|
| 454 |
+
"libraries_unlocked": pick.get("libraries_unlocked", False),
|
| 455 |
+
"today": today, "today_staffed": h.get("staffed", ""),
|
| 456 |
+
"unlocked_today": h.get("unlocked", ""),
|
| 457 |
+
"open_now": is_open, "status": status,
|
| 458 |
+
"page_url": pick["url"], "checked": _now(),
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
UNLOCKED_BRANCHES = ["Bromsgrove", "Droitwich", "Evesham", "Kidderminster",
|
| 463 |
+
"Malvern", "Pershore", "Redditch", "Rubery", "St John's",
|
| 464 |
+
"Stourport", "Tenbury"]
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def libraries_unlocked(branch: str | None = None) -> dict:
|
| 468 |
+
tier = next((t for t in kb().get("membership_tiers", [])
|
| 469 |
+
if "Unlocked" in t.get("tier", "")), {})
|
| 470 |
+
out = {"branches": UNLOCKED_BRANCHES,
|
| 471 |
+
"hours": "8:00am to 8:00pm, Monday to Saturday",
|
| 472 |
+
"what_you_need": tier.get("what_you_need", ELIGIBILITY["unlocked"]),
|
| 473 |
+
"unlocks": tier.get("unlocks", ""),
|
| 474 |
+
"page_url": tier.get("url", f"{GOV}/council-services/libraries/libraries-unlocked"),
|
| 475 |
+
"checked": _now()}
|
| 476 |
+
if branch:
|
| 477 |
+
b = branch.strip().lower()
|
| 478 |
+
out["branch_match"] = next(
|
| 479 |
+
(x for x in UNLOCKED_BRANCHES if x.lower() in b or b in x.lower()), None)
|
| 480 |
+
return out
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
# Curated, customer-grade detail for the online-hub resources. The crawler
|
| 484 |
+
# gets summaries; this adds the precise "what you need + how to access + what's
|
| 485 |
+
# inside" that the council pages bury. (Verified June 2026.)
|
| 486 |
+
# NB: we surface ACCESS + public TITLE COVERAGE only β never the licensed
|
| 487 |
+
# article content itself (that would breach PressReader/publisher terms).
|
| 488 |
+
CURATED_HUB = {
|
| 489 |
+
"pressreader": {
|
| 490 |
+
"what_you_need": "Worcestershire library card number + PIN (free full membership, residents). No separate PressReader account needed.",
|
| 491 |
+
"at_home": True,
|
| 492 |
+
"access": [
|
| 493 |
+
"Get the PressReader app (or use pressreader.com).",
|
| 494 |
+
"Tap 'Libraries & Groups' and search/select 'Worcestershire'.",
|
| 495 |
+
"Sign in with your library card number + PIN.",
|
| 496 |
+
"At home you get a 30-day pass (re-confirm monthly); on library Wi-Fi it's 7 days.",
|
| 497 |
+
],
|
| 498 |
+
"inside": "7,000+ full-page newspapers & magazines, 60+ languages, 120+ countries.",
|
| 499 |
+
"titles": ["The Guardian", "The Independent", "Newsweek", "Vogue", "GQ",
|
| 500 |
+
"Hello!", "BBC Top Gear", "Le Monde", "El PaΓs"],
|
| 501 |
+
"extras": "Free account adds offline download, article translation and listen-to-article audio.",
|
| 502 |
+
},
|
| 503 |
+
"borrowbox": {
|
| 504 |
+
"what_you_need": "Library card number + PIN; choose 'Worcestershire' in the app.",
|
| 505 |
+
"at_home": True,
|
| 506 |
+
"access": [
|
| 507 |
+
"Download the BorrowBox app.",
|
| 508 |
+
"Select 'Worcestershire' as your library service.",
|
| 509 |
+
"Sign in with your card number + PIN.",
|
| 510 |
+
],
|
| 511 |
+
"inside": "Free eBooks & eAudiobooks β latest titles, award winners, non-fiction, classics and children's, plus the curated 'Worcestershire Reads' picks.",
|
| 512 |
+
"limits": "Borrow up to 4 eBooks + 4 eAudiobooks at once; auto-returns (no fines); read/listen offline in the app.",
|
| 513 |
+
},
|
| 514 |
+
"ancestry": {
|
| 515 |
+
"what_you_need": "Library membership. Ancestry Library Edition is normally used in the library / on library Wi-Fi β check the hub page for current at-home access.",
|
| 516 |
+
"at_home": False,
|
| 517 |
+
"inside": "Billions of historical records β census, births/marriages/deaths, military, immigration and more, spanning the 1500sβ2000s.",
|
| 518 |
+
},
|
| 519 |
+
"theory test pro": {
|
| 520 |
+
"what_you_need": "Library card; works at home.",
|
| 521 |
+
"at_home": True,
|
| 522 |
+
"inside": "Practise the official DVSA driving theory test (car, motorcycle, LGV/PCV) including hazard-perception clips.",
|
| 523 |
+
},
|
| 524 |
+
"which": {
|
| 525 |
+
"what_you_need": "Library membership (often used in-branch β check the page).",
|
| 526 |
+
"inside": "Independent product reviews and Best Buy buying advice.",
|
| 527 |
+
},
|
| 528 |
+
"times digital archive": {
|
| 529 |
+
"what_you_need": "Free digital membership.",
|
| 530 |
+
"at_home": True,
|
| 531 |
+
"inside": "Every page of The Times newspaper, scanned back to 1785.",
|
| 532 |
+
},
|
| 533 |
+
"access to research": {
|
| 534 |
+
"what_you_need": "Free walk-in use on a library computer (in-branch).",
|
| 535 |
+
"at_home": False,
|
| 536 |
+
"inside": "Millions of academic journal articles across many disciplines β for students and independent researchers.",
|
| 537 |
+
},
|
| 538 |
+
"bfi": {
|
| 539 |
+
"what_you_need": "Use on library computers / library Wi-Fi (in-branch).",
|
| 540 |
+
"at_home": False,
|
| 541 |
+
"inside": "Thousands of archive British films and TV programmes from the BFI National Archive.",
|
| 542 |
+
},
|
| 543 |
+
"cobra": {
|
| 544 |
+
"what_you_need": "Library membership; supported by the Business & IP Centre (often used in-branch).",
|
| 545 |
+
"at_home": False,
|
| 546 |
+
"inside": "Business start-up guides, market research and reference for new and growing businesses.",
|
| 547 |
+
},
|
| 548 |
+
"digital library membership": {
|
| 549 |
+
"what_you_need": "Any Worcestershire resident β sign up online in minutes with your postcode. No card needed.",
|
| 550 |
+
"at_home": True,
|
| 551 |
+
"inside": "Instant free access to eBooks, eAudiobooks, eMagazines & eNewspapers and more.",
|
| 552 |
+
},
|
| 553 |
+
"ebsco": {
|
| 554 |
+
"what_you_need": "Library card + PIN; available at home.",
|
| 555 |
+
"at_home": True,
|
| 556 |
+
"inside": "Academic and reference databases β magazines, journals and research articles.",
|
| 557 |
+
},
|
| 558 |
+
"espacenet": {
|
| 559 |
+
"what_you_need": "Free for everyone; support via the Business & IP Centre.",
|
| 560 |
+
"at_home": True,
|
| 561 |
+
"inside": "Search 150+ million patent documents worldwide (European Patent Office).",
|
| 562 |
+
},
|
| 563 |
+
"online events": {
|
| 564 |
+
"what_you_need": "Free β book a place via the events listing.",
|
| 565 |
+
"at_home": True,
|
| 566 |
+
"inside": "Live digital talks and workshops you can join from home.",
|
| 567 |
+
},
|
| 568 |
+
"national biography": {
|
| 569 |
+
"what_you_need": "Free digital membership.",
|
| 570 |
+
"at_home": True,
|
| 571 |
+
"inside": "60,000+ biographies of notable people from British history (Oxford DNB).",
|
| 572 |
+
},
|
| 573 |
+
"oxford english": {
|
| 574 |
+
"what_you_need": "Free digital membership.",
|
| 575 |
+
"at_home": True,
|
| 576 |
+
"inside": "The complete Oxford English Dictionary β meanings, history and pronunciation.",
|
| 577 |
+
},
|
| 578 |
+
"oxford reference": {
|
| 579 |
+
"what_you_need": "Free digital membership.",
|
| 580 |
+
"at_home": True,
|
| 581 |
+
"inside": "Thousands of dictionaries and reference works across every subject.",
|
| 582 |
+
},
|
| 583 |
+
"oxford research": {
|
| 584 |
+
"what_you_need": "Free digital membership.",
|
| 585 |
+
"at_home": True,
|
| 586 |
+
"inside": "In-depth peer-reviewed research encyclopaedias across many fields.",
|
| 587 |
+
},
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def _merge_curated(name: str) -> dict:
|
| 592 |
+
n = name.lower()
|
| 593 |
+
for key, data in CURATED_HUB.items():
|
| 594 |
+
if key in n or n in key:
|
| 595 |
+
return data
|
| 596 |
+
return {}
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
_HUB_SYNONYMS = {"newspaper": "pressreader", "magazine": "pressreader",
|
| 600 |
+
"family history": "ancestry", "ancestry": "ancestry",
|
| 601 |
+
"genealogy": "ancestry", "ebook": "borrowbox",
|
| 602 |
+
"audiobook": "borrowbox", "business": "cobra",
|
| 603 |
+
"driving": "theory", "theory test": "theory",
|
| 604 |
+
"dictionary": "oxford", "research": "ebsco"}
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def online_hub(topic: str | None = None) -> dict:
|
| 608 |
+
"""Free-from-home digital resources (BorrowBox, PressReader, Ancestryβ¦)."""
|
| 609 |
+
hub = kb().get("online_hub", [])
|
| 610 |
+
items = hub
|
| 611 |
+
if topic:
|
| 612 |
+
t = topic.lower()
|
| 613 |
+
hits = [] # synonym-mapped resources rank first
|
| 614 |
+
for k, v in _HUB_SYNONYMS.items():
|
| 615 |
+
if k in t:
|
| 616 |
+
hits += [h for h in hub if v in h["name"].lower()]
|
| 617 |
+
hits += [h for h in hub if t in h["name"].lower()
|
| 618 |
+
or t in h.get("summary", "").lower()]
|
| 619 |
+
if hits:
|
| 620 |
+
seen, dedup = set(), []
|
| 621 |
+
for h in hits:
|
| 622 |
+
if h["url"] not in seen:
|
| 623 |
+
seen.add(h["url"]); dedup.append(h)
|
| 624 |
+
items = dedup
|
| 625 |
+
out_items = []
|
| 626 |
+
for h in items[:8]:
|
| 627 |
+
cur = _merge_curated(h["name"])
|
| 628 |
+
out_items.append({
|
| 629 |
+
"name": h["name"],
|
| 630 |
+
"summary": cur.get("inside") or h.get("summary", "")[:200],
|
| 631 |
+
"what_you_need": cur.get("what_you_need")
|
| 632 |
+
or " ".join((h.get("what_you_need") or [ELIGIBILITY["borrow_digital"]])[:1]),
|
| 633 |
+
"access": cur.get("access", []),
|
| 634 |
+
"at_home": cur.get("at_home"),
|
| 635 |
+
"limits": cur.get("limits", ""),
|
| 636 |
+
"titles": cur.get("titles", []),
|
| 637 |
+
"extras": cur.get("extras", ""),
|
| 638 |
+
"url": h["url"],
|
| 639 |
+
})
|
| 640 |
+
return {
|
| 641 |
+
"count": len(out_items),
|
| 642 |
+
"items": out_items,
|
| 643 |
+
"tiers": kb().get("membership_tiers", []),
|
| 644 |
+
"page_url": ONLINE_HUB, "checked": _now(),
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def membership_help(service: str | None = None) -> dict:
|
| 649 |
+
"""What you need to sign up β the cross-service membership matrix."""
|
| 650 |
+
out = {"tiers": kb().get("membership_tiers", []),
|
| 651 |
+
"page_url": f"{GOV}/council-services/libraries/your-library-membership/join-library",
|
| 652 |
+
"checked": _now()}
|
| 653 |
+
if service:
|
| 654 |
+
s = service.lower()
|
| 655 |
+
if any(w in s for w in ("print", "photocopy")):
|
| 656 |
+
out["need"] = "Full membership + a topped-up PaperCut account."
|
| 657 |
+
elif any(w in s for w in ("ebook", "audiobook", "borrowbox", "online",
|
| 658 |
+
"magazine", "newspaper", "ancestry", "digital")):
|
| 659 |
+
out["need"] = "Free digital membership β instant with a postcode."
|
| 660 |
+
elif any(w in s for w in ("unlock", "8pm", "evening", "after hours")):
|
| 661 |
+
out["need"] = "Full membership, 15+, plus a one-off induction."
|
| 662 |
+
else:
|
| 663 |
+
out["need"] = "Free full membership."
|
| 664 |
+
return out
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def whats_new(genre: str | None = None, limit: int = 6) -> dict:
|
| 668 |
+
"""Newest catalogue titles for a genre/topic β fuel for a fun 'hot take'."""
|
| 669 |
+
term = (genre or "fiction").strip()
|
| 670 |
+
res = search_catalogue(term, limit=30)
|
| 671 |
+
items = [i for i in res.get("items", []) if i.get("year", "").isdigit()]
|
| 672 |
+
items.sort(key=lambda i: i["year"], reverse=True)
|
| 673 |
+
items = items or res.get("items", [])
|
| 674 |
+
return {"genre": genre or "fiction", "items": items[:limit],
|
| 675 |
+
"search_url": res.get("search_url", ""), "checked": _now()}
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
# --------------------------------------------------------------------------- #
|
| 679 |
+
# Self-test
|
| 680 |
+
# --------------------------------------------------------------------------- #
|
| 681 |
+
|
| 682 |
+
if __name__ == "__main__":
|
| 683 |
+
import json
|
| 684 |
+
|
| 685 |
+
print("\n### 1. CATALOGUE: 'harry potter' ###")
|
| 686 |
+
res = search_catalogue("harry potter", limit=4)
|
| 687 |
+
print(f"count={res['count']} (feed had ~{res.get('total_hint')}) checked {res.get('checked')}")
|
| 688 |
+
for it in res["items"]:
|
| 689 |
+
print(f" {it['icon']} {it['title']} β {it['author']} [{it['format']} {it['year']}]")
|
| 690 |
+
|
| 691 |
+
print("\n### 2. MOBILE LIBRARY: 'abberley' ###")
|
| 692 |
+
res = mobile_library("abberley")
|
| 693 |
+
if "error" in res:
|
| 694 |
+
print(" ", res["error"], "β", res.get("suggestions"))
|
| 695 |
+
else:
|
| 696 |
+
print(f" {res['village']} β {res['date_of_operation']} ({len(res['stops'])} stops)")
|
| 697 |
+
for s in res["stops"][:4]:
|
| 698 |
+
print(f" {s['time']} β {s['location']}")
|
| 699 |
+
|
| 700 |
+
print("\n### 2b. MOBILE LIBRARY fuzzy: 'kemsey' (typo) ###")
|
| 701 |
+
res = mobile_library("kemsey")
|
| 702 |
+
print(" ", res.get("village") or res.get("error"), res.get("suggestions", ""))
|
| 703 |
+
|
| 704 |
+
print("\n### 3. EVENTS (filter: 'knit') ###")
|
| 705 |
+
res = library_events("knit")
|
| 706 |
+
for e in res["events"]:
|
| 707 |
+
print(f" β’ {e['name']} β {e['when']} {e['time']} @ {e['location']}")
|
| 708 |
+
|
| 709 |
+
print("\n### 3b. EVENTS (all) ###")
|
| 710 |
+
res = library_events()
|
| 711 |
+
print(f" {res['count']} events found")
|
| 712 |
+
|
| 713 |
+
print("\n### 4. PRINTING ###")
|
| 714 |
+
res = printing_help()
|
| 715 |
+
print(" ", res["summary"][:80], "...")
|
| 716 |
+
print(" pricing:", res["pricing"])
|
requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
-
huggingface_hub>=0.
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
| 1 |
+
huggingface_hub>=0.27
|
| 2 |
+
requests>=2.31
|
| 3 |
+
beautifulsoup4>=4.12
|
| 4 |
+
networkx>=3.0
|
server.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
server.py β a fully custom frontend via `gradio.Server` (π¨ Off-Brand badge).
|
| 3 |
+
|
| 4 |
+
Reuses the SAME backend as app.py (one source of truth: app.respond), but serves
|
| 5 |
+
a hand-built HTML/CSS/JS chat UI instead of the default Gradio look. The frontend
|
| 6 |
+
talks to this server with the Gradio JS client and streams the answer.
|
| 7 |
+
|
| 8 |
+
NOTE: app.py (Gradio Blocks) remains the tested, go-live default. This is the
|
| 9 |
+
custom-UI variant β **smoke-test it on the Space first** (we couldn't boot Gradio
|
| 10 |
+
locally on Python 3.9). To use it on a Space, set `app_file: server.py` in README.
|
| 11 |
+
|
| 12 |
+
Run: python server.py β http://localhost:7860
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
from fastapi.responses import HTMLResponse
|
| 20 |
+
from gradio import Server
|
| 21 |
+
|
| 22 |
+
import app as backend # reuse respond(), tools, model wrapper, traces
|
| 23 |
+
|
| 24 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 25 |
+
server = Server()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@server.api(name="ask")
|
| 29 |
+
def ask(message: str):
|
| 30 |
+
"""Stream the assistant's growing markdown answer to the custom frontend."""
|
| 31 |
+
last = ""
|
| 32 |
+
for text, _chips in backend.respond(message, []):
|
| 33 |
+
last = text
|
| 34 |
+
yield text
|
| 35 |
+
if not last:
|
| 36 |
+
yield "Sorry β I didn't catch that. Try asking about a book, a branch, " \
|
| 37 |
+
"events, or printing."
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@server.get("/", response_class=HTMLResponse)
|
| 41 |
+
def home():
|
| 42 |
+
with open(os.path.join(HERE, "index.html"), encoding="utf-8") as f:
|
| 43 |
+
return f.read()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
server.launch(server_name="0.0.0.0", server_port=7860)
|
trace.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
trace.py β capture a structured agent trace per turn.
|
| 3 |
+
|
| 4 |
+
Two payoffs:
|
| 5 |
+
β’ π‘ Open Trace badge β every answer's reasoning is logged to traces.jsonl in a
|
| 6 |
+
shareable schema and can be pushed to the Hub.
|
| 7 |
+
β’ π€ Best Agent β the trace is also shown in-chat as a "How I answered" panel,
|
| 8 |
+
so the agent's route β tool β retrieve β synthesise loop is visible.
|
| 9 |
+
|
| 10 |
+
The schema is intentionally close to the hackathon's own trace datasets:
|
| 11 |
+
one JSON object per user turn, with ordered steps.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import time
|
| 19 |
+
import uuid
|
| 20 |
+
from datetime import datetime, timezone
|
| 21 |
+
|
| 22 |
+
TRACE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "traces.jsonl")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Trace:
|
| 26 |
+
def __init__(self, question: str, model: str):
|
| 27 |
+
self.d = {
|
| 28 |
+
"trace_id": uuid.uuid4().hex[:12],
|
| 29 |
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 30 |
+
"app": "worcs-libraries-live-assistant",
|
| 31 |
+
"question": question,
|
| 32 |
+
"model": model,
|
| 33 |
+
"route": {},
|
| 34 |
+
"steps": [],
|
| 35 |
+
"answer": "",
|
| 36 |
+
"sources": [],
|
| 37 |
+
"total_ms": 0,
|
| 38 |
+
}
|
| 39 |
+
self._t0 = time.time()
|
| 40 |
+
|
| 41 |
+
def set_route(self, tool: str, args: dict, router: str, ms: int):
|
| 42 |
+
self.d["route"] = {"tool": tool, "args": args, "router": router,
|
| 43 |
+
"latency_ms": ms}
|
| 44 |
+
|
| 45 |
+
def step(self, kind: str, **kw):
|
| 46 |
+
self.d["steps"].append({"type": kind, **kw})
|
| 47 |
+
return self
|
| 48 |
+
|
| 49 |
+
def finish(self, answer: str, sources: list):
|
| 50 |
+
self.d["answer"] = answer
|
| 51 |
+
self.d["sources"] = [s for s in sources if s]
|
| 52 |
+
self.d["total_ms"] = int((time.time() - self._t0) * 1000)
|
| 53 |
+
return self
|
| 54 |
+
|
| 55 |
+
def save(self, path: str = TRACE_PATH):
|
| 56 |
+
try:
|
| 57 |
+
with open(path, "a", encoding="utf-8") as f:
|
| 58 |
+
f.write(json.dumps(self.d, ensure_ascii=False) + "\n")
|
| 59 |
+
except Exception:
|
| 60 |
+
pass
|
| 61 |
+
return self
|
| 62 |
+
|
| 63 |
+
def to_markdown(self) -> str:
|
| 64 |
+
r = self.d["route"]
|
| 65 |
+
steps = " β ".join(s["type"] for s in self.d["steps"]) or "β"
|
| 66 |
+
src = self.d["sources"][0] if self.d["sources"] else ""
|
| 67 |
+
srcline = f" Β· [source]({src})" if src else ""
|
| 68 |
+
return (
|
| 69 |
+
"\n\n<details><summary>π How I answered (agent trace)</summary>\n\n"
|
| 70 |
+
f"- **Route:** `{r.get('tool','?')}` via {r.get('router','?')} "
|
| 71 |
+
f"({r.get('latency_ms',0)} ms)\n"
|
| 72 |
+
f"- **Steps:** {steps}\n"
|
| 73 |
+
f"- **Model:** {self.d['model']} Β· **Total:** {self.d['total_ms']} ms Β· "
|
| 74 |
+
f"{self.d['ts']}{srcline}\n\n"
|
| 75 |
+
"<sub>Logged openly to `traces.jsonl` for the π‘ Open Trace badge.</sub>\n"
|
| 76 |
+
"</details>"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
def to_dict(self) -> dict:
|
| 80 |
+
return self.d
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def push_to_hub(repo_id: str, token: str | None = None, path: str = TRACE_PATH):
|
| 84 |
+
"""Upload traces.jsonl as a dataset file (optional, needs a token)."""
|
| 85 |
+
from huggingface_hub import HfApi
|
| 86 |
+
HfApi(token=token).upload_file(
|
| 87 |
+
path_or_fileobj=path, path_in_repo="traces.jsonl",
|
| 88 |
+
repo_id=repo_id, repo_type="dataset")
|