GameTheory commited on
Commit
ecf42b5
Β·
verified Β·
1 Parent(s): 205d99f

Sync from GitHub

Browse files
Files changed (17) hide show
  1. FIELD_NOTES.md +99 -0
  2. IDEAS.md +82 -0
  3. LICENSE +21 -0
  4. PLAN.md +100 -0
  5. README.md +74 -29
  6. SUBMISSION.md +122 -0
  7. app.py +572 -735
  8. build_kb.py +303 -0
  9. graph_build.py +209 -0
  10. graph_rag.py +170 -0
  11. index.html +167 -0
  12. library_graph.json +0 -0
  13. library_kb.json +1879 -0
  14. library_sources.py +716 -0
  15. requirements.txt +4 -3
  16. server.py +47 -0
  17. 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 β€” Discovery Assistant
3
- short_description: Ask about Worcestershire's 23 libraries and services.
4
  emoji: πŸ“š
5
- colorFrom: blue
6
- colorTo: yellow
7
  sdk: gradio
8
- sdk_version: 6.16.0
9
  app_file: app.py
10
  pinned: true
11
  license: mit
12
- tags:
13
- - gradio
14
- - library
15
- - community
16
- - rag
17
- - small-model
18
- - backyard-ai
19
- - build-small-hackathon
20
  ---
21
 
22
- # Worcestershire Libraries β€” Discovery Assistant
23
 
24
- > **Build Small Hackathon β€” Backyard AI track**
 
25
 
26
- A RAG-powered assistant for all 23 Worcestershire library branches, 154 mobile library villages, and the full range of library services β€” built on a wiki mined directly from worcestershire.gov.uk.
 
 
 
27
 
28
- Ask about opening hours, the mobile library schedule, children's events, eBooks, room hire, adult learning courses, printing, computer access, or anything else the library offers.
29
 
30
- ## How it works
 
31
 
32
- - **Knowledge base**: 223 wiki pages extracted from worcestershire.gov.uk β€” branches, mobile library routes, service pages, events
33
- - **RAG**: `query_tool.py` routes queries to the right wiki page (branch lookup, village name matching, service keyword routing, keyword fallback)
34
- - **LLM**: `Qwen/Qwen2.5-Coder-32B-Instruct` via HF Inference API (streaming)
35
- - **UI**: Gradio 6 with Worcestershire County Council brand colours
 
 
36
 
37
- ## Space secrets
 
38
 
39
- Set `HF_TOKEN` in Space secrets for the inference client to authenticate.
40
 
