""" NurseLex — Legal Literacy Agent for All Nurses and Nursing Students Architecture: 1. Local legislation.parquet — 219K health/social care Acts & SIs for browsing 2. cached_legislation.py — 1,128 sections loaded from nursing_sections.json 3. Gemini Flash REST API — Plain English explanations (with retry logic) """ import os import asyncio import httpx import logging import pandas as pd import gradio as gr from cached_legislation import search_cached from local_search import search_scenarios_locally logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # --- Load local legislation index --- PARQUET_PATH = os.path.join(os.path.dirname(__file__), "legislation.parquet") try: LEG_DF = pd.read_parquet(PARQUET_PATH) logger.info(f"Loaded {len(LEG_DF)} legislation entries from parquet") except Exception as e: logger.warning(f"Could not load parquet: {e}") LEG_DF = pd.DataFrame() # --- Key nursing legislation IDs --- NURSING_ACTS = { "Mental Health Act 1983": "ukpga/1983/20", "Mental Capacity Act 2005": "ukpga/2005/9", "Care Act 2014": "ukpga/2014/23", "Human Rights Act 1998": "ukpga/1998/42", "Equality Act 2010": "ukpga/2010/15", "Health and Social Care Act 2012": "ukpga/2012/7", "Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27", "Autism Act 2009": "ukpga/2009/15", "Children Act 1989": "ukpga/1989/41", "Children Act 2004": "ukpga/2004/31", "Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47", "Health and Care Act 2022": "ukpga/2022/31", } REVERSE_ACTS = {v: k for k, v in NURSING_ACTS.items()} # --- Gemini REST API --- GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta/models" GEMINI_MODELS = ["gemini-2.0-flash-lite", "gemini-2.0-flash"] SYSTEM_PROMPT = """You are NurseLex, a legal literacy assistant for all UK nurses and nursing students. Your role: 1. Answer legal questions using ONLY the legislation text provided in the context. 2. Explain the law in clear, plain English suitable for all nurses and nursing students. 3. Always cite the specific Act, section number, and year. 4. If the context doesn't contain enough information, say so clearly. 5. Add practical nursing implications (e.g., "In practice, this means..."). 6. Include professional reminders (e.g., NMC Code, duty of care). Disclaimers to include: - "This is for educational purposes only — always consult your trust's legal team for specific cases." - "This reflects the legislation as written — local trust policies may add additional requirements." Format with clear headings, bullet points, and bold key terms.""" QUICK_QUESTIONS = [ "What is Section 5(4) of the Mental Health Act and when can a nurse use it?", "What does the Mental Capacity Act say about best interests decisions?", "When can a patient be detained under Section 2 vs Section 3?", "What are the legal requirements for using restraint?", "What does Section 117 aftercare mean and who is entitled?", "What are a nurse's legal duties under the Care Act 2014 for safeguarding?", "What is Deprivation of Liberty and when do DoLS apply?", "What rights does a patient have under Section 136?", ] async def call_gemini(prompt: str) -> str: """Call Gemini via REST API with retry logic and model fallback.""" if not GEMINI_API_KEY: return "" payload = { "system_instruction": {"parts": [{"text": SYSTEM_PROMPT}]}, "contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"temperature": 0.3, "maxOutputTokens": 2048}, } async with httpx.AsyncClient(timeout=60.0) as client: for model in GEMINI_MODELS: url = f"{GEMINI_BASE}/{model}:generateContent?key={GEMINI_API_KEY}" for attempt in range(3): try: resp = await client.post(url, json=payload) if resp.status_code == 429: wait = 2 ** (attempt + 1) logger.warning(f"Rate limited ({model}), retrying in {wait}s") await asyncio.sleep(wait) continue resp.raise_for_status() data = resp.json() return data["candidates"][0]["content"]["parts"][0]["text"] except httpx.HTTPStatusError as e: if e.response.status_code == 429: wait = 2 ** (attempt + 1) logger.warning(f"Rate limited ({model}), retrying in {wait}s") await asyncio.sleep(wait) continue logger.error(f"Gemini API error ({model}): {e.response.status_code}") break except Exception as e: logger.error(f"Gemini error ({model}): {type(e).__name__}") break logger.info(f"Model {model} exhausted, trying next...") logger.error("All Gemini models failed") return "" def search_legislation_index(query: str, max_results: int = 10) -> pd.DataFrame: """Search the full legislation index parquet by title.""" if LEG_DF.empty: return pd.DataFrame() mask = LEG_DF["title"].str.contains(query, case=False, na=False) results = LEG_DF[mask].sort_values("year", ascending=False).head(max_results) return results async def query_and_respond(user_question: str, history: list) -> str: """Main RAG pipeline: local cached sections (1,128) + Gemini explanation.""" if not user_question.strip(): return "Please enter a question about UK healthcare legislation." # Step 1: Search local legislation sections sections = search_cached(user_question, max_results=5) logger.info(f"Local search returned {len(sections)} sections for: {user_question[:60]}") # Step 2: Search parquet index for related Acts related_acts = search_legislation_index(user_question, max_results=5) # Step 3: Build context context_parts = [] for section in sections: title = section.get("title", "Untitled") text = section.get("text", "") leg_id = section.get("legislation_id", "") num = section.get("number", "") context_parts.append(f"### {title}\n**Source:** {leg_id}, Section {num}\n\n{text}\n") context = "\n---\n".join(context_parts) if context_parts else "No matching legislation sections found in cache." # Step 4: Generate Gemini response prompt = f"## Nurse's Question\n{user_question}\n\n## Relevant UK Legislation\n{context}\n\nPlease answer the nurse's question using the legislation above." answer = await call_gemini(prompt) if not answer: # Fallback: show raw legislation if Gemini fails or is missing key answer = _build_fallback(user_question, sections) if not GEMINI_API_KEY: answer += "\n\n⚠️ *Set `GEMINI_API_KEY` in Space secrets for AI-powered plain English explanations.*" elif "rate limit" in answer.lower(): answer += "\n\n⚠️ *Gemini is currently rate limited, falling back to raw legislation.*" # Add source citations source_acts = set() for s in sections: leg_id = s.get("legislation_id", "") if leg_id: source_acts.add(leg_id) if source_acts: answer += "\n\n---\n📚 **Sources:** " answer += " | ".join(f"[{sid}](https://www.legislation.gov.uk/id/{sid})" for sid in sorted(source_acts)) # Add related Acts from parquet if not related_acts.empty: answer += "\n\n📖 **Related legislation:** " act_links = [] for _, row in related_acts.head(3).iterrows(): uri = row.get("uri", "") title = row.get("title", "") if uri and title: act_links.append(f"[{title}]({uri})") if act_links: answer += " | ".join(act_links) answer += "\n\n🏛️ *Data from [legislation.gov.uk](https://www.legislation.gov.uk/) — Crown Copyright, OGL v3.0*" return answer def _build_fallback(question: str, sections: list) -> str: """Show raw legislation without LLM.""" response = f"## Legislation relevant to: *{question}*\n\n" if not sections: response += ( "No matching sections found in cache. Try searching the full **Browse Legislation** tab for the Act title, or try specific terms like:\n" "- **\"Section 5(4)\"** or **\"nurse holding power\"**\n" "- **\"best interests\"** or **\"capacity\"**\n" "- **\"safeguarding\"** or **\"Section 42\"**\n" "- **\"Section 136\"** or **\"place of safety\"**\n" ) return response for i, section in enumerate(sections[:5], 1): title = section.get("title", "Untitled") text = section.get("text", "No text available") leg_id = section.get("legislation_id", "") num = section.get("number", "") uri = section.get("uri", "") response += f"### {i}. {title}\n" response += f"**Act:** `{leg_id}` | **Section:** {num}\n\n" response += f"{text}\n\n" if uri: response += f"🔗 [View on legislation.gov.uk]({uri})\n\n" response += "---\n\n" return response async def section_lookup(act_name: str, section_input: str) -> str: """Look up sections from cached legislation.""" legislation_id = NURSING_ACTS.get(act_name) if not legislation_id: return f"❌ Act not found in NurseLex." cache_query = f"{act_name} section {section_input}" if section_input.strip() else act_name sections = search_cached(cache_query, max_results=10) sections = [s for s in sections if s.get("legislation_id") == legislation_id] if section_input.strip() and sections: try: target_num = int(section_input.strip().replace("Section ", "").replace("s.", "").replace("S.", "")) matching = [s for s in sections if s.get("number") == target_num] if matching: sections = matching except ValueError: pass if not sections: return ( f"⏳ Section not found in cache for **{act_name}**.\n\n" f"Try the **Chat tab** for a broader search, or visit " f"[legislation.gov.uk](https://www.legislation.gov.uk/id/{legislation_id}) directly." ) result = f"## {act_name}\n\n" for section in sections[:5]: title = section.get("title", "Untitled") text = section.get("text", "No text") num = section.get("number", "") uri = section.get("uri", "") result += f"### Section {num}: {title}\n\n{text}\n\n" if uri: result += f"🔗 [View on legislation.gov.uk]({uri})\n\n" result += "---\n\n" result += "\n🏛️ *Crown Copyright, OGL v3.0*" return result async def fetch_explanatory_note(act_name: str, section_input: str) -> str: """Dynamically fetch Explanatory Notes from the i.AI Lex API.""" if not section_input.strip(): return "Please specify a section number to view its Explanatory Note." try: # Extract the digits section_number = "".join([c for c in section_input if c.isdigit()]) if not section_number: return "Please enter a valid section number." url = 'https://lex.lab.i.ai.gov.uk/explanatory_note/section/search' payload = { 'query': f'"{act_name}" Section {section_number}', 'limit': 5 } async with httpx.AsyncClient() as client: r = await client.post(url, json=payload, timeout=10.0) if r.status_code == 200: data = r.json() if isinstance(data, list): parent_id = NURSING_ACTS.get(act_name, "") for note in data: if parent_id and parent_id in note.get('legislation_id', ''): text = note.get('text', '') if text: return f"### Official Explanatory Note\n\n{text}\n\n*Source: i.AI Lex API*" return f"No official Explanatory Note found for {act_name} Section {section_number}.\n\n*(Note: Acts passed prior to 1999 generally do not have Explanatory Notes).*." except httpx.TimeoutException: return "⏳ API Timeout while fetching Explanatory Note." except Exception as e: return f"Error fetching note: {str(e)}" async def scenario_search(scenario_text: str) -> str: """Use local i-dot-ai vector search to map a clinical scenario to legal sections.""" if not scenario_text.strip(): return "Please describe a clinical scenario." try: results = search_scenarios_locally(scenario_text, top_k=5) if not results: return "No matching legislation found for this scenario in the local cache." result = f"## ⚖️ Probable Legislation Matches for:\n*{scenario_text}*\n\n" for i, n in enumerate(results, 1): leg_id = n.get("legislation_id", "") # 1. Use the act_name from known mapping act_name = "" for known_id, known_name in REVERSE_ACTS.items(): if known_id in leg_id: act_name = known_name break # 2. Final fallback: extract from the legislation_id URL if not act_name: act_name = leg_id.split("/id/")[-1] if "/id/" in leg_id else leg_id or "Legislation" sec_num = n.get("number", "??") title = n.get("title", "Untitled Section") text = n.get("text", "") uri = n.get("uri", f"https://www.legislation.gov.uk/id/{leg_id}/section/{sec_num}") score = n.get("score", 0.0) result += f"### {i}. {act_name} — Section {sec_num}: {title} (Match Score: {score:.2f})\n" result += f"{text[:800]}...\n\n" result += f"🔗 [Read full text on legislation.gov.uk]({uri})\n\n---\n\n" return result except Exception as e: return f"Error during local scenario search: {str(e)}" def browse_legislation(search_term: str, act_type: str) -> str: """Browse the legislation index from the parquet file.""" if LEG_DF.empty: return "⚠️ Legislation index not loaded." filtered = LEG_DF.copy() if act_type != "All": type_map = {"Primary Acts": "ukpga", "Statutory Instruments": "uksi", "Scottish SIs": "ssi", "NI SRs": "nisr", "Welsh SIs": "wsi"} if act_type in type_map: filtered = filtered[filtered["type"] == type_map[act_type]] if search_term.strip(): filtered = filtered[filtered["title"].str.contains(search_term, case=False, na=False)] filtered = filtered.sort_values("year", ascending=False).head(50) if filtered.empty: return f"No legislation found matching '{search_term}'." result = f"## 📖 Legislation Index ({len(filtered)} results)\n\n| Year | Title | Type |\n|---|---|---|\n" for _, row in filtered.iterrows(): year = row.get("year", "—") title = row.get("title", "Untitled") uri = row.get("uri", "") leg_type = row.get("type", "") title_link = f"[{title}]({uri})" if uri else title result += f"| {year} | {title_link} | {leg_type} |\n" result += f"\n\n*Showing top 50 of {len(LEG_DF)} health & social care entries — {len(LEG_DF[LEG_DF['type']=='ukpga'])} Primary Acts*" result += "\n\n🏛️ *Data from i.AI Lex bulk downloads — Crown Copyright, OGL v3.0*" return result # --- Gradio UI --- THEME = gr.themes.Soft( primary_hue="indigo", secondary_hue="violet", neutral_hue="slate", font=gr.themes.GoogleFont("Inter"), ) CSS = """ .gradio-container { max-width: 960px !important; } .header-banner { background: linear-gradient(135deg, #312e81 0%, #4338ca 50%, #6366f1 100%); border-radius: 16px; padding: 28px 32px; margin-bottom: 16px; color: white; } .header-banner h1 { color: white; font-size: 2em; margin: 0 0 8px 0; } .header-banner p { color: #c7d2fe; margin: 0; font-size: 1.05em; } .disclaimer-box { background: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; font-size: 0.9em; color: #92400e; } footer { display: none !important; } """ with gr.Blocks(theme=THEME, css=CSS, title="NurseLex — UK Law for All Nurses") as app: gr.HTML("""
""") gr.HTML("""