diff --git a/app.py b/app.py index 626156ee945b36a91169942fe07f922c4547334c..6ee3817b421ede56c3166eb43bcb5cb6af7d47ac 100644 --- a/app.py +++ b/app.py @@ -1,192 +1,695 @@ +#!/usr/bin/env python3 +""" +Worcestershire Libraries — Gradio Agent Interface +Run: python chat_app.py + +Requires: ANTHROPIC_API_KEY env var for LLM responses. +Without it, runs in context-only mode (shows wiki content directly). +""" + +import datetime import os +import re +import sys +from pathlib import Path + import gradio as gr -from huggingface_hub import InferenceClient -from knowledge_base import retrieve, format_context +from gradio import ChatMessage + +BASE_DIR = Path(__file__).parent +sys.path.insert(0, str(BASE_DIR)) + +from query_tool import LibraryQueryTool + +# ── LLM setup ──────────────────────────────────────────────────────────────── +# Priority: ANTHROPIC_API_KEY → HF_TOKEN (HuggingFace Inference) → context-only -HF_TOKEN = os.environ.get("HF_TOKEN") -MODEL = "Qwen/Qwen2.5-7B-Instruct" +LLM_AVAILABLE = False +LLM_BACKEND = "none" +_anthropic_client = None +_hf_client = None -client = InferenceClient(model=MODEL, token=HF_TOKEN) +_anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "") +_hf_token = os.environ.get("HF_TOKEN", "") -SYSTEM_PROMPT = """\ -You are a friendly, knowledgeable assistant for The Hive — Worcester's public library, \ -located at Sawmill Walk, The Butts, Worcester WR1 3PD. Open 8:30am–10pm every day. \ -Your job is to help Worcester residents discover library services that match their specific needs. +# Default HF model — Qwen2.5-Coder-32B is a top-tier 32B instruct model +HF_MODEL = os.environ.get("HF_MODEL", "Qwen/Qwen2.5-Coder-32B-Instruct") -Use ONLY the library information provided below to answer questions. \ -Be specific — mention actual service names, how to access them, and what they cost. \ -If something isn't covered in the provided information, say so honestly rather than guessing. \ -Keep answers warm, conversational, and under 180 words unless more detail is clearly needed. \ -Always close by suggesting the person call 01905 822866, email worcesterlib@worcestershire.gov.uk, \ -or visit thehiveworcester.org to confirm details or book. +if _anthropic_key: + try: + import anthropic as _anthropic + _anthropic_client = _anthropic.Anthropic(api_key=_anthropic_key) + LLM_AVAILABLE = True + LLM_BACKEND = "anthropic" + except Exception as e: + print(f"Anthropic init failed: {e}") -Relevant library services: -{context} +if not LLM_AVAILABLE and _hf_token: + try: + from huggingface_hub import InferenceClient as _HFClient + _hf_client = _HFClient(token=_hf_token) + LLM_AVAILABLE = True + LLM_BACKEND = "huggingface" + except Exception as e: + print(f"HuggingFace init failed: {e}") + +# ── Startup singletons ─────────────────────────────────────────────────────── + +WIKI_DIR = BASE_DIR / "wiki" +_tool = LibraryQueryTool(WIKI_DIR) + +_ctx_file = BASE_DIR / "AGENT_CONTEXT.md" +AGENT_CONTEXT = _ctx_file.read_text(encoding="utf-8") if _ctx_file.exists() else "" + +SYSTEM_PROMPT = f"""You are the Worcestershire Libraries virtual assistant. +You help members of the public with questions about libraries across Worcestershire — +branches, opening hours, the mobile library, events, courses, services, and membership. + +## Your domain knowledge +{AGENT_CONTEXT} + +## Rules +- Always use the search tool before answering factual questions. Never guess hours, addresses, or emails. +- Be warm, concise and helpful. Use bullet points for hours/facilities lists. +- For events and activities: describe the TYPES of regular activities shown in the wiki context + (e.g. Storytime, Bounce & Rhyme, reading groups, coding clubs, adult learning) even when + specific upcoming dates are not listed. Always include the events page link for current schedules: + https://www.worcestershire.gov.uk/council-services/libraries/library-events-and-activities +- After answering, include the source URL and date in this format: + *Source: [page title](URL) — as of YYYY-MM-DD* +- If the source is more than 7 days old and the topic is events or opening hours, add: + ⚠️ *Information may be out of date — check the website before visiting.* +- If you cannot find the answer, say so honestly and give: + https://www.worcestershire.gov.uk/council-services/libraries +- Today is {datetime.date.today().isoformat()}. """ -# Seeded opening message — removes blank-screen anxiety and frames the tool -OPENING_MESSAGE = [ - { - "role": "assistant", - "content": ( - "Hi! I'm here to help you discover what The Hive offers — Worcester's free public library on Sawmill Walk. " - "Tell me what you're working on, struggling with, or need help with — or just ask what we have. " - "Most people are genuinely surprised by what's available here for free." - ), - } -] +# ── Content definitions ─────────────────────────────────────────────────────── -EXAMPLES_LEARNING = [ - "I'm starting a business and need help with research", - "I want to trace my family history", - "Can I borrow books without visiting the library?", - "What can I watch or stream with my library card?", +QUICK_QUESTIONS = [ + ("🕐 Branch hours", "What are the opening hours for Bromsgrove Library?"), + ("📍 Find a branch", "What library branches are there in Worcestershire?"), + ("🚐 Mobile library", "When does the mobile library visit Kempsey?"), + ("🏫 Book a room", "How do I book a meeting room at the library?"), + ("👶 Kids activities", "What children's activities and events does the library offer?"), + ("📚 Join the library", "How do I join the library and what are the benefits?"), ] -EXAMPLES_FAMILY = [ - "My kids need something to do over the summer", - "Are there any groups or activities for older people?", - "I have dementia in my family — can the library help?", +DID_YOU_KNOW = [ + ("📱", "Free eBooks & audiobooks", + "Borrow thousands of digital books, audiobooks and magazines from home — free with your library card. No late fees ever.", + "How do I borrow eBooks and audiobooks for free with my library card?"), + ("💼", "Free business support (BIPC)", + "The Business & IP Centre at Bromsgrove Library and The Hive gives you free access to market research databases worth thousands of pounds.", + "What free business support does the library offer through BIPC?"), + ("🏠", "Books delivered to your door", + "Can't get to a branch? The Home Library Service delivers books directly to you — completely free of charge.", + "Can the library deliver books to my home?"), + ("🌡️", "Warm Welcome — no card needed", + "All our libraries are Warm Welcome spaces. Drop in any time to sit, read and be warm — no library card or reason required.", + "What is the Warm Welcome programme at Worcestershire Libraries?"), + ("💻", "Free computers & Wi-Fi", + "Every branch has free public computers with internet access and free Wi-Fi. Book a session or just drop in.", + "Can I use a computer at the library for free?"), + ("🎓", "Adult learning courses", + "Free and low-cost courses from digital skills to English language, numeracy and employability — running in libraries across the county.", + "What adult learning courses are available at Worcestershire Libraries?"), + ("🧠", "Memories & Me — dementia support", + "Free reminiscence activities and support for people living with dementia and their carers, at branches across Worcestershire.", + "Tell me about the Memories and Me dementia support programme at the library."), + ("🏛️", "900 years of history at The Hive", + "The Worcestershire Archive & Archaeology Service holds county records going back 900 years — ideal for genealogy and local history research.", + "What archive and local history resources are available at The Hive?"), ] -EXAMPLES_ACCESS = [ - "I need a room for a community meeting", - "I'm struggling with heating bills this winter", - "I need to print and scan some documents", -] +WELCOME_TEXT = """## 👋 Welcome to Worcestershire Libraries -CSS = """ -#header { - text-align: center; - padding: 1.5rem 1rem 0.5rem; - border-bottom: 3px solid #1a3a5c; - margin-bottom: 1rem; +I can help you with anything about your local library service: + +- **Branch hours & locations** — all 23 libraries +- **Mobile library** — schedules for 154 villages +- **Events** — children's sessions, reading groups, adult learning +- **Services** — eBooks, room hire, computers, printing +- **Membership** — joining, renewals, fees + +Use the quick buttons below, click a card on the right, or just ask me anything. + +--- +*Information sourced from worcestershire.gov.uk · Always verify hours before visiting* +""" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _blocks_to_str(content) -> str: + """Extract plain text from any Gradio content format (string or list-of-blocks).""" + if isinstance(content, str): + return content + if isinstance(content, list): + return " ".join(b.get("text", "") for b in content if isinstance(b, dict)) + return str(content) if content else "" + + +def _normalize_history(history: list) -> list[ChatMessage]: + """Convert any Gradio history format to clean ChatMessage objects with string content. + + Gradio 6 serialises ChatMessage.content as list-of-blocks when reading back + the chatbot state. This strips that back to plain text so it never leaks into + the chat display or the API call. + """ + clean: list[ChatMessage] = [] + for msg in history or []: + if hasattr(msg, "role"): + role, content = msg.role, _blocks_to_str(msg.content) + elif isinstance(msg, dict): + role, content = msg.get("role", "user"), _blocks_to_str(msg.get("content", "")) + else: + continue + if role in ("user", "assistant") and content.strip(): + clean.append(ChatMessage(role=role, content=content)) + return clean + + +def _extract_source(context: str) -> str: + """Pull source URL and crawl date out of query_tool output, formatted for display.""" + url_match = re.search(r'\*\*Source:\*\* \[(https?://[^\]]+)\]\([^\)]+\)', context) + date_match = re.search(r'Last updated from website: (\d{4}-\d{2}-\d{2})', context) + + if not url_match: + return "" + + url = url_match.group(1) + label = url.split("/")[-1].replace("-", " ").replace("_", " ").title() or "Library website" + date_str = date_match.group(1) if date_match else "unknown date" + + # Freshness warning + warning = "" + try: + crawled = datetime.date.fromisoformat(date_str) + age = (datetime.date.today() - crawled).days + if age > 30: + warning = f"\n> ⚠️ *This page is {age} days old — please verify before visiting.*" + elif age > 7 and any(w in context.lower() for w in ("event", "activit", "course", "session")): + warning = f"\n> ⚠️ *Events information is {age} days old — check the website for current listings.*" + except ValueError: + pass + + return f"\n\n---\n> *Source: [{label}]({url}) — as of {date_str}*{warning}" + + +def _history_to_anthropic(history: list[ChatMessage]) -> list[dict]: + """Convert Gradio ChatMessage list to Anthropic messages format. + + Rules: + - Skip the static welcome message (assistant-only opening) + - Anthropic requires messages to start with a user turn + - Keep at most MAX_HISTORY_TURNS full turns to limit token growth + """ + MAX_HISTORY_TURNS = 6 # 3 user + 3 assistant = last ~3 exchanges + messages = [] + for msg in history: + role = msg.role if hasattr(msg, "role") else msg.get("role", "user") + content = msg.content if hasattr(msg, "content") else msg.get("content", "") + if not isinstance(content, str): + # Gradio 6 may use list-of-blocks format; extract text + if isinstance(content, list): + content = " ".join(b.get("text", "") for b in content if isinstance(b, dict)) + else: + content = str(content) + if role in ("user", "assistant") and content.strip(): + messages.append({"role": role, "content": content}) + # Trim to last N messages, then ensure we start on a user turn + messages = messages[-MAX_HISTORY_TURNS:] + while messages and messages[0]["role"] != "user": + messages.pop(0) + return messages + + +def _no_llm_response(context: str, question: str) -> str: + """Format a context-only response when no API key is available.""" + if not context or "no relevant content" in context.lower(): + return ( + "> *AI assistant not available — showing direct wiki search result.*\n\n" + "I couldn't find specific information about that in the library wiki.\n\n" + "Please contact your local library or visit " + "[worcestershire.gov.uk/libraries](https://www.worcestershire.gov.uk/council-services/libraries)." + ) + return ( + "> *AI assistant not available — showing library knowledge base content directly.*\n\n" + + context + ) + + +# ── Core chat handler ───────────────────────────────────────────────────────── + +def respond(message: str, history: list): + """Generator: process a chat message and stream the response. + + Yields 3-tuples: (chatbot_value, history_state_value, msg_clear). + Owning history_state directly avoids the lambda-h-copy pattern that lets + Gradio's internal list-of-blocks serialisation leak into the display. + """ + if not message.strip(): + yield history, history, "" + return + + # Normalise: convert any Gradio list-of-blocks format back to plain strings + history = _normalize_history(history) + + # Add user message + history = history + [ChatMessage(role="user", content=message)] + yield history, history, "" + + # Retrieve wiki context + context = _tool.query(message) + source_line = _extract_source(context) + + if not LLM_AVAILABLE: + reply = _no_llm_response(context, message) + result = history + [ChatMessage(role="assistant", content=reply)] + yield result, result, "" + return + + # Build message list for the LLM + api_messages = _history_to_anthropic(history[:-1]) + + # Inject retrieved context into the user turn + if context and "no relevant content" not in context.lower(): + user_content = ( + f"{message}\n\n" + f"---\nRelevant library information from the wiki:\n\n{context}" + ) + else: + user_content = message + + api_messages.append({"role": "user", "content": user_content}) + + # Stream response + accumulated = "" + history = history + [ChatMessage(role="assistant", content="")] + + try: + if LLM_BACKEND == "anthropic": + with _anthropic_client.messages.stream( + model="claude-haiku-4-5-20251001", + max_tokens=700, + system=SYSTEM_PROMPT, + messages=api_messages, + ) as stream: + for text in stream.text_stream: + accumulated += text + history[-1] = ChatMessage(role="assistant", content=accumulated + " ▌") + yield history, history, "" + + elif LLM_BACKEND == "huggingface": + hf_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + api_messages + stream = _hf_client.chat_completion( + model=HF_MODEL, + messages=hf_messages, + max_tokens=700, + stream=True, + ) + for chunk in stream: + delta = chunk.choices[0].delta.content or "" + accumulated += delta + history[-1] = ChatMessage(role="assistant", content=accumulated + " ▌") + yield history, history, "" + + # Final — remove streaming cursor, append source line + history[-1] = ChatMessage(role="assistant", content=accumulated.rstrip() + source_line) + yield history, history, "" + + except Exception as e: + err = ( + "I encountered an error retrieving that information. " + "Please try again or contact your local library directly.\n\n" + f"*Error: {type(e).__name__}*" + ) + history[-1] = ChatMessage(role="assistant", content=err) + yield history, history, "" + + +def inject_question(question: str, history: list[ChatMessage]): + """Inject a quick question into the chat — triggers respond() via .then().""" + return question, history + + +# ── CSS ─────────────────────────────────────────────────────────────────────── + +WCC_CSS = """ +/* ── Worcestershire Libraries brand colours ── */ +:root { + --wcc-navy: #1e3a5f; + --wcc-blue: #1d4ed8; + --wcc-blue-light: #dbeafe; + --wcc-gold: #d97706; + --wcc-gold-light: #fef9ec; + --wcc-green: #166534; + --wcc-bg: #f8fafc; + --wcc-border: #e2e8f0; } -#header h1 { - color: #1a3a5c; + +/* ── Page background ── */ +.gradio-container { background: var(--wcc-bg) !important; } + +/* ── Header ── */ +#wcc-header { + background: linear-gradient(135deg, var(--wcc-navy) 0%, #1e4db7 100%); + border-bottom: 4px solid var(--wcc-gold); + border-radius: 12px; + padding: 20px 28px; + margin-bottom: 4px; + color: white; +} +#wcc-header h1 { + margin: 0 0 4px 0; font-size: 1.6rem; - margin: 0 0 0.25rem; + font-weight: 700; + letter-spacing: -0.02em; + color: white !important; } -#header p { - color: #4a5568; - font-size: 0.95rem; +#wcc-header p { margin: 0; + font-size: 0.9rem; + opacity: 0.85; + color: white !important; +} +#wcc-header .badge { + display: inline-block; + background: rgba(255,255,255,0.15); + border-radius: 20px; + padding: 2px 10px; + margin: 6px 4px 0 0; + font-size: 0.78rem; + letter-spacing: 0.01em; +} + +/* ── Quick question pill buttons ── */ +.quick-q button { + background: var(--wcc-blue-light) !important; + color: var(--wcc-navy) !important; + border: 1.5px solid #93c5fd !important; + border-radius: 20px !important; + font-size: 0.8rem !important; + font-weight: 600 !important; + padding: 6px 14px !important; + white-space: nowrap !important; + transition: all 0.15s ease !important; +} +.quick-q button:hover { + background: var(--wcc-blue) !important; + color: white !important; + border-color: var(--wcc-blue) !important; + transform: translateY(-1px); + box-shadow: 0 3px 8px rgba(29,78,216,0.25); +} + +/* ── Did you know cards ── */ +.dyk-card button { + background: white !important; + border: 1px solid var(--wcc-border) !important; + border-radius: 10px !important; + text-align: left !important; + padding: 10px 12px !important; + font-size: 0.82rem !important; + line-height: 1.4 !important; + color: #334155 !important; + transition: all 0.15s ease !important; + margin-bottom: 6px !important; + width: 100% !important; } -#discover-row { - display: flex; - justify-content: center; - margin: 0.75rem 0 1rem; +.dyk-card button:hover { + background: var(--wcc-gold-light) !important; + border-color: var(--wcc-gold) !important; + box-shadow: 0 3px 10px rgba(217,119,6,0.15) !important; + transform: translateX(2px); } -#chatbot { - border: 1px solid #cbd5e0; + +/* ── No-LLM notice banner ── */ +#no-llm-notice { + background: #fffbeb; + border: 1px solid #fde68a; border-radius: 8px; + padding: 8px 14px; + font-size: 0.82rem; + color: #92400e; + margin-top: 4px; +} + +/* ── Chatbot ── */ +#wcc-chatbot { + border: 1px solid var(--wcc-border) !important; + border-radius: 12px !important; + background: white !important; + min-height: 460px; +} +#wcc-chatbot .message.bot { background: #eff6ff !important; } +#wcc-chatbot .message.user { background: var(--wcc-blue-light) !important; } + +/* ── Input area ── */ +#msg-input textarea { + border-radius: 10px !important; + border: 1.5px solid var(--wcc-border) !important; + font-size: 0.95rem !important; +} +#msg-input textarea:focus { + border-color: var(--wcc-blue) !important; + box-shadow: 0 0 0 3px rgba(29,78,216,0.1) !important; +} +#send-btn button { + background: var(--wcc-blue) !important; + border-radius: 10px !important; + font-weight: 700 !important; + min-width: 80px; } -#footer { +#clear-btn button { + border-radius: 10px !important; + color: #64748b !important; +} + +/* ── Right panel ── */ +#right-panel { padding-left: 12px; } +#right-panel .prose { font-size: 0.88rem; } + +/* ── Footer ── */ +#wcc-footer { + background: white; + border: 1px solid var(--wcc-border); + border-radius: 10px; + padding: 10px 18px; + font-size: 0.78rem; + color: #64748b; + margin-top: 8px; text-align: center; - color: #718096; - font-size: 0.8rem; - padding: 0.75rem; - border-top: 1px solid #e2e8f0; - margin-top: 0.5rem; +} +#wcc-footer a { color: var(--wcc-blue); } + +/* ── Mobile ── */ +@media (max-width: 768px) { + #right-panel { display: none; } + #wcc-header h1 { font-size: 1.2rem; } } """ +# ── UI builder ─────────────────────────────────────────────────────────────── + +WCC_THEME = gr.themes.Soft( + primary_hue=gr.themes.colors.blue, + secondary_hue=gr.themes.colors.amber, + neutral_hue=gr.themes.colors.slate, + font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], +) + -def chat(message: str, history: list) -> str: - context = format_context(retrieve(message, top_k=4)) - system = SYSTEM_PROMPT.format(context=context) - - messages = [{"role": "system", "content": system}] - for turn in history: - messages.append({"role": turn["role"], "content": turn["content"]}) - messages.append({"role": "user", "content": message}) - - response = "" - for chunk in client.chat_completion( - messages=messages, - max_tokens=400, - stream=True, - temperature=0.65, - ): - token = chunk.choices[0].delta.content or "" - response += token - yield response - - -with gr.Blocks(title="The Hive Worcester — What can we do for you?") as demo: - gr.HTML( - """ -