41
- ## Running locally
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  ```bash
44
- pip install -r requirements.txt
45
- export HF_TOKEN=your_token
46
- export GRADIO_SERVER_PORT=7860
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 β€” Gradio Agent Interface
4
- Run: python chat_app.py
5
 
6
- Requires: ANTHROPIC_API_KEY env var for LLM responses.
7
- Without it, runs in context-only mode (shows wiki content directly).
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
- import datetime
 
 
11
  import os
12
  import re
13
- import sys
14
- from pathlib import Path
15
 
16
  import gradio as gr
17
- from gradio import ChatMessage
18
 
19
- BASE_DIR = Path(__file__).parent
20
- sys.path.insert(0, str(BASE_DIR))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- from query_tool import LibraryQueryTool
 
 
 
 
 
23
 
24
- # ── LLM setup ────────────────────────────────────────────────────────────────
25
- # Priority: ANTHROPIC_API_KEY β†’ HF_TOKEN (HuggingFace Inference) β†’ context-only
26
 
27
- LLM_AVAILABLE = False
28
- LLM_BACKEND = "none"
29
- _anthropic_client = None
30
- _hf_client = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- _anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "")
33
- _hf_token = os.environ.get("HF_TOKEN", "")
34
 
35
- # Default HF model β€” Qwen2.5-Coder-32B is a top-tier 32B instruct model
36
- HF_MODEL = os.environ.get("HF_MODEL", "Qwen/Qwen2.5-Coder-32B-Instruct")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if not LLM_AVAILABLE and _hf_token:
48
- try:
49
- from huggingface_hub import InferenceClient as _HFClient
50
- _hf_client = _HFClient(token=_hf_token)
51
- LLM_AVAILABLE = True
52
- LLM_BACKEND = "huggingface"
53
- except Exception as e:
54
- print(f"HuggingFace init failed: {e}")
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
- # ── Helpers ───────────────────────────────────────────────────────────────────
156
-
157
- def _blocks_to_str(content) -> str:
158
- """Extract plain text from any Gradio content format (string or list-of-blocks)."""
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
- crawled = datetime.date.fromisoformat(date_str)
202
- age = (datetime.date.today() - crawled).days
203
- if age > 30:
204
- warning = f"\n> ⚠️ *This page is {age} days old β€” please verify before visiting.*"
205
- elif age > 7 and any(w in context.lower() for w in ("event", "activit", "course", "session")):
206
- warning = f"\n> ⚠️ *Events information is {age} days old β€” check the website for current listings.*"
207
- except ValueError:
208
- pass
209
-
210
- return f"\n\n---\n> *Source: [{label}]({url}) β€” as of {date_str}*{warning}"
211
-
212
-
213
- def _history_to_anthropic(history: list[ChatMessage]) -> list[dict]:
214
- """Convert Gradio ChatMessage list to Anthropic messages format.
215
-
216
- Rules:
217
- - Skip the static welcome message (assistant-only opening)
218
- - Anthropic requires messages to start with a user turn
219
- - Keep at most MAX_HISTORY_TURNS full turns to limit token growth
220
- """
221
- MAX_HISTORY_TURNS = 6 # 3 user + 3 assistant = last ~3 exchanges
222
- messages = []
223
- for msg in history:
224
- role = msg.role if hasattr(msg, "role") else msg.get("role", "user")
225
- content = msg.content if hasattr(msg, "content") else msg.get("content", "")
226
- if not isinstance(content, str):
227
- # Gradio 6 may use list-of-blocks format; extract text
228
- if isinstance(content, list):
229
- content = " ".join(b.get("text", "") for b in content if isinstance(b, dict))
230
- else:
231
- content = str(content)
232
- if role in ("user", "assistant") and content.strip():
233
- messages.append({"role": role, "content": content})
234
- # Trim to last N messages, then ensure we start on a user turn
235
- messages = messages[-MAX_HISTORY_TURNS:]
236
- while messages and messages[0]["role"] != "user":
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
- # Normalise: convert any Gradio list-of-blocks format back to plain strings
270
- history = _normalize_history(history)
271
-
272
- # Add user message
273
- history = history + [ChatMessage(role="user", content=message)]
274
- yield history, history, ""
275
 
276
- # Retrieve wiki context
277
- context = _tool.query(message)
278
- source_line = _extract_source(context)
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
- # Build message list for the LLM
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
- if LLM_BACKEND == "anthropic":
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
- err = (
337
- "I encountered an error retrieving that information. "
338
- "Please try again or contact your local library directly.\n\n"
339
- f"*Error: {type(e).__name__}*"
340
- )
341
- history[-1] = ChatMessage(role="assistant", content=err)
342
- yield history, history, ""
343
-
344
-
345
- def inject_question(question: str, history: list[ChatMessage]):
346
- """Inject a quick question into the chat β€” triggers respond() via .then()."""
347
- return question, history
348
-
349
-
350
- # ── CSS ───────────────────────────────────────────────────────────────────────
351
-
352
- WCC_CSS = """
353
- /* ── Worcestershire Libraries brand colours ── */
354
- :root {
355
- color-scheme: light; /* prevent Safari/Firefox dark-mode inversion */
356
- --wcc-navy: #1e3a5f;
357
- --wcc-blue: #1d4ed8;
358
- --wcc-blue-light: #dbeafe;
359
- --wcc-gold: #d97706;
360
- --wcc-gold-light: #fef9ec;
361
- --wcc-green: #166534;
362
- --wcc-bg: #f8fafc;
363
- --wcc-border: #e2e8f0;
364
- --wcc-surface: #ffffff;
365
- --wcc-text: #334155;
366
- --wcc-text-muted: #64748b;
367
- }
368
-
369
- /* Dark mode β€” keep brand integrity while respecting user preference */
370
- @media (prefers-color-scheme: dark) {
371
- :root {
372
- color-scheme: dark;
373
- --wcc-bg: #0f172a;
374
- --wcc-border: #334155;
375
- --wcc-surface: #1e293b;
376
- --wcc-text: #cbd5e1;
377
- --wcc-text-muted: #94a3b8;
378
- --wcc-blue-light: #1e3a5f;
379
- --wcc-gold-light: #1c1405;
380
- --wcc-blue: #60a5fa;
381
- }
382
- }
383
-
384
- /* ── Page background ── */
385
- .gradio-container { background: var(--wcc-bg) !important; color: var(--wcc-text) !important; }
386
-
387
- /* ── Header ── */
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 build_ui() -> gr.Blocks:
562
-
563
- with gr.Blocks(
564
- title="Worcestershire Libraries",
565
- fill_width=True,
566
- ) as demo:
567
-
568
- # ── Header ──────────────────────────────────────────────────────────
569
- gr.HTML("""
570
- <div id="wcc-header">
571
- <h1>πŸ“š Worcestershire Libraries</h1>
572
- <p>Your local library assistant β€” ask about hours, events, the mobile library, printing and more</p>
573
- <span class="badge">23 branches</span>
574
- <span class="badge">Mobile library Β· 154 villages</span>
575
- </div>
576
- """)
577
-
578
- # ── Body: main chat (left) + info panel (right) ──────────────────────
579
- with gr.Row(equal_height=False):
580
-
581
- # ── Left: chat ──────────────────────────────────────────────────
582
- with gr.Column(scale=3):
583
- chatbot = gr.Chatbot(
584
- value=[ChatMessage(
585
- role="assistant",
586
- content=WELCOME_TEXT,
587
- )],
588
- elem_id="wcc-chatbot",
589
- height=500,
590
- show_label=False,
591
- sanitize_html=False,
592
- )
593
-
594
- # Input row
595
- with gr.Row():
596
- msg = gr.Textbox(
597
- placeholder="Ask about opening hours, mobile library, events, membership…",
598
- show_label=False,
599
- scale=7,
600
- autofocus=True,
601
- elem_id="msg-input",
602
- submit_btn=False,
603
- lines=1,
604
- max_lines=4,
605
- )
606
- send_btn = gr.Button("Send ➀", variant="primary", scale=1, elem_id="send-btn")
607
- clear_btn = gr.Button("Clear", variant="secondary", scale=1, elem_id="clear-btn")
608
-
609
- # Status banner
610
- if not LLM_AVAILABLE:
611
- gr.HTML("""
612
- <div id="no-llm-notice">
613
- ⚠️ <strong>AI assistant not available</strong> β€” set ANTHROPIC_API_KEY or HF_TOKEN.
614
- Showing library knowledge base content directly.
615
- </div>
616
- """)
617
- elif LLM_BACKEND == "huggingface":
618
- gr.HTML("""
619
- <div id="no-llm-notice" style="background:#f0fdf4;border-color:#86efac;color:#166534;">
620
- βœ“ <strong>AI assistant active</strong>
621
- </div>
622
- """)
623
-
624
- # Quick question buttons
625
- gr.Markdown("**Quick questions:**", container=False)
626
- with gr.Row(elem_id="quick-buttons"):
627
- quick_btns = []
628
- for label, question in QUICK_QUESTIONS:
629
- btn = gr.Button(label, elem_classes=["quick-q"], size="sm", variant="secondary")
630
- quick_btns.append((btn, question))
631
-
632
- # ── Right: topic navigator panel ─────────────────────────��───────
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
- &nbsp;Β·&nbsp;
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
- &nbsp;Β·&nbsp; Wiki last updated: {datetime.date.today().strftime("%-d %B %Y")}
680
- &nbsp;Β·&nbsp; 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
- if LLM_BACKEND == "anthropic":
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 &amp; 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,"&lt;"));
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.24.0
2
- anthropic>=0.25.0
3
- PyYAML>=6.0
 
 
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")