Spaces:
Sleeping
Sleeping
Commit ·
19a3093
0
Parent(s):
feat: complete local embedding search with i-dot-ai HF model
Browse files- .gitattributes +35 -0
- MCP_SETUP.md +137 -0
- README.md +118 -0
- __pycache__/app.cpython-314.pyc +0 -0
- __pycache__/lex_client.cpython-314.pyc +0 -0
- __pycache__/mcp_server.cpython-314.pyc +0 -0
- app.py +1142 -0
- cached_legislation.py +233 -0
- download_notes.py +88 -0
- download_sections.py +97 -0
- fetch_schema.py +31 -0
- filter_parquet.py +41 -0
- inspect_parquet.py +22 -0
- legislation.parquet +3 -0
- lex_client.py +383 -0
- local_search.py +96 -0
- mcp_server.py +353 -0
- nursing_legislation.parquet +3 -0
- nursing_sections.json +0 -0
- probe_sections.py +30 -0
- push.log +0 -0
- requirements.txt +13 -0
- test_amendment.py +23 -0
- test_notes.json +56 -0
- test_semantic.py +23 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
MCP_SETUP.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<<<<<<< HEAD
|
| 2 |
+
# 🏥 NurseLex MCP Server Setup Guide
|
| 3 |
+
|
| 4 |
+
Welcome to the **NurseLex MCP Server**! This server uses the **Model Context Protocol (MCP)** to inject the entire i.AI Lex UK legislative database directly into your favorite AI Assistant (like Claude Desktop or Cursor).
|
| 5 |
+
|
| 6 |
+
When connected, your AI assistant gains three powerful real-time tools:
|
| 7 |
+
|
| 8 |
+
1. **`search_local_nursing_cache`**: Instantly retrieves the exact statutory text for 1,128 critical nursing sections (Mental Health Act, Care Act, etc.).
|
| 9 |
+
2. **`get_official_explanatory_note`**: Fetches the official UK Government plain English explainer for a specific Act and section.
|
| 10 |
+
3. **`vector_search_lex_api`**: A semantic search engine that maps clinical, plain-English scenarios (e.g., "patient lacks capacity to consent to treatment") to relevant national laws.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 🛠️ Prerequisites
|
| 15 |
+
|
| 16 |
+
1. You must have **Python 3.10+** installed.
|
| 17 |
+
2. Install the required dependencies:
|
| 18 |
+
```bash
|
| 19 |
+
pip install mcp fastmcp httpx pandas
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## 🔌 Connecting to Claude Desktop
|
| 25 |
+
|
| 26 |
+
To let Claude Desktop use NurseLex as a knowledge tool, you need to edit your `claude_desktop_config.json` file.
|
| 27 |
+
|
| 28 |
+
### 1. Locate your Config File
|
| 29 |
+
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
| 30 |
+
- **Mac:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
| 31 |
+
|
| 32 |
+
### 2. Add the NurseLex Server
|
| 33 |
+
Open the file in a text editor and add the following configuration to your `mcpServers` block.
|
| 34 |
+
|
| 35 |
+
*Make sure to replace `/path/to/NurseLex` with the actual folder path where you downloaded the project!*
|
| 36 |
+
|
| 37 |
+
```json
|
| 38 |
+
{
|
| 39 |
+
"mcpServers": {
|
| 40 |
+
"nurselex": {
|
| 41 |
+
"command": "python",
|
| 42 |
+
"args": [
|
| 43 |
+
"/path/to/NurseLex/mcp_server.py"
|
| 44 |
+
]
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### 3. Restart Claude
|
| 51 |
+
Fully close and restart the Claude Desktop app. You should now see a 🔨 (hammer) icon in the chat bar indicating the tools are loaded!
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## 🖱️ Connecting to Cursor IDE
|
| 56 |
+
|
| 57 |
+
If you are a Nurse Citizen Developer building applications in Cursor, you can add NurseLex to help write legally compliant code!
|
| 58 |
+
|
| 59 |
+
1. Open Cursor Settings (**Cmd + Shift + J** on Mac, **Ctrl + Shift + J** on Windows).
|
| 60 |
+
2. Go to the **Features** -> **MCP** section.
|
| 61 |
+
3. Click **+ Add New MCP Server**.
|
| 62 |
+
4. Set the following:
|
| 63 |
+
- **Name:** `NurseLex`
|
| 64 |
+
- **Type:** `command`
|
| 65 |
+
- **Command:** `python /path/to/NurseLex/mcp_server.py` (Use the absolute path to your folder).
|
| 66 |
+
5. Click **Save** and verify the connection is green.
|
| 67 |
+
|
| 68 |
+
You can now ask Cursor Agent: *"Write a Python function that uses NurseLex to find the legal requirements for Section 136 of the MHA."*
|
| 69 |
+
=======
|
| 70 |
+
# 🏥 NurseLex MCP Server Setup Guide
|
| 71 |
+
|
| 72 |
+
Welcome to the **NurseLex MCP Server**! This server uses the **Model Context Protocol (MCP)** to inject the entire i.AI Lex UK legislative database directly into your favorite AI Assistant (like Claude Desktop or Cursor).
|
| 73 |
+
|
| 74 |
+
When connected, your AI assistant gains three powerful real-time tools:
|
| 75 |
+
|
| 76 |
+
1. **`search_local_nursing_cache`**: Instantly retrieves the exact statutory text for 1,128 critical nursing sections (Mental Health Act, Care Act, etc.).
|
| 77 |
+
2. **`get_official_explanatory_note`**: Fetches the official UK Government plain English explainer for a specific Act and section.
|
| 78 |
+
3. **`vector_search_lex_api`**: A semantic search engine that maps clinical, plain-English scenarios (e.g., "patient lacks capacity to consent to treatment") to relevant national laws.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
## 🛠️ Prerequisites
|
| 83 |
+
|
| 84 |
+
1. You must have **Python 3.10+** installed.
|
| 85 |
+
2. Install the required dependencies:
|
| 86 |
+
```bash
|
| 87 |
+
pip install mcp fastmcp httpx pandas
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## 🔌 Connecting to Claude Desktop
|
| 93 |
+
|
| 94 |
+
To let Claude Desktop use NurseLex as a knowledge tool, you need to edit your `claude_desktop_config.json` file.
|
| 95 |
+
|
| 96 |
+
### 1. Locate your Config File
|
| 97 |
+
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
| 98 |
+
- **Mac:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
| 99 |
+
|
| 100 |
+
### 2. Add the NurseLex Server
|
| 101 |
+
Open the file in a text editor and add the following configuration to your `mcpServers` block.
|
| 102 |
+
|
| 103 |
+
*Make sure to replace `/path/to/NurseLex` with the actual folder path where you downloaded the project!*
|
| 104 |
+
|
| 105 |
+
```json
|
| 106 |
+
{
|
| 107 |
+
"mcpServers": {
|
| 108 |
+
"nurselex": {
|
| 109 |
+
"command": "python",
|
| 110 |
+
"args": [
|
| 111 |
+
"/path/to/NurseLex/mcp_server.py"
|
| 112 |
+
]
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### 3. Restart Claude
|
| 119 |
+
Fully close and restart the Claude Desktop app. You should now see a 🔨 (hammer) icon in the chat bar indicating the tools are loaded!
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 🖱️ Connecting to Cursor IDE
|
| 124 |
+
|
| 125 |
+
If you are a Nurse Citizen Developer building applications in Cursor, you can add NurseLex to help write legally compliant code!
|
| 126 |
+
|
| 127 |
+
1. Open Cursor Settings (**Cmd + Shift + J** on Mac, **Ctrl + Shift + J** on Windows).
|
| 128 |
+
2. Go to the **Features** -> **MCP** section.
|
| 129 |
+
3. Click **+ Add New MCP Server**.
|
| 130 |
+
4. Set the following:
|
| 131 |
+
- **Name:** `NurseLex`
|
| 132 |
+
- **Type:** `command`
|
| 133 |
+
- **Command:** `python /path/to/NurseLex/mcp_server.py` (Use the absolute path to your folder).
|
| 134 |
+
5. Click **Save** and verify the connection is green.
|
| 135 |
+
|
| 136 |
+
You can now ask Cursor Agent: *"Write a Python function that uses NurseLex to find the legal requirements for Section 136 of the MHA."*
|
| 137 |
+
>>>>>>> a4e257b16d56f80612b7c9ac6d2e7c198fef5bb6
|
README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<<<<<<< HEAD
|
| 2 |
+
---
|
| 3 |
+
title: NurseLex
|
| 4 |
+
emoji: 🏛️
|
| 5 |
+
colorFrom: indigo
|
| 6 |
+
colorTo: purple
|
| 7 |
+
sdk: gradio
|
| 8 |
+
sdk_version: "5.50.0"
|
| 9 |
+
app_file: app.py
|
| 10 |
+
pinned: true
|
| 11 |
+
license: mit
|
| 12 |
+
short_description: UK law for mental health & LD nurses
|
| 13 |
+
tags:
|
| 14 |
+
- nursing
|
| 15 |
+
- legal
|
| 16 |
+
- mental-health
|
| 17 |
+
- nhs
|
| 18 |
+
- uk-law
|
| 19 |
+
- lex-api
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
# 🏛️ NurseLex — UK Law for Nurses
|
| 23 |
+
|
| 24 |
+
**Legal literacy for mental health & learning disability nurses** — powered by the **i.AI Lex API**, a custom UK Gov **[fine-tuned embedding model](https://huggingface.co/i-dot-ai/all-miniLM-L6-v2-UKPGA-6k-finetune)**, and Google Gemini Flash.
|
| 25 |
+
|
| 26 |
+
## What It Does
|
| 27 |
+
|
| 28 |
+
NurseLex helps non-acute NHS nursing staff understand UK healthcare legislation by:
|
| 29 |
+
|
| 30 |
+
1. **Searching real UK legislation** via the i.AI Lex API (semantic search across millions of Acts and Statutory Instruments)
|
| 31 |
+
2. **Explaining the law in plain English** using Gemini Flash, tailored for mental health and learning disability nursing
|
| 32 |
+
3. **Citing the exact Act, section, and year** — zero hallucination, grounded in actual statutory text
|
| 33 |
+
|
| 34 |
+
## Key Legislation
|
| 35 |
+
|
| 36 |
+
- Mental Health Act 1983 (Sections 2, 3, 5(4), 17, 117, 135, 136)
|
| 37 |
+
- Mental Capacity Act 2005 (Capacity, Best Interests, DoLS)
|
| 38 |
+
- Care Act 2014 (Safeguarding, Assessments, Advocacy)
|
| 39 |
+
- Human Rights Act 1998 (Articles 5, 8)
|
| 40 |
+
- Equality Act 2010 (Reasonable Adjustments)
|
| 41 |
+
- Mental Health Units (Use of Force) Act 2018
|
| 42 |
+
- Autism Act 2009
|
| 43 |
+
|
| 44 |
+
## Architecture
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
User Query → i.AI UKPGA Embedding Model (Semantic Search) → Local Statutory Cache → Gemini Flash → Plain English + Citations
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
All legislation data comes from the **[i.AI Lex](https://lex.lab.i.ai.gov.uk/)** bulk datasets (Crown Copyright, Open Government Licence v3.0).
|
| 51 |
+
Semantic search capabilities are powered locally using the `i-dot-ai/all-miniLM-L6-v2-UKPGA-6k-finetune` sentence transformer, specifically trained on UK Public General Acts.
|
| 52 |
+
|
| 53 |
+
## Built By
|
| 54 |
+
|
| 55 |
+
**NurseCitizenDeveloper** — NHS Registered Nurse (Learning Disabilities) building open-source AI tools for nursing.
|
| 56 |
+
|
| 57 |
+
- 🌐 [AI Educator Toolkit](https://clinical-quality-intelligence.github.io/nursing-ai-toolkit/)
|
| 58 |
+
- 🤗 [Hugging Face](https://huggingface.co/NurseCitizenDeveloper)
|
| 59 |
+
=======
|
| 60 |
+
---
|
| 61 |
+
title: NurseLex
|
| 62 |
+
emoji: 🏛️
|
| 63 |
+
colorFrom: indigo
|
| 64 |
+
colorTo: purple
|
| 65 |
+
sdk: gradio
|
| 66 |
+
sdk_version: "5.50.0"
|
| 67 |
+
app_file: app.py
|
| 68 |
+
pinned: true
|
| 69 |
+
license: mit
|
| 70 |
+
short_description: UK law for mental health & LD nurses
|
| 71 |
+
tags:
|
| 72 |
+
- nursing
|
| 73 |
+
- legal
|
| 74 |
+
- mental-health
|
| 75 |
+
- nhs
|
| 76 |
+
- uk-law
|
| 77 |
+
- lex-api
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
# 🏛️ NurseLex — UK Law for Nurses
|
| 81 |
+
|
| 82 |
+
**Legal literacy for mental health & learning disability nurses** — powered by the [i.AI Lex API](https://lex.lab.i.ai.gov.uk/) and Google Gemini Flash.
|
| 83 |
+
|
| 84 |
+
## What It Does
|
| 85 |
+
|
| 86 |
+
NurseLex helps non-acute NHS nursing staff understand UK healthcare legislation by:
|
| 87 |
+
|
| 88 |
+
1. **Searching real UK legislation** via the i.AI Lex API (semantic search across millions of Acts and Statutory Instruments)
|
| 89 |
+
2. **Explaining the law in plain English** using Gemini Flash, tailored for mental health and learning disability nursing
|
| 90 |
+
3. **Citing the exact Act, section, and year** — zero hallucination, grounded in actual statutory text
|
| 91 |
+
|
| 92 |
+
## Key Legislation
|
| 93 |
+
|
| 94 |
+
- Mental Health Act 1983 (Sections 2, 3, 5(4), 17, 117, 135, 136)
|
| 95 |
+
- Mental Capacity Act 2005 (Capacity, Best Interests, DoLS)
|
| 96 |
+
- Care Act 2014 (Safeguarding, Assessments, Advocacy)
|
| 97 |
+
- Human Rights Act 1998 (Articles 5, 8)
|
| 98 |
+
- Equality Act 2010 (Reasonable Adjustments)
|
| 99 |
+
- Mental Health Units (Use of Force) Act 2018
|
| 100 |
+
- Autism Act 2009
|
| 101 |
+
|
| 102 |
+
## Architecture
|
| 103 |
+
|
| 104 |
+
```
|
| 105 |
+
User Question → Lex API (semantic search) → Statutory Text → Gemini Flash → Plain English + Citations
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
## Data Source
|
| 109 |
+
|
| 110 |
+
All legislation data comes from the **[i.AI Lex API](https://lex.lab.i.ai.gov.uk/)** — an official UK Government service. Crown Copyright, Open Government Licence v3.0.
|
| 111 |
+
|
| 112 |
+
## Built By
|
| 113 |
+
|
| 114 |
+
**NurseCitizenDeveloper** — NHS Registered Nurse (Learning Disabilities) building open-source AI tools for nursing.
|
| 115 |
+
|
| 116 |
+
- 🌐 [AI Educator Toolkit](https://clinical-quality-intelligence.github.io/nursing-ai-toolkit/)
|
| 117 |
+
- 🤗 [Hugging Face](https://huggingface.co/NurseCitizenDeveloper)
|
| 118 |
+
>>>>>>> a4e257b16d56f80612b7c9ac6d2e7c198fef5bb6
|
__pycache__/app.cpython-314.pyc
ADDED
|
Binary file (34.2 kB). View file
|
|
|
__pycache__/lex_client.cpython-314.pyc
ADDED
|
Binary file (10 kB). View file
|
|
|
__pycache__/mcp_server.cpython-314.pyc
ADDED
|
Binary file (10 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,1142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<<<<<<< HEAD
|
| 2 |
+
"""
|
| 3 |
+
NurseLex — Legal Literacy Agent for All Nurses and Nursing Students
|
| 4 |
+
Architecture:
|
| 5 |
+
1. Local legislation.parquet — 219K health/social care Acts & SIs for browsing
|
| 6 |
+
2. cached_legislation.py — 1,128 sections loaded from nursing_sections.json
|
| 7 |
+
3. Gemini Flash REST API — Plain English explanations (with retry logic)
|
| 8 |
+
"""
|
| 9 |
+
import os
|
| 10 |
+
import asyncio
|
| 11 |
+
import httpx
|
| 12 |
+
import logging
|
| 13 |
+
import pandas as pd
|
| 14 |
+
import gradio as gr
|
| 15 |
+
|
| 16 |
+
from cached_legislation import search_cached
|
| 17 |
+
from local_search import search_scenarios_locally
|
| 18 |
+
|
| 19 |
+
logging.basicConfig(level=logging.INFO)
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# --- Load local legislation index ---
|
| 23 |
+
PARQUET_PATH = os.path.join(os.path.dirname(__file__), "legislation.parquet")
|
| 24 |
+
try:
|
| 25 |
+
LEG_DF = pd.read_parquet(PARQUET_PATH)
|
| 26 |
+
logger.info(f"Loaded {len(LEG_DF)} legislation entries from parquet")
|
| 27 |
+
except Exception as e:
|
| 28 |
+
logger.warning(f"Could not load parquet: {e}")
|
| 29 |
+
LEG_DF = pd.DataFrame()
|
| 30 |
+
|
| 31 |
+
# --- Key nursing legislation IDs ---
|
| 32 |
+
NURSING_ACTS = {
|
| 33 |
+
"Mental Health Act 1983": "ukpga/1983/20",
|
| 34 |
+
"Mental Capacity Act 2005": "ukpga/2005/9",
|
| 35 |
+
"Care Act 2014": "ukpga/2014/23",
|
| 36 |
+
"Human Rights Act 1998": "ukpga/1998/42",
|
| 37 |
+
"Equality Act 2010": "ukpga/2010/15",
|
| 38 |
+
"Health and Social Care Act 2012": "ukpga/2012/7",
|
| 39 |
+
"Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
|
| 40 |
+
"Autism Act 2009": "ukpga/2009/15",
|
| 41 |
+
"Children Act 1989": "ukpga/1989/41",
|
| 42 |
+
"Children Act 2004": "ukpga/2004/31",
|
| 43 |
+
"Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
|
| 44 |
+
"Health and Care Act 2022": "ukpga/2022/31",
|
| 45 |
+
}
|
| 46 |
+
REVERSE_ACTS = {v: k for k, v in NURSING_ACTS.items()}
|
| 47 |
+
|
| 48 |
+
# --- Gemini REST API ---
|
| 49 |
+
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
|
| 50 |
+
GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
| 51 |
+
GEMINI_MODELS = ["gemini-2.0-flash-lite", "gemini-2.0-flash"]
|
| 52 |
+
|
| 53 |
+
SYSTEM_PROMPT = """You are NurseLex, a legal literacy assistant for all UK nurses and nursing students.
|
| 54 |
+
|
| 55 |
+
Your role:
|
| 56 |
+
1. Answer legal questions using ONLY the legislation text provided in the context.
|
| 57 |
+
2. Explain the law in clear, plain English suitable for all nurses and nursing students.
|
| 58 |
+
3. Always cite the specific Act, section number, and year.
|
| 59 |
+
4. If the context doesn't contain enough information, say so clearly.
|
| 60 |
+
5. Add practical nursing implications (e.g., "In practice, this means...").
|
| 61 |
+
6. Include professional reminders (e.g., NMC Code, duty of care).
|
| 62 |
+
|
| 63 |
+
Disclaimers to include:
|
| 64 |
+
- "This is for educational purposes only — always consult your trust's legal team for specific cases."
|
| 65 |
+
- "This reflects the legislation as written — local trust policies may add additional requirements."
|
| 66 |
+
|
| 67 |
+
Format with clear headings, bullet points, and bold key terms."""
|
| 68 |
+
|
| 69 |
+
QUICK_QUESTIONS = [
|
| 70 |
+
"What is Section 5(4) of the Mental Health Act and when can a nurse use it?",
|
| 71 |
+
"What does the Mental Capacity Act say about best interests decisions?",
|
| 72 |
+
"When can a patient be detained under Section 2 vs Section 3?",
|
| 73 |
+
"What are the legal requirements for using restraint?",
|
| 74 |
+
"What does Section 117 aftercare mean and who is entitled?",
|
| 75 |
+
"What are a nurse's legal duties under the Care Act 2014 for safeguarding?",
|
| 76 |
+
"What is Deprivation of Liberty and when do DoLS apply?",
|
| 77 |
+
"What rights does a patient have under Section 136?",
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
async def call_gemini(prompt: str) -> str:
|
| 81 |
+
"""Call Gemini via REST API with retry logic and model fallback."""
|
| 82 |
+
if not GEMINI_API_KEY:
|
| 83 |
+
return ""
|
| 84 |
+
|
| 85 |
+
payload = {
|
| 86 |
+
"system_instruction": {"parts": [{"text": SYSTEM_PROMPT}]},
|
| 87 |
+
"contents": [{"parts": [{"text": prompt}]}],
|
| 88 |
+
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 2048},
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 92 |
+
for model in GEMINI_MODELS:
|
| 93 |
+
url = f"{GEMINI_BASE}/{model}:generateContent?key={GEMINI_API_KEY}"
|
| 94 |
+
for attempt in range(3):
|
| 95 |
+
try:
|
| 96 |
+
resp = await client.post(url, json=payload)
|
| 97 |
+
if resp.status_code == 429:
|
| 98 |
+
wait = 2 ** (attempt + 1)
|
| 99 |
+
logger.warning(f"Rate limited ({model}), retrying in {wait}s")
|
| 100 |
+
await asyncio.sleep(wait)
|
| 101 |
+
continue
|
| 102 |
+
resp.raise_for_status()
|
| 103 |
+
data = resp.json()
|
| 104 |
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
| 105 |
+
except httpx.HTTPStatusError as e:
|
| 106 |
+
if e.response.status_code == 429:
|
| 107 |
+
wait = 2 ** (attempt + 1)
|
| 108 |
+
logger.warning(f"Rate limited ({model}), retrying in {wait}s")
|
| 109 |
+
await asyncio.sleep(wait)
|
| 110 |
+
continue
|
| 111 |
+
logger.error(f"Gemini API error ({model}): {e.response.status_code}")
|
| 112 |
+
break
|
| 113 |
+
except Exception as e:
|
| 114 |
+
logger.error(f"Gemini error ({model}): {type(e).__name__}")
|
| 115 |
+
break
|
| 116 |
+
logger.info(f"Model {model} exhausted, trying next...")
|
| 117 |
+
|
| 118 |
+
logger.error("All Gemini models failed")
|
| 119 |
+
return ""
|
| 120 |
+
|
| 121 |
+
def search_legislation_index(query: str, max_results: int = 10) -> pd.DataFrame:
|
| 122 |
+
"""Search the full legislation index parquet by title."""
|
| 123 |
+
if LEG_DF.empty:
|
| 124 |
+
return pd.DataFrame()
|
| 125 |
+
|
| 126 |
+
mask = LEG_DF["title"].str.contains(query, case=False, na=False)
|
| 127 |
+
results = LEG_DF[mask].sort_values("year", ascending=False).head(max_results)
|
| 128 |
+
return results
|
| 129 |
+
|
| 130 |
+
async def query_and_respond(user_question: str, history: list) -> str:
|
| 131 |
+
"""Main RAG pipeline: local cached sections (1,128) + Gemini explanation."""
|
| 132 |
+
if not user_question.strip():
|
| 133 |
+
return "Please enter a question about UK healthcare legislation."
|
| 134 |
+
|
| 135 |
+
# Step 1: Search local legislation sections
|
| 136 |
+
sections = search_cached(user_question, max_results=5)
|
| 137 |
+
logger.info(f"Local search returned {len(sections)} sections for: {user_question[:60]}")
|
| 138 |
+
|
| 139 |
+
# Step 2: Search parquet index for related Acts
|
| 140 |
+
related_acts = search_legislation_index(user_question, max_results=5)
|
| 141 |
+
|
| 142 |
+
# Step 3: Build context
|
| 143 |
+
context_parts = []
|
| 144 |
+
for section in sections:
|
| 145 |
+
title = section.get("title", "Untitled")
|
| 146 |
+
text = section.get("text", "")
|
| 147 |
+
leg_id = section.get("legislation_id", "")
|
| 148 |
+
num = section.get("number", "")
|
| 149 |
+
context_parts.append(f"### {title}\n**Source:** {leg_id}, Section {num}\n\n{text}\n")
|
| 150 |
+
|
| 151 |
+
context = "\n---\n".join(context_parts) if context_parts else "No matching legislation sections found in cache."
|
| 152 |
+
|
| 153 |
+
# Step 4: Generate Gemini response
|
| 154 |
+
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."
|
| 155 |
+
|
| 156 |
+
answer = await call_gemini(prompt)
|
| 157 |
+
|
| 158 |
+
if not answer:
|
| 159 |
+
# Fallback: show raw legislation if Gemini fails or is missing key
|
| 160 |
+
answer = _build_fallback(user_question, sections)
|
| 161 |
+
if not GEMINI_API_KEY:
|
| 162 |
+
answer += "\n\n⚠️ *Set `GEMINI_API_KEY` in Space secrets for AI-powered plain English explanations.*"
|
| 163 |
+
elif "rate limit" in answer.lower():
|
| 164 |
+
answer += "\n\n⚠️ *Gemini is currently rate limited, falling back to raw legislation.*"
|
| 165 |
+
|
| 166 |
+
# Add source citations
|
| 167 |
+
source_acts = set()
|
| 168 |
+
for s in sections:
|
| 169 |
+
leg_id = s.get("legislation_id", "")
|
| 170 |
+
if leg_id:
|
| 171 |
+
source_acts.add(leg_id)
|
| 172 |
+
|
| 173 |
+
if source_acts:
|
| 174 |
+
answer += "\n\n---\n📚 **Sources:** "
|
| 175 |
+
answer += " | ".join(f"[{sid}](https://www.legislation.gov.uk/id/{sid})" for sid in sorted(source_acts))
|
| 176 |
+
|
| 177 |
+
# Add related Acts from parquet
|
| 178 |
+
if not related_acts.empty:
|
| 179 |
+
answer += "\n\n📖 **Related legislation:** "
|
| 180 |
+
act_links = []
|
| 181 |
+
for _, row in related_acts.head(3).iterrows():
|
| 182 |
+
uri = row.get("uri", "")
|
| 183 |
+
title = row.get("title", "")
|
| 184 |
+
if uri and title:
|
| 185 |
+
act_links.append(f"[{title}]({uri})")
|
| 186 |
+
if act_links:
|
| 187 |
+
answer += " | ".join(act_links)
|
| 188 |
+
|
| 189 |
+
answer += "\n\n🏛️ *Data from [legislation.gov.uk](https://www.legislation.gov.uk/) — Crown Copyright, OGL v3.0*"
|
| 190 |
+
return answer
|
| 191 |
+
|
| 192 |
+
def _build_fallback(question: str, sections: list) -> str:
|
| 193 |
+
"""Show raw legislation without LLM."""
|
| 194 |
+
response = f"## Legislation relevant to: *{question}*\n\n"
|
| 195 |
+
|
| 196 |
+
if not sections:
|
| 197 |
+
response += (
|
| 198 |
+
"No matching sections found in cache. Try searching the full **Browse Legislation** tab for the Act title, or try specific terms like:\n"
|
| 199 |
+
"- **\"Section 5(4)\"** or **\"nurse holding power\"**\n"
|
| 200 |
+
"- **\"best interests\"** or **\"capacity\"**\n"
|
| 201 |
+
"- **\"safeguarding\"** or **\"Section 42\"**\n"
|
| 202 |
+
"- **\"Section 136\"** or **\"place of safety\"**\n"
|
| 203 |
+
)
|
| 204 |
+
return response
|
| 205 |
+
|
| 206 |
+
for i, section in enumerate(sections[:5], 1):
|
| 207 |
+
title = section.get("title", "Untitled")
|
| 208 |
+
text = section.get("text", "No text available")
|
| 209 |
+
leg_id = section.get("legislation_id", "")
|
| 210 |
+
num = section.get("number", "")
|
| 211 |
+
uri = section.get("uri", "")
|
| 212 |
+
|
| 213 |
+
response += f"### {i}. {title}\n"
|
| 214 |
+
response += f"**Act:** `{leg_id}` | **Section:** {num}\n\n"
|
| 215 |
+
response += f"{text}\n\n"
|
| 216 |
+
if uri:
|
| 217 |
+
response += f"🔗 [View on legislation.gov.uk]({uri})\n\n"
|
| 218 |
+
response += "---\n\n"
|
| 219 |
+
|
| 220 |
+
return response
|
| 221 |
+
|
| 222 |
+
async def section_lookup(act_name: str, section_input: str) -> str:
|
| 223 |
+
"""Look up sections from cached legislation."""
|
| 224 |
+
legislation_id = NURSING_ACTS.get(act_name)
|
| 225 |
+
if not legislation_id:
|
| 226 |
+
return f"❌ Act not found in NurseLex."
|
| 227 |
+
|
| 228 |
+
cache_query = f"{act_name} section {section_input}" if section_input.strip() else act_name
|
| 229 |
+
sections = search_cached(cache_query, max_results=10)
|
| 230 |
+
sections = [s for s in sections if s.get("legislation_id") == legislation_id]
|
| 231 |
+
|
| 232 |
+
if section_input.strip() and sections:
|
| 233 |
+
try:
|
| 234 |
+
target_num = int(section_input.strip().replace("Section ", "").replace("s.", "").replace("S.", ""))
|
| 235 |
+
matching = [s for s in sections if s.get("number") == target_num]
|
| 236 |
+
if matching:
|
| 237 |
+
sections = matching
|
| 238 |
+
except ValueError:
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
+
if not sections:
|
| 242 |
+
return (
|
| 243 |
+
f"⏳ Section not found in cache for **{act_name}**.\n\n"
|
| 244 |
+
f"Try the **Chat tab** for a broader search, or visit "
|
| 245 |
+
f"[legislation.gov.uk](https://www.legislation.gov.uk/id/{legislation_id}) directly."
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
result = f"## {act_name}\n\n"
|
| 249 |
+
for section in sections[:5]:
|
| 250 |
+
title = section.get("title", "Untitled")
|
| 251 |
+
text = section.get("text", "No text")
|
| 252 |
+
num = section.get("number", "")
|
| 253 |
+
uri = section.get("uri", "")
|
| 254 |
+
|
| 255 |
+
result += f"### Section {num}: {title}\n\n{text}\n\n"
|
| 256 |
+
if uri:
|
| 257 |
+
result += f"🔗 [View on legislation.gov.uk]({uri})\n\n"
|
| 258 |
+
result += "---\n\n"
|
| 259 |
+
|
| 260 |
+
result += "\n🏛️ *Crown Copyright, OGL v3.0*"
|
| 261 |
+
return result
|
| 262 |
+
|
| 263 |
+
async def fetch_explanatory_note(act_name: str, section_input: str) -> str:
|
| 264 |
+
"""Dynamically fetch Explanatory Notes from the i.AI Lex API."""
|
| 265 |
+
if not section_input.strip():
|
| 266 |
+
return "Please specify a section number to view its Explanatory Note."
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
# Extract the digits
|
| 270 |
+
section_number = "".join([c for c in section_input if c.isdigit()])
|
| 271 |
+
if not section_number:
|
| 272 |
+
return "Please enter a valid section number."
|
| 273 |
+
|
| 274 |
+
url = 'https://lex.lab.i.ai.gov.uk/explanatory_note/section/search'
|
| 275 |
+
payload = {
|
| 276 |
+
'query': f'"{act_name}" Section {section_number}',
|
| 277 |
+
'limit': 5
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
async with httpx.AsyncClient() as client:
|
| 281 |
+
r = await client.post(url, json=payload, timeout=10.0)
|
| 282 |
+
if r.status_code == 200:
|
| 283 |
+
data = r.json()
|
| 284 |
+
if isinstance(data, list):
|
| 285 |
+
parent_id = NURSING_ACTS.get(act_name, "")
|
| 286 |
+
for note in data:
|
| 287 |
+
if parent_id and parent_id in note.get('legislation_id', ''):
|
| 288 |
+
text = note.get('text', '')
|
| 289 |
+
if text:
|
| 290 |
+
return f"### Official Explanatory Note\n\n{text}\n\n*Source: i.AI Lex API*"
|
| 291 |
+
|
| 292 |
+
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).*."
|
| 293 |
+
except httpx.TimeoutException:
|
| 294 |
+
return "⏳ API Timeout while fetching Explanatory Note."
|
| 295 |
+
except Exception as e:
|
| 296 |
+
return f"Error fetching note: {str(e)}"
|
| 297 |
+
|
| 298 |
+
async def scenario_search(scenario_text: str) -> str:
|
| 299 |
+
"""Use local i-dot-ai vector search to map a clinical scenario to legal sections."""
|
| 300 |
+
if not scenario_text.strip():
|
| 301 |
+
return "Please describe a clinical scenario."
|
| 302 |
+
|
| 303 |
+
try:
|
| 304 |
+
results = search_scenarios_locally(scenario_text, top_k=5)
|
| 305 |
+
|
| 306 |
+
if not results:
|
| 307 |
+
return "No matching legislation found for this scenario in the local cache."
|
| 308 |
+
|
| 309 |
+
result = f"## ⚖️ Probable Legislation Matches for:\n*{scenario_text}*\n\n"
|
| 310 |
+
|
| 311 |
+
for i, n in enumerate(results, 1):
|
| 312 |
+
leg_id = n.get("legislation_id", "")
|
| 313 |
+
|
| 314 |
+
# 1. Use the act_name from known mapping
|
| 315 |
+
act_name = ""
|
| 316 |
+
for known_id, known_name in REVERSE_ACTS.items():
|
| 317 |
+
if known_id in leg_id:
|
| 318 |
+
act_name = known_name
|
| 319 |
+
break
|
| 320 |
+
|
| 321 |
+
# 2. Final fallback: extract from the legislation_id URL
|
| 322 |
+
if not act_name:
|
| 323 |
+
act_name = leg_id.split("/id/")[-1] if "/id/" in leg_id else leg_id or "Legislation"
|
| 324 |
+
|
| 325 |
+
sec_num = n.get("number", "??")
|
| 326 |
+
title = n.get("title", "Untitled Section")
|
| 327 |
+
text = n.get("text", "")
|
| 328 |
+
uri = n.get("uri", f"https://www.legislation.gov.uk/id/{leg_id}/section/{sec_num}")
|
| 329 |
+
score = n.get("score", 0.0)
|
| 330 |
+
|
| 331 |
+
result += f"### {i}. {act_name} — Section {sec_num}: {title} (Match Score: {score:.2f})\n"
|
| 332 |
+
result += f"{text[:800]}...\n\n"
|
| 333 |
+
result += f"🔗 [Read full text on legislation.gov.uk]({uri})\n\n---\n\n"
|
| 334 |
+
|
| 335 |
+
return result
|
| 336 |
+
except Exception as e:
|
| 337 |
+
return f"Error during local scenario search: {str(e)}"
|
| 338 |
+
|
| 339 |
+
def browse_legislation(search_term: str, act_type: str) -> str:
|
| 340 |
+
"""Browse the legislation index from the parquet file."""
|
| 341 |
+
if LEG_DF.empty:
|
| 342 |
+
return "⚠️ Legislation index not loaded."
|
| 343 |
+
|
| 344 |
+
filtered = LEG_DF.copy()
|
| 345 |
+
|
| 346 |
+
if act_type != "All":
|
| 347 |
+
type_map = {"Primary Acts": "ukpga", "Statutory Instruments": "uksi", "Scottish SIs": "ssi", "NI SRs": "nisr", "Welsh SIs": "wsi"}
|
| 348 |
+
if act_type in type_map:
|
| 349 |
+
filtered = filtered[filtered["type"] == type_map[act_type]]
|
| 350 |
+
|
| 351 |
+
if search_term.strip():
|
| 352 |
+
filtered = filtered[filtered["title"].str.contains(search_term, case=False, na=False)]
|
| 353 |
+
|
| 354 |
+
filtered = filtered.sort_values("year", ascending=False).head(50)
|
| 355 |
+
|
| 356 |
+
if filtered.empty:
|
| 357 |
+
return f"No legislation found matching '{search_term}'."
|
| 358 |
+
|
| 359 |
+
result = f"## 📖 Legislation Index ({len(filtered)} results)\n\n| Year | Title | Type |\n|---|---|---|\n"
|
| 360 |
+
|
| 361 |
+
for _, row in filtered.iterrows():
|
| 362 |
+
year = row.get("year", "—")
|
| 363 |
+
title = row.get("title", "Untitled")
|
| 364 |
+
uri = row.get("uri", "")
|
| 365 |
+
leg_type = row.get("type", "")
|
| 366 |
+
title_link = f"[{title}]({uri})" if uri else title
|
| 367 |
+
result += f"| {year} | {title_link} | {leg_type} |\n"
|
| 368 |
+
|
| 369 |
+
result += f"\n\n*Showing top 50 of {len(LEG_DF)} health & social care entries — {len(LEG_DF[LEG_DF['type']=='ukpga'])} Primary Acts*"
|
| 370 |
+
result += "\n\n🏛️ *Data from i.AI Lex bulk downloads — Crown Copyright, OGL v3.0*"
|
| 371 |
+
return result
|
| 372 |
+
|
| 373 |
+
# --- Gradio UI ---
|
| 374 |
+
THEME = gr.themes.Soft(
|
| 375 |
+
primary_hue="indigo",
|
| 376 |
+
secondary_hue="violet",
|
| 377 |
+
neutral_hue="slate",
|
| 378 |
+
font=gr.themes.GoogleFont("Inter"),
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
CSS = """
|
| 382 |
+
.gradio-container { max-width: 960px !important; }
|
| 383 |
+
.header-banner {
|
| 384 |
+
background: linear-gradient(135deg, #312e81 0%, #4338ca 50%, #6366f1 100%);
|
| 385 |
+
border-radius: 16px;
|
| 386 |
+
padding: 28px 32px;
|
| 387 |
+
margin-bottom: 16px;
|
| 388 |
+
color: white;
|
| 389 |
+
}
|
| 390 |
+
.header-banner h1 { color: white; font-size: 2em; margin: 0 0 8px 0; }
|
| 391 |
+
.header-banner p { color: #c7d2fe; margin: 0; font-size: 1.05em; }
|
| 392 |
+
.disclaimer-box {
|
| 393 |
+
background: #fef3c7;
|
| 394 |
+
border-left: 4px solid #f59e0b;
|
| 395 |
+
border-radius: 8px;
|
| 396 |
+
padding: 12px 16px;
|
| 397 |
+
margin-bottom: 12px;
|
| 398 |
+
font-size: 0.9em;
|
| 399 |
+
color: #92400e;
|
| 400 |
+
}
|
| 401 |
+
footer { display: none !important; }
|
| 402 |
+
"""
|
| 403 |
+
|
| 404 |
+
with gr.Blocks(theme=THEME, css=CSS, title="NurseLex — UK Law for All Nurses") as app:
|
| 405 |
+
gr.HTML("""
|
| 406 |
+
<div class="header-banner">
|
| 407 |
+
<h1>🏛️ NurseLex</h1>
|
| 408 |
+
<p>Legal literacy for all nurses and nursing students — powered by UK Government legislation data</p>
|
| 409 |
+
</div>
|
| 410 |
+
""")
|
| 411 |
+
|
| 412 |
+
gr.HTML("""
|
| 413 |
+
<div class="disclaimer-box">
|
| 414 |
+
⚠️ <strong>Educational tool only.</strong> NurseLex provides legislation text and AI-generated explanations for learning purposes.
|
| 415 |
+
It does not constitute legal advice. Always consult your trust's legal/governance team for specific cases.
|
| 416 |
+
</div>
|
| 417 |
+
""")
|
| 418 |
+
|
| 419 |
+
with gr.Tabs():
|
| 420 |
+
# --- Tab 1: Chat ---
|
| 421 |
+
with gr.TabItem("💬 Ask a Legal Question", id="chat"):
|
| 422 |
+
gr.Markdown("Ask about UK healthcare legislation — answers are grounded in **real statutory text**. (Cache: 1,128 Sections + 219K Acts)")
|
| 423 |
+
|
| 424 |
+
chatbot = gr.Chatbot(
|
| 425 |
+
label="NurseLex",
|
| 426 |
+
height=480,
|
| 427 |
+
type="messages",
|
| 428 |
+
show_copy_button=True,
|
| 429 |
+
avatar_images=(None, "https://em-content.zobj.net/source/twitter/376/classical-building_1f3db-fe0f.png"),
|
| 430 |
+
)
|
| 431 |
+
msg = gr.Textbox(
|
| 432 |
+
label="Your question",
|
| 433 |
+
placeholder="e.g., What is Section 5(4) of the Mental Health Act and when can a nurse use it?",
|
| 434 |
+
lines=2,
|
| 435 |
+
)
|
| 436 |
+
with gr.Row():
|
| 437 |
+
submit_btn = gr.Button("🔍 Search Legislation", variant="primary", scale=2)
|
| 438 |
+
clear_btn = gr.ClearButton([msg, chatbot], value="🗑️ Clear", scale=1)
|
| 439 |
+
|
| 440 |
+
gr.Markdown("### 💡 Quick Questions")
|
| 441 |
+
with gr.Row(equal_height=True):
|
| 442 |
+
for i in range(0, 4):
|
| 443 |
+
gr.Button(
|
| 444 |
+
QUICK_QUESTIONS[i][:55] + "..." if len(QUICK_QUESTIONS[i]) > 55 else QUICK_QUESTIONS[i],
|
| 445 |
+
size="sm",
|
| 446 |
+
variant="secondary",
|
| 447 |
+
).click(fn=lambda q=QUICK_QUESTIONS[i]: q, outputs=msg)
|
| 448 |
+
with gr.Row(equal_height=True):
|
| 449 |
+
for i in range(4, 8):
|
| 450 |
+
gr.Button(
|
| 451 |
+
QUICK_QUESTIONS[i][:55] + "..." if len(QUICK_QUESTIONS[i]) > 55 else QUICK_QUESTIONS[i],
|
| 452 |
+
size="sm",
|
| 453 |
+
variant="secondary",
|
| 454 |
+
).click(fn=lambda q=QUICK_QUESTIONS[i]: q, outputs=msg)
|
| 455 |
+
|
| 456 |
+
async def respond(message, history):
|
| 457 |
+
history = history or []
|
| 458 |
+
history.append({"role": "user", "content": message})
|
| 459 |
+
answer = await query_and_respond(message, history)
|
| 460 |
+
history.append({"role": "assistant", "content": answer})
|
| 461 |
+
return "", history
|
| 462 |
+
|
| 463 |
+
submit_btn.click(respond, [msg, chatbot], [msg, chatbot])
|
| 464 |
+
msg.submit(respond, [msg, chatbot], [msg, chatbot])
|
| 465 |
+
|
| 466 |
+
# --- Tab 2: Section Lookup ---
|
| 467 |
+
with gr.TabItem("📖 Section Lookup", id="lookup"):
|
| 468 |
+
gr.Markdown("Look up a **specific section** of key nursing Acts. Includes **Official Explanatory Notes** where available.")
|
| 469 |
+
|
| 470 |
+
with gr.Row():
|
| 471 |
+
act_dropdown = gr.Dropdown(
|
| 472 |
+
choices=list(NURSING_ACTS.keys()),
|
| 473 |
+
label="Select Act",
|
| 474 |
+
value="Mental Health Act 1983",
|
| 475 |
+
)
|
| 476 |
+
section_input_box = gr.Textbox(
|
| 477 |
+
label="Section number",
|
| 478 |
+
placeholder="e.g., 5 or 117 or 136",
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
lookup_btn = gr.Button("🔍 Look Up Law & Notes", variant="primary")
|
| 482 |
+
|
| 483 |
+
with gr.Row():
|
| 484 |
+
lookup_output = gr.Markdown(label="Statutory Text")
|
| 485 |
+
note_output = gr.Markdown(label="Official Explanatory Note")
|
| 486 |
+
|
| 487 |
+
lookup_btn.click(section_lookup, [act_dropdown, section_input_box], lookup_output)
|
| 488 |
+
lookup_btn.click(fetch_explanatory_note, [act_dropdown, section_input_box], note_output)
|
| 489 |
+
|
| 490 |
+
# --- Tab 3: Scenario Matcher ---
|
| 491 |
+
with gr.TabItem("🧠 Scenario Matcher", id="scenario"):
|
| 492 |
+
gr.Markdown("Describe a clinical scenario in plain English, and the **Lex Vector Search Engine** will map it to the most relevant UK laws.")
|
| 493 |
+
|
| 494 |
+
with gr.Row():
|
| 495 |
+
scenario_input = gr.Textbox(
|
| 496 |
+
label="Clinical Scenario",
|
| 497 |
+
placeholder="e.g. 'Patient wants to leave the ward but lacks capacity' or 'Doctor orders restraint without DoLS'",
|
| 498 |
+
lines=3
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
scenario_btn = gr.Button("🤖 Find Relevant Law", variant="primary")
|
| 502 |
+
scenario_output = gr.Markdown(label="Semantic Search Results")
|
| 503 |
+
|
| 504 |
+
scenario_btn.click(scenario_search, [scenario_input], scenario_output)
|
| 505 |
+
|
| 506 |
+
# --- Tab 4: Browse Legislation ---
|
| 507 |
+
with gr.TabItem("📚 Browse Legislation", id="browse"):
|
| 508 |
+
gr.Markdown(f"Browse **219,678** health & social care Acts and Statutory Instruments from the i.AI Lex dataset.")
|
| 509 |
+
|
| 510 |
+
with gr.Row():
|
| 511 |
+
browse_search = gr.Textbox(
|
| 512 |
+
label="Search legislation titles",
|
| 513 |
+
placeholder="e.g., mental health, safeguarding, disability",
|
| 514 |
+
)
|
| 515 |
+
browse_type = gr.Dropdown(
|
| 516 |
+
choices=["All", "Primary Acts", "Statutory Instruments", "Scottish SIs", "NI SRs", "Welsh SIs"],
|
| 517 |
+
label="Type",
|
| 518 |
+
value="All",
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
browse_btn = gr.Button("🔍 Search", variant="primary")
|
| 522 |
+
browse_output = gr.Markdown(label="Results")
|
| 523 |
+
|
| 524 |
+
browse_btn.click(browse_legislation, [browse_search, browse_type], browse_output)
|
| 525 |
+
|
| 526 |
+
# --- Tab 4: About ---
|
| 527 |
+
with gr.TabItem("ℹ️ About", id="about"):
|
| 528 |
+
gr.Markdown(f"""
|
| 529 |
+
## About NurseLex
|
| 530 |
+
|
| 531 |
+
**NurseLex** is a universal legal literacy tool for **all nurses and nursing students**.
|
| 532 |
+
|
| 533 |
+
### How It Works
|
| 534 |
+
|
| 535 |
+
1. **You ask a question** about UK healthcare law
|
| 536 |
+
2. **Cached legislation** provides the actual statutory text instantly
|
| 537 |
+
3. **Gemini Flash** explains it in plain English with practical nursing implications
|
| 538 |
+
4. **Every answer cites** the specific Act, section, and year
|
| 539 |
+
|
| 540 |
+
### Data
|
| 541 |
+
|
| 542 |
+
- **219,678 legislation entries** from the [i.AI Lex](https://lex.lab.i.ai.gov.uk/) bulk dataset
|
| 543 |
+
- **1,128 key sections** pre-cached with full text (MHA 1983, MCA 2005, Care Act 2014)
|
| 544 |
+
- **Crown Copyright** — Open Government Licence v3.0
|
| 545 |
+
|
| 546 |
+
### Key Acts Covered
|
| 547 |
+
|
| 548 |
+
| Act | Key Sections | Nursing Relevance |
|
| 549 |
+
|---|---|---|
|
| 550 |
+
| Mental Health Act 1983 | S.2, S.3, S.4, S.5(2), S.5(4), S.17, S.117, S.135, S.136 | Detention, holding powers, leave, aftercare |
|
| 551 |
+
| Mental Capacity Act 2005 | S.1 (Principles), S.2-3 (Capacity), S.4 (Best Interests), S.5 | Capacity assessments, best interests, DoLS |
|
| 552 |
+
| Care Act 2014 | S.42 (Safeguarding), S.67 (Advocacy) | Safeguarding adults, independent advocates |
|
| 553 |
+
|
| 554 |
+
### Built By
|
| 555 |
+
|
| 556 |
+
**NurseCitizenDeveloper** — NHS Registered Nurse building AI tools for nursing education.
|
| 557 |
+
|
| 558 |
+
🤗 [Hugging Face](https://huggingface.co/NurseCitizenDeveloper) · 🐙 [GitHub](https://github.com/Clinical-Quality-Intelligence)
|
| 559 |
+
""")
|
| 560 |
+
|
| 561 |
+
app.queue()
|
| 562 |
+
|
| 563 |
+
if __name__ == "__main__":
|
| 564 |
+
app.launch(server_name="0.0.0.0", server_port=7860)
|
| 565 |
+
=======
|
| 566 |
+
"""
|
| 567 |
+
NurseLex — Legal Literacy Agent for All Nurses and Nursing Students
|
| 568 |
+
Architecture:
|
| 569 |
+
1. Local legislation.parquet — 219K health/social care Acts & SIs for browsing
|
| 570 |
+
2. cached_legislation.py — 1,128 sections loaded from nursing_sections.json
|
| 571 |
+
3. Gemini Flash REST API — Plain English explanations (with retry logic)
|
| 572 |
+
"""
|
| 573 |
+
import os
|
| 574 |
+
import asyncio
|
| 575 |
+
import httpx
|
| 576 |
+
import logging
|
| 577 |
+
import pandas as pd
|
| 578 |
+
import gradio as gr
|
| 579 |
+
|
| 580 |
+
from cached_legislation import search_cached
|
| 581 |
+
|
| 582 |
+
logging.basicConfig(level=logging.INFO)
|
| 583 |
+
logger = logging.getLogger(__name__)
|
| 584 |
+
|
| 585 |
+
# --- Load local legislation index ---
|
| 586 |
+
PARQUET_PATH = os.path.join(os.path.dirname(__file__), "legislation.parquet")
|
| 587 |
+
try:
|
| 588 |
+
LEG_DF = pd.read_parquet(PARQUET_PATH)
|
| 589 |
+
logger.info(f"Loaded {len(LEG_DF)} legislation entries from parquet")
|
| 590 |
+
except Exception as e:
|
| 591 |
+
logger.warning(f"Could not load parquet: {e}")
|
| 592 |
+
LEG_DF = pd.DataFrame()
|
| 593 |
+
|
| 594 |
+
# --- Key nursing legislation IDs ---
|
| 595 |
+
NURSING_ACTS = {
|
| 596 |
+
"Mental Health Act 1983": "ukpga/1983/20",
|
| 597 |
+
"Mental Capacity Act 2005": "ukpga/2005/9",
|
| 598 |
+
"Care Act 2014": "ukpga/2014/23",
|
| 599 |
+
"Human Rights Act 1998": "ukpga/1998/42",
|
| 600 |
+
"Equality Act 2010": "ukpga/2010/15",
|
| 601 |
+
"Health and Social Care Act 2012": "ukpga/2012/7",
|
| 602 |
+
"Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
|
| 603 |
+
"Autism Act 2009": "ukpga/2009/15",
|
| 604 |
+
"Children Act 1989": "ukpga/1989/41",
|
| 605 |
+
"Children Act 2004": "ukpga/2004/31",
|
| 606 |
+
"Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
|
| 607 |
+
"Health and Care Act 2022": "ukpga/2022/31",
|
| 608 |
+
}
|
| 609 |
+
REVERSE_ACTS = {v: k for k, v in NURSING_ACTS.items()}
|
| 610 |
+
|
| 611 |
+
# --- Gemini REST API ---
|
| 612 |
+
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
|
| 613 |
+
GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
| 614 |
+
GEMINI_MODELS = ["gemini-2.0-flash-lite", "gemini-2.0-flash"]
|
| 615 |
+
|
| 616 |
+
SYSTEM_PROMPT = """You are NurseLex, a legal literacy assistant for all UK nurses and nursing students.
|
| 617 |
+
|
| 618 |
+
Your role:
|
| 619 |
+
1. Answer legal questions using ONLY the legislation text provided in the context.
|
| 620 |
+
2. Explain the law in clear, plain English suitable for all nurses and nursing students.
|
| 621 |
+
3. Always cite the specific Act, section number, and year.
|
| 622 |
+
4. If the context doesn't contain enough information, say so clearly.
|
| 623 |
+
5. Add practical nursing implications (e.g., "In practice, this means...").
|
| 624 |
+
6. Include professional reminders (e.g., NMC Code, duty of care).
|
| 625 |
+
|
| 626 |
+
Disclaimers to include:
|
| 627 |
+
- "This is for educational purposes only — always consult your trust's legal team for specific cases."
|
| 628 |
+
- "This reflects the legislation as written — local trust policies may add additional requirements."
|
| 629 |
+
|
| 630 |
+
Format with clear headings, bullet points, and bold key terms."""
|
| 631 |
+
|
| 632 |
+
QUICK_QUESTIONS = [
|
| 633 |
+
"What is Section 5(4) of the Mental Health Act and when can a nurse use it?",
|
| 634 |
+
"What does the Mental Capacity Act say about best interests decisions?",
|
| 635 |
+
"When can a patient be detained under Section 2 vs Section 3?",
|
| 636 |
+
"What are the legal requirements for using restraint?",
|
| 637 |
+
"What does Section 117 aftercare mean and who is entitled?",
|
| 638 |
+
"What are a nurse's legal duties under the Care Act 2014 for safeguarding?",
|
| 639 |
+
"What is Deprivation of Liberty and when do DoLS apply?",
|
| 640 |
+
"What rights does a patient have under Section 136?",
|
| 641 |
+
]
|
| 642 |
+
|
| 643 |
+
async def call_gemini(prompt: str) -> str:
|
| 644 |
+
"""Call Gemini via REST API with retry logic and model fallback."""
|
| 645 |
+
if not GEMINI_API_KEY:
|
| 646 |
+
return ""
|
| 647 |
+
|
| 648 |
+
payload = {
|
| 649 |
+
"system_instruction": {"parts": [{"text": SYSTEM_PROMPT}]},
|
| 650 |
+
"contents": [{"parts": [{"text": prompt}]}],
|
| 651 |
+
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 2048},
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 655 |
+
for model in GEMINI_MODELS:
|
| 656 |
+
url = f"{GEMINI_BASE}/{model}:generateContent?key={GEMINI_API_KEY}"
|
| 657 |
+
for attempt in range(3):
|
| 658 |
+
try:
|
| 659 |
+
resp = await client.post(url, json=payload)
|
| 660 |
+
if resp.status_code == 429:
|
| 661 |
+
wait = 2 ** (attempt + 1)
|
| 662 |
+
logger.warning(f"Rate limited ({model}), retrying in {wait}s")
|
| 663 |
+
await asyncio.sleep(wait)
|
| 664 |
+
continue
|
| 665 |
+
resp.raise_for_status()
|
| 666 |
+
data = resp.json()
|
| 667 |
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
| 668 |
+
except httpx.HTTPStatusError as e:
|
| 669 |
+
if e.response.status_code == 429:
|
| 670 |
+
wait = 2 ** (attempt + 1)
|
| 671 |
+
logger.warning(f"Rate limited ({model}), retrying in {wait}s")
|
| 672 |
+
await asyncio.sleep(wait)
|
| 673 |
+
continue
|
| 674 |
+
logger.error(f"Gemini API error ({model}): {e.response.status_code}")
|
| 675 |
+
break
|
| 676 |
+
except Exception as e:
|
| 677 |
+
logger.error(f"Gemini error ({model}): {type(e).__name__}")
|
| 678 |
+
break
|
| 679 |
+
logger.info(f"Model {model} exhausted, trying next...")
|
| 680 |
+
|
| 681 |
+
logger.error("All Gemini models failed")
|
| 682 |
+
return ""
|
| 683 |
+
|
| 684 |
+
def search_legislation_index(query: str, max_results: int = 10) -> pd.DataFrame:
|
| 685 |
+
"""Search the full legislation index parquet by title."""
|
| 686 |
+
if LEG_DF.empty:
|
| 687 |
+
return pd.DataFrame()
|
| 688 |
+
|
| 689 |
+
mask = LEG_DF["title"].str.contains(query, case=False, na=False)
|
| 690 |
+
results = LEG_DF[mask].sort_values("year", ascending=False).head(max_results)
|
| 691 |
+
return results
|
| 692 |
+
|
| 693 |
+
async def query_and_respond(user_question: str, history: list) -> str:
|
| 694 |
+
"""Main RAG pipeline: local cached sections (1,128) + Gemini explanation."""
|
| 695 |
+
if not user_question.strip():
|
| 696 |
+
return "Please enter a question about UK healthcare legislation."
|
| 697 |
+
|
| 698 |
+
# Step 1: Search local legislation sections
|
| 699 |
+
sections = search_cached(user_question, max_results=5)
|
| 700 |
+
logger.info(f"Local search returned {len(sections)} sections for: {user_question[:60]}")
|
| 701 |
+
|
| 702 |
+
# Step 2: Search parquet index for related Acts
|
| 703 |
+
related_acts = search_legislation_index(user_question, max_results=5)
|
| 704 |
+
|
| 705 |
+
# Step 3: Build context
|
| 706 |
+
context_parts = []
|
| 707 |
+
for section in sections:
|
| 708 |
+
title = section.get("title", "Untitled")
|
| 709 |
+
text = section.get("text", "")
|
| 710 |
+
leg_id = section.get("legislation_id", "")
|
| 711 |
+
num = section.get("number", "")
|
| 712 |
+
context_parts.append(f"### {title}\n**Source:** {leg_id}, Section {num}\n\n{text}\n")
|
| 713 |
+
|
| 714 |
+
context = "\n---\n".join(context_parts) if context_parts else "No matching legislation sections found in cache."
|
| 715 |
+
|
| 716 |
+
# Step 4: Generate Gemini response
|
| 717 |
+
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."
|
| 718 |
+
|
| 719 |
+
answer = await call_gemini(prompt)
|
| 720 |
+
|
| 721 |
+
if not answer:
|
| 722 |
+
# Fallback: show raw legislation if Gemini fails or is missing key
|
| 723 |
+
answer = _build_fallback(user_question, sections)
|
| 724 |
+
if not GEMINI_API_KEY:
|
| 725 |
+
answer += "\n\n⚠️ *Set `GEMINI_API_KEY` in Space secrets for AI-powered plain English explanations.*"
|
| 726 |
+
elif "rate limit" in answer.lower():
|
| 727 |
+
answer += "\n\n⚠️ *Gemini is currently rate limited, falling back to raw legislation.*"
|
| 728 |
+
|
| 729 |
+
# Add source citations
|
| 730 |
+
source_acts = set()
|
| 731 |
+
for s in sections:
|
| 732 |
+
leg_id = s.get("legislation_id", "")
|
| 733 |
+
if leg_id:
|
| 734 |
+
source_acts.add(leg_id)
|
| 735 |
+
|
| 736 |
+
if source_acts:
|
| 737 |
+
answer += "\n\n---\n📚 **Sources:** "
|
| 738 |
+
answer += " | ".join(f"[{sid}](https://www.legislation.gov.uk/id/{sid})" for sid in sorted(source_acts))
|
| 739 |
+
|
| 740 |
+
# Add related Acts from parquet
|
| 741 |
+
if not related_acts.empty:
|
| 742 |
+
answer += "\n\n📖 **Related legislation:** "
|
| 743 |
+
act_links = []
|
| 744 |
+
for _, row in related_acts.head(3).iterrows():
|
| 745 |
+
uri = row.get("uri", "")
|
| 746 |
+
title = row.get("title", "")
|
| 747 |
+
if uri and title:
|
| 748 |
+
act_links.append(f"[{title}]({uri})")
|
| 749 |
+
if act_links:
|
| 750 |
+
answer += " | ".join(act_links)
|
| 751 |
+
|
| 752 |
+
answer += "\n\n🏛️ *Data from [legislation.gov.uk](https://www.legislation.gov.uk/) — Crown Copyright, OGL v3.0*"
|
| 753 |
+
return answer
|
| 754 |
+
|
| 755 |
+
def _build_fallback(question: str, sections: list) -> str:
|
| 756 |
+
"""Show raw legislation without LLM."""
|
| 757 |
+
response = f"## Legislation relevant to: *{question}*\n\n"
|
| 758 |
+
|
| 759 |
+
if not sections:
|
| 760 |
+
response += (
|
| 761 |
+
"No matching sections found in cache. Try searching the full **Browse Legislation** tab for the Act title, or try specific terms like:\n"
|
| 762 |
+
"- **\"Section 5(4)\"** or **\"nurse holding power\"**\n"
|
| 763 |
+
"- **\"best interests\"** or **\"capacity\"**\n"
|
| 764 |
+
"- **\"safeguarding\"** or **\"Section 42\"**\n"
|
| 765 |
+
"- **\"Section 136\"** or **\"place of safety\"**\n"
|
| 766 |
+
)
|
| 767 |
+
return response
|
| 768 |
+
|
| 769 |
+
for i, section in enumerate(sections[:5], 1):
|
| 770 |
+
title = section.get("title", "Untitled")
|
| 771 |
+
text = section.get("text", "No text available")
|
| 772 |
+
leg_id = section.get("legislation_id", "")
|
| 773 |
+
num = section.get("number", "")
|
| 774 |
+
uri = section.get("uri", "")
|
| 775 |
+
|
| 776 |
+
response += f"### {i}. {title}\n"
|
| 777 |
+
response += f"**Act:** `{leg_id}` | **Section:** {num}\n\n"
|
| 778 |
+
response += f"{text}\n\n"
|
| 779 |
+
if uri:
|
| 780 |
+
response += f"🔗 [View on legislation.gov.uk]({uri})\n\n"
|
| 781 |
+
response += "---\n\n"
|
| 782 |
+
|
| 783 |
+
return response
|
| 784 |
+
|
| 785 |
+
async def section_lookup(act_name: str, section_input: str) -> str:
|
| 786 |
+
"""Look up sections from cached legislation."""
|
| 787 |
+
legislation_id = NURSING_ACTS.get(act_name)
|
| 788 |
+
if not legislation_id:
|
| 789 |
+
return f"❌ Act not found in NurseLex."
|
| 790 |
+
|
| 791 |
+
cache_query = f"{act_name} section {section_input}" if section_input.strip() else act_name
|
| 792 |
+
sections = search_cached(cache_query, max_results=10)
|
| 793 |
+
sections = [s for s in sections if s.get("legislation_id") == legislation_id]
|
| 794 |
+
|
| 795 |
+
if section_input.strip() and sections:
|
| 796 |
+
try:
|
| 797 |
+
target_num = int(section_input.strip().replace("Section ", "").replace("s.", "").replace("S.", ""))
|
| 798 |
+
matching = [s for s in sections if s.get("number") == target_num]
|
| 799 |
+
if matching:
|
| 800 |
+
sections = matching
|
| 801 |
+
except ValueError:
|
| 802 |
+
pass
|
| 803 |
+
|
| 804 |
+
if not sections:
|
| 805 |
+
return (
|
| 806 |
+
f"⏳ Section not found in cache for **{act_name}**.\n\n"
|
| 807 |
+
f"Try the **Chat tab** for a broader search, or visit "
|
| 808 |
+
f"[legislation.gov.uk](https://www.legislation.gov.uk/id/{legislation_id}) directly."
|
| 809 |
+
)
|
| 810 |
+
|
| 811 |
+
result = f"## {act_name}\n\n"
|
| 812 |
+
for section in sections[:5]:
|
| 813 |
+
title = section.get("title", "Untitled")
|
| 814 |
+
text = section.get("text", "No text")
|
| 815 |
+
num = section.get("number", "")
|
| 816 |
+
uri = section.get("uri", "")
|
| 817 |
+
|
| 818 |
+
result += f"### Section {num}: {title}\n\n{text}\n\n"
|
| 819 |
+
if uri:
|
| 820 |
+
result += f"🔗 [View on legislation.gov.uk]({uri})\n\n"
|
| 821 |
+
result += "---\n\n"
|
| 822 |
+
|
| 823 |
+
result += "\n🏛️ *Crown Copyright, OGL v3.0*"
|
| 824 |
+
return result
|
| 825 |
+
|
| 826 |
+
async def fetch_explanatory_note(act_name: str, section_input: str) -> str:
|
| 827 |
+
"""Dynamically fetch Explanatory Notes from the i.AI Lex API."""
|
| 828 |
+
if not section_input.strip():
|
| 829 |
+
return "Please specify a section number to view its Explanatory Note."
|
| 830 |
+
|
| 831 |
+
try:
|
| 832 |
+
# Extract the digits
|
| 833 |
+
section_number = "".join([c for c in section_input if c.isdigit()])
|
| 834 |
+
if not section_number:
|
| 835 |
+
return "Please enter a valid section number."
|
| 836 |
+
|
| 837 |
+
url = 'https://lex.lab.i.ai.gov.uk/explanatory_note/section/search'
|
| 838 |
+
payload = {
|
| 839 |
+
'query': f'"{act_name}" Section {section_number}',
|
| 840 |
+
'limit': 5
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
async with httpx.AsyncClient() as client:
|
| 844 |
+
r = await client.post(url, json=payload, timeout=10.0)
|
| 845 |
+
if r.status_code == 200:
|
| 846 |
+
data = r.json()
|
| 847 |
+
if isinstance(data, list):
|
| 848 |
+
parent_id = NURSING_ACTS.get(act_name, "")
|
| 849 |
+
for note in data:
|
| 850 |
+
if parent_id and parent_id in note.get('legislation_id', ''):
|
| 851 |
+
text = note.get('text', '')
|
| 852 |
+
if text:
|
| 853 |
+
return f"### Official Explanatory Note\n\n{text}\n\n*Source: i.AI Lex API*"
|
| 854 |
+
|
| 855 |
+
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).*."
|
| 856 |
+
except httpx.TimeoutException:
|
| 857 |
+
return "⏳ API Timeout while fetching Explanatory Note."
|
| 858 |
+
except Exception as e:
|
| 859 |
+
return f"Error fetching note: {str(e)}"
|
| 860 |
+
|
| 861 |
+
async def scenario_search(scenario_text: str) -> str:
|
| 862 |
+
"""Use i.AI Lex vector search to map a clinical scenario to legal sections."""
|
| 863 |
+
if not scenario_text.strip():
|
| 864 |
+
return "Please describe a clinical scenario."
|
| 865 |
+
|
| 866 |
+
url = 'https://lex.lab.i.ai.gov.uk/legislation/section/search'
|
| 867 |
+
payload = {
|
| 868 |
+
'query': scenario_text,
|
| 869 |
+
'limit': 5
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
try:
|
| 873 |
+
async with httpx.AsyncClient() as client:
|
| 874 |
+
r = await client.post(url, json=payload, timeout=15.0)
|
| 875 |
+
if r.status_code != 200:
|
| 876 |
+
return f"⚠️ Lex API Error: {r.status_code}"
|
| 877 |
+
|
| 878 |
+
data = r.json()
|
| 879 |
+
if not isinstance(data, list) or not data:
|
| 880 |
+
return "No matching legislation found for this scenario."
|
| 881 |
+
|
| 882 |
+
result = f"## ⚖️ Probable Legislation Matches for:\n*{scenario_text}*\n\n"
|
| 883 |
+
|
| 884 |
+
for i, n in enumerate(data, 1):
|
| 885 |
+
leg_id = n.get("legislation_id", "")
|
| 886 |
+
|
| 887 |
+
# 1. Use the act_name from the API response if available
|
| 888 |
+
act_name = n.get("act_name", "")
|
| 889 |
+
|
| 890 |
+
# 2. If not, try our known mapping
|
| 891 |
+
if not act_name:
|
| 892 |
+
for known_id, known_name in REVERSE_ACTS.items():
|
| 893 |
+
if known_id in leg_id:
|
| 894 |
+
act_name = known_name
|
| 895 |
+
break
|
| 896 |
+
|
| 897 |
+
# 3. Final fallback: extract from the legislation_id URL
|
| 898 |
+
if not act_name:
|
| 899 |
+
act_name = leg_id.split("/id/")[-1] if "/id/" in leg_id else leg_id or "Legislation"
|
| 900 |
+
|
| 901 |
+
sec_num = n.get("number", "??")
|
| 902 |
+
title = n.get("title", "Untitled Section")
|
| 903 |
+
text = n.get("text", "")
|
| 904 |
+
uri = n.get("uri", f"https://www.legislation.gov.uk/id/{leg_id}/section/{sec_num}")
|
| 905 |
+
|
| 906 |
+
result += f"### {i}. {act_name} — Section {sec_num}: {title}\n"
|
| 907 |
+
result += f"{text[:800]}...\n\n"
|
| 908 |
+
result += f"🔗 [Read full text on legislation.gov.uk]({uri})\n\n---\n\n"
|
| 909 |
+
|
| 910 |
+
return result
|
| 911 |
+
except httpx.TimeoutException:
|
| 912 |
+
return "⏳ API Timeout while searching scenarios."
|
| 913 |
+
except Exception as e:
|
| 914 |
+
return f"Error during scenario search: {str(e)}"
|
| 915 |
+
|
| 916 |
+
def browse_legislation(search_term: str, act_type: str) -> str:
|
| 917 |
+
"""Browse the legislation index from the parquet file."""
|
| 918 |
+
if LEG_DF.empty:
|
| 919 |
+
return "⚠️ Legislation index not loaded."
|
| 920 |
+
|
| 921 |
+
filtered = LEG_DF.copy()
|
| 922 |
+
|
| 923 |
+
if act_type != "All":
|
| 924 |
+
type_map = {"Primary Acts": "ukpga", "Statutory Instruments": "uksi", "Scottish SIs": "ssi", "NI SRs": "nisr", "Welsh SIs": "wsi"}
|
| 925 |
+
if act_type in type_map:
|
| 926 |
+
filtered = filtered[filtered["type"] == type_map[act_type]]
|
| 927 |
+
|
| 928 |
+
if search_term.strip():
|
| 929 |
+
filtered = filtered[filtered["title"].str.contains(search_term, case=False, na=False)]
|
| 930 |
+
|
| 931 |
+
filtered = filtered.sort_values("year", ascending=False).head(50)
|
| 932 |
+
|
| 933 |
+
if filtered.empty:
|
| 934 |
+
return f"No legislation found matching '{search_term}'."
|
| 935 |
+
|
| 936 |
+
result = f"## 📖 Legislation Index ({len(filtered)} results)\n\n| Year | Title | Type |\n|---|---|---|\n"
|
| 937 |
+
|
| 938 |
+
for _, row in filtered.iterrows():
|
| 939 |
+
year = row.get("year", "—")
|
| 940 |
+
title = row.get("title", "Untitled")
|
| 941 |
+
uri = row.get("uri", "")
|
| 942 |
+
leg_type = row.get("type", "")
|
| 943 |
+
title_link = f"[{title}]({uri})" if uri else title
|
| 944 |
+
result += f"| {year} | {title_link} | {leg_type} |\n"
|
| 945 |
+
|
| 946 |
+
result += f"\n\n*Showing top 50 of {len(LEG_DF)} health & social care entries — {len(LEG_DF[LEG_DF['type']=='ukpga'])} Primary Acts*"
|
| 947 |
+
result += "\n\n🏛️ *Data from i.AI Lex bulk downloads — Crown Copyright, OGL v3.0*"
|
| 948 |
+
return result
|
| 949 |
+
|
| 950 |
+
# --- Gradio UI ---
|
| 951 |
+
THEME = gr.themes.Soft(
|
| 952 |
+
primary_hue="indigo",
|
| 953 |
+
secondary_hue="violet",
|
| 954 |
+
neutral_hue="slate",
|
| 955 |
+
font=gr.themes.GoogleFont("Inter"),
|
| 956 |
+
)
|
| 957 |
+
|
| 958 |
+
CSS = """
|
| 959 |
+
.gradio-container { max-width: 960px !important; }
|
| 960 |
+
.header-banner {
|
| 961 |
+
background: linear-gradient(135deg, #312e81 0%, #4338ca 50%, #6366f1 100%);
|
| 962 |
+
border-radius: 16px;
|
| 963 |
+
padding: 28px 32px;
|
| 964 |
+
margin-bottom: 16px;
|
| 965 |
+
color: white;
|
| 966 |
+
}
|
| 967 |
+
.header-banner h1 { color: white; font-size: 2em; margin: 0 0 8px 0; }
|
| 968 |
+
.header-banner p { color: #c7d2fe; margin: 0; font-size: 1.05em; }
|
| 969 |
+
.disclaimer-box {
|
| 970 |
+
background: #fef3c7;
|
| 971 |
+
border-left: 4px solid #f59e0b;
|
| 972 |
+
border-radius: 8px;
|
| 973 |
+
padding: 12px 16px;
|
| 974 |
+
margin-bottom: 12px;
|
| 975 |
+
font-size: 0.9em;
|
| 976 |
+
color: #92400e;
|
| 977 |
+
}
|
| 978 |
+
footer { display: none !important; }
|
| 979 |
+
"""
|
| 980 |
+
|
| 981 |
+
with gr.Blocks(theme=THEME, css=CSS, title="NurseLex — UK Law for All Nurses") as app:
|
| 982 |
+
gr.HTML("""
|
| 983 |
+
<div class="header-banner">
|
| 984 |
+
<h1>🏛️ NurseLex</h1>
|
| 985 |
+
<p>Legal literacy for all nurses and nursing students — powered by UK Government legislation data</p>
|
| 986 |
+
</div>
|
| 987 |
+
""")
|
| 988 |
+
|
| 989 |
+
gr.HTML("""
|
| 990 |
+
<div class="disclaimer-box">
|
| 991 |
+
⚠️ <strong>Educational tool only.</strong> NurseLex provides legislation text and AI-generated explanations for learning purposes.
|
| 992 |
+
It does not constitute legal advice. Always consult your trust's legal/governance team for specific cases.
|
| 993 |
+
</div>
|
| 994 |
+
""")
|
| 995 |
+
|
| 996 |
+
with gr.Tabs():
|
| 997 |
+
# --- Tab 1: Chat ---
|
| 998 |
+
with gr.TabItem("💬 Ask a Legal Question", id="chat"):
|
| 999 |
+
gr.Markdown("Ask about UK healthcare legislation — answers are grounded in **real statutory text**. (Cache: 1,128 Sections + 219K Acts)")
|
| 1000 |
+
|
| 1001 |
+
chatbot = gr.Chatbot(
|
| 1002 |
+
label="NurseLex",
|
| 1003 |
+
height=480,
|
| 1004 |
+
type="messages",
|
| 1005 |
+
show_copy_button=True,
|
| 1006 |
+
avatar_images=(None, "https://em-content.zobj.net/source/twitter/376/classical-building_1f3db-fe0f.png"),
|
| 1007 |
+
)
|
| 1008 |
+
msg = gr.Textbox(
|
| 1009 |
+
label="Your question",
|
| 1010 |
+
placeholder="e.g., What is Section 5(4) of the Mental Health Act and when can a nurse use it?",
|
| 1011 |
+
lines=2,
|
| 1012 |
+
)
|
| 1013 |
+
with gr.Row():
|
| 1014 |
+
submit_btn = gr.Button("🔍 Search Legislation", variant="primary", scale=2)
|
| 1015 |
+
clear_btn = gr.ClearButton([msg, chatbot], value="🗑️ Clear", scale=1)
|
| 1016 |
+
|
| 1017 |
+
gr.Markdown("### 💡 Quick Questions")
|
| 1018 |
+
with gr.Row(equal_height=True):
|
| 1019 |
+
for i in range(0, 4):
|
| 1020 |
+
gr.Button(
|
| 1021 |
+
QUICK_QUESTIONS[i][:55] + "..." if len(QUICK_QUESTIONS[i]) > 55 else QUICK_QUESTIONS[i],
|
| 1022 |
+
size="sm",
|
| 1023 |
+
variant="secondary",
|
| 1024 |
+
).click(fn=lambda q=QUICK_QUESTIONS[i]: q, outputs=msg)
|
| 1025 |
+
with gr.Row(equal_height=True):
|
| 1026 |
+
for i in range(4, 8):
|
| 1027 |
+
gr.Button(
|
| 1028 |
+
QUICK_QUESTIONS[i][:55] + "..." if len(QUICK_QUESTIONS[i]) > 55 else QUICK_QUESTIONS[i],
|
| 1029 |
+
size="sm",
|
| 1030 |
+
variant="secondary",
|
| 1031 |
+
).click(fn=lambda q=QUICK_QUESTIONS[i]: q, outputs=msg)
|
| 1032 |
+
|
| 1033 |
+
async def respond(message, history):
|
| 1034 |
+
history = history or []
|
| 1035 |
+
history.append({"role": "user", "content": message})
|
| 1036 |
+
answer = await query_and_respond(message, history)
|
| 1037 |
+
history.append({"role": "assistant", "content": answer})
|
| 1038 |
+
return "", history
|
| 1039 |
+
|
| 1040 |
+
submit_btn.click(respond, [msg, chatbot], [msg, chatbot])
|
| 1041 |
+
msg.submit(respond, [msg, chatbot], [msg, chatbot])
|
| 1042 |
+
|
| 1043 |
+
# --- Tab 2: Section Lookup ---
|
| 1044 |
+
with gr.TabItem("📖 Section Lookup", id="lookup"):
|
| 1045 |
+
gr.Markdown("Look up a **specific section** of key nursing Acts. Includes **Official Explanatory Notes** where available.")
|
| 1046 |
+
|
| 1047 |
+
with gr.Row():
|
| 1048 |
+
act_dropdown = gr.Dropdown(
|
| 1049 |
+
choices=list(NURSING_ACTS.keys()),
|
| 1050 |
+
label="Select Act",
|
| 1051 |
+
value="Mental Health Act 1983",
|
| 1052 |
+
)
|
| 1053 |
+
section_input_box = gr.Textbox(
|
| 1054 |
+
label="Section number",
|
| 1055 |
+
placeholder="e.g., 5 or 117 or 136",
|
| 1056 |
+
)
|
| 1057 |
+
|
| 1058 |
+
lookup_btn = gr.Button("🔍 Look Up Law & Notes", variant="primary")
|
| 1059 |
+
|
| 1060 |
+
with gr.Row():
|
| 1061 |
+
lookup_output = gr.Markdown(label="Statutory Text")
|
| 1062 |
+
note_output = gr.Markdown(label="Official Explanatory Note")
|
| 1063 |
+
|
| 1064 |
+
lookup_btn.click(section_lookup, [act_dropdown, section_input_box], lookup_output)
|
| 1065 |
+
lookup_btn.click(fetch_explanatory_note, [act_dropdown, section_input_box], note_output)
|
| 1066 |
+
|
| 1067 |
+
# --- Tab 3: Scenario Matcher ---
|
| 1068 |
+
with gr.TabItem("🧠 Scenario Matcher", id="scenario"):
|
| 1069 |
+
gr.Markdown("Describe a clinical scenario in plain English, and the **Lex Vector Search Engine** will map it to the most relevant UK laws.")
|
| 1070 |
+
|
| 1071 |
+
with gr.Row():
|
| 1072 |
+
scenario_input = gr.Textbox(
|
| 1073 |
+
label="Clinical Scenario",
|
| 1074 |
+
placeholder="e.g. 'Patient wants to leave the ward but lacks capacity' or 'Doctor orders restraint without DoLS'",
|
| 1075 |
+
lines=3
|
| 1076 |
+
)
|
| 1077 |
+
|
| 1078 |
+
scenario_btn = gr.Button("🤖 Find Relevant Law", variant="primary")
|
| 1079 |
+
scenario_output = gr.Markdown(label="Semantic Search Results")
|
| 1080 |
+
|
| 1081 |
+
scenario_btn.click(scenario_search, [scenario_input], scenario_output)
|
| 1082 |
+
|
| 1083 |
+
# --- Tab 4: Browse Legislation ---
|
| 1084 |
+
with gr.TabItem("📚 Browse Legislation", id="browse"):
|
| 1085 |
+
gr.Markdown(f"Browse **219,678** health & social care Acts and Statutory Instruments from the i.AI Lex dataset.")
|
| 1086 |
+
|
| 1087 |
+
with gr.Row():
|
| 1088 |
+
browse_search = gr.Textbox(
|
| 1089 |
+
label="Search legislation titles",
|
| 1090 |
+
placeholder="e.g., mental health, safeguarding, disability",
|
| 1091 |
+
)
|
| 1092 |
+
browse_type = gr.Dropdown(
|
| 1093 |
+
choices=["All", "Primary Acts", "Statutory Instruments", "Scottish SIs", "NI SRs", "Welsh SIs"],
|
| 1094 |
+
label="Type",
|
| 1095 |
+
value="All",
|
| 1096 |
+
)
|
| 1097 |
+
|
| 1098 |
+
browse_btn = gr.Button("🔍 Search", variant="primary")
|
| 1099 |
+
browse_output = gr.Markdown(label="Results")
|
| 1100 |
+
|
| 1101 |
+
browse_btn.click(browse_legislation, [browse_search, browse_type], browse_output)
|
| 1102 |
+
|
| 1103 |
+
# --- Tab 4: About ---
|
| 1104 |
+
with gr.TabItem("ℹ️ About", id="about"):
|
| 1105 |
+
gr.Markdown(f"""
|
| 1106 |
+
## About NurseLex
|
| 1107 |
+
|
| 1108 |
+
**NurseLex** is a universal legal literacy tool for **all nurses and nursing students**.
|
| 1109 |
+
|
| 1110 |
+
### How It Works
|
| 1111 |
+
|
| 1112 |
+
1. **You ask a question** about UK healthcare law
|
| 1113 |
+
2. **Cached legislation** provides the actual statutory text instantly
|
| 1114 |
+
3. **Gemini Flash** explains it in plain English with practical nursing implications
|
| 1115 |
+
4. **Every answer cites** the specific Act, section, and year
|
| 1116 |
+
|
| 1117 |
+
### Data
|
| 1118 |
+
|
| 1119 |
+
- **219,678 legislation entries** from the [i.AI Lex](https://lex.lab.i.ai.gov.uk/) bulk dataset
|
| 1120 |
+
- **1,128 key sections** pre-cached with full text (MHA 1983, MCA 2005, Care Act 2014)
|
| 1121 |
+
- **Crown Copyright** — Open Government Licence v3.0
|
| 1122 |
+
|
| 1123 |
+
### Key Acts Covered
|
| 1124 |
+
|
| 1125 |
+
| Act | Key Sections | Nursing Relevance |
|
| 1126 |
+
|---|---|---|
|
| 1127 |
+
| Mental Health Act 1983 | S.2, S.3, S.4, S.5(2), S.5(4), S.17, S.117, S.135, S.136 | Detention, holding powers, leave, aftercare |
|
| 1128 |
+
| Mental Capacity Act 2005 | S.1 (Principles), S.2-3 (Capacity), S.4 (Best Interests), S.5 | Capacity assessments, best interests, DoLS |
|
| 1129 |
+
| Care Act 2014 | S.42 (Safeguarding), S.67 (Advocacy) | Safeguarding adults, independent advocates |
|
| 1130 |
+
|
| 1131 |
+
### Built By
|
| 1132 |
+
|
| 1133 |
+
**NurseCitizenDeveloper** — NHS Registered Nurse building AI tools for nursing education.
|
| 1134 |
+
|
| 1135 |
+
🤗 [Hugging Face](https://huggingface.co/NurseCitizenDeveloper) · 🐙 [GitHub](https://github.com/Clinical-Quality-Intelligence)
|
| 1136 |
+
""")
|
| 1137 |
+
|
| 1138 |
+
app.queue()
|
| 1139 |
+
|
| 1140 |
+
if __name__ == "__main__":
|
| 1141 |
+
app.launch(server_name="0.0.0.0", server_port=7860)
|
| 1142 |
+
>>>>>>> a4e257b16d56f80612b7c9ac6d2e7c198fef5bb6
|
cached_legislation.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<<<<<<< HEAD
|
| 2 |
+
"""
|
| 3 |
+
NurseLex — Pre-cached legislation for offline lookup.
|
| 4 |
+
Loads full section text from nursing_sections.json (1,000+ sections).
|
| 5 |
+
Source: legislation.gov.uk (Crown Copyright, OGL v3.0) via i.AI Lex API.
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# --- Configuration ---
|
| 14 |
+
JSON_PATH = os.path.join(os.path.dirname(__file__), "nursing_sections.json")
|
| 15 |
+
|
| 16 |
+
# --- Load Sections ---
|
| 17 |
+
CACHED_SECTIONS = []
|
| 18 |
+
try:
|
| 19 |
+
if os.path.exists(JSON_PATH):
|
| 20 |
+
with open(JSON_PATH, "r", encoding="utf-8") as f:
|
| 21 |
+
CACHED_SECTIONS = json.load(f)
|
| 22 |
+
logger.info(f"Loaded {len(CACHED_SECTIONS)} sections from {JSON_PATH}")
|
| 23 |
+
else:
|
| 24 |
+
logger.warning(f"Metadata file {JSON_PATH} not found.")
|
| 25 |
+
except Exception as e:
|
| 26 |
+
logger.error(f"Error loading {JSON_PATH}: {e}")
|
| 27 |
+
|
| 28 |
+
# --- Keyword Map for Natural Language Shortcuts ---
|
| 29 |
+
# Direct section links for common nursing search terms
|
| 30 |
+
KEYWORD_MAP = {
|
| 31 |
+
"nurse holding power": ("ukpga/1983/20", "5(4)"),
|
| 32 |
+
"doctor holding power": ("ukpga/1983/20", "5(2)"),
|
| 33 |
+
"section 2": ("ukpga/1983/20", "2"),
|
| 34 |
+
"section 3": ("ukpga/1983/20", "3"),
|
| 35 |
+
"section 4": ("ukpga/1983/20", "4"),
|
| 36 |
+
"aftercare": ("ukpga/1983/20", "117"),
|
| 37 |
+
"section 117": ("ukpga/1983/20", "117"),
|
| 38 |
+
"leave of absence": ("ukpga/1983/20", "17"),
|
| 39 |
+
"section 17": ("ukpga/1983/20", "17"),
|
| 40 |
+
"place of safety": ("ukpga/1983/20", "136"),
|
| 41 |
+
"section 136": ("ukpga/1983/20", "136"),
|
| 42 |
+
"section 135": ("ukpga/1983/20", "135"),
|
| 43 |
+
"best interests": ("ukpga/2005/9", "4"),
|
| 44 |
+
"capacity test": ("ukpga/2005/9", "3"),
|
| 45 |
+
"functional test": ("ukpga/2005/9", "3"),
|
| 46 |
+
"mca principles": ("ukpga/2005/9", "1"),
|
| 47 |
+
"safeguarding": ("ukpga/2014/23", "42"),
|
| 48 |
+
"section 42": ("ukpga/2014/23", "42"),
|
| 49 |
+
"advocacy": ("ukpga/2014/23", "67"),
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
def search_cached(query: str, max_results: int = 5) -> list:
|
| 53 |
+
"""
|
| 54 |
+
Search local sections by keyword, title, or legislation ID.
|
| 55 |
+
Returns a list of section dictionaries.
|
| 56 |
+
"""
|
| 57 |
+
if not query:
|
| 58 |
+
return []
|
| 59 |
+
|
| 60 |
+
query = query.lower().strip()
|
| 61 |
+
results = []
|
| 62 |
+
|
| 63 |
+
# 1. Check Keyword Map First (High Precision)
|
| 64 |
+
for kw, (leg_id, sec_num) in KEYWORD_MAP.items():
|
| 65 |
+
if kw in query:
|
| 66 |
+
# Find the specific section in our list
|
| 67 |
+
for s in CACHED_SECTIONS:
|
| 68 |
+
if s.get("legislation_id") == leg_id and str(s.get("number")) == sec_num:
|
| 69 |
+
if s not in results:
|
| 70 |
+
results.append(s)
|
| 71 |
+
# Find closest matches if exact number not found (e.g. 5(4) vs 5)
|
| 72 |
+
if not results:
|
| 73 |
+
for s in CACHED_SECTIONS:
|
| 74 |
+
if s.get("legislation_id") == leg_id and str(s.get("number")).startswith(sec_num.split('(')[0]):
|
| 75 |
+
if s not in results:
|
| 76 |
+
results.append(s)
|
| 77 |
+
|
| 78 |
+
# 2. Text-based Search
|
| 79 |
+
# Sort sections by relevance (title match > text match)
|
| 80 |
+
scored_results = []
|
| 81 |
+
for s in CACHED_SECTIONS:
|
| 82 |
+
score = 0
|
| 83 |
+
title = s.get("title", "").lower()
|
| 84 |
+
text = s.get("text", "").lower()
|
| 85 |
+
leg_id = s.get("legislation_id", "").lower()
|
| 86 |
+
num = str(s.get("number", "")).lower()
|
| 87 |
+
|
| 88 |
+
# Exact section reference (e.g. "Section 5")
|
| 89 |
+
if f"section {num}" in query or f"s.{num}" in query or f"s {num}" in query:
|
| 90 |
+
score += 100
|
| 91 |
+
|
| 92 |
+
# Title matches
|
| 93 |
+
if query in title:
|
| 94 |
+
score += 50
|
| 95 |
+
|
| 96 |
+
# Word matches in title
|
| 97 |
+
for word in query.split():
|
| 98 |
+
if len(word) > 3 and word in title:
|
| 99 |
+
score += 10
|
| 100 |
+
|
| 101 |
+
# Content matches
|
| 102 |
+
if query in text:
|
| 103 |
+
score += 5
|
| 104 |
+
|
| 105 |
+
if score > 0:
|
| 106 |
+
scored_results.append((score, s))
|
| 107 |
+
|
| 108 |
+
# Sort and add to results
|
| 109 |
+
scored_results.sort(key=lambda x: x[0], reverse=True)
|
| 110 |
+
for _, s in scored_results:
|
| 111 |
+
if s not in results:
|
| 112 |
+
results.append(s)
|
| 113 |
+
if len(results) >= max_results:
|
| 114 |
+
break
|
| 115 |
+
|
| 116 |
+
return results[:max_results]
|
| 117 |
+
=======
|
| 118 |
+
"""
|
| 119 |
+
NurseLex — Pre-cached legislation for offline lookup.
|
| 120 |
+
Loads full section text from nursing_sections.json (1,000+ sections).
|
| 121 |
+
Source: legislation.gov.uk (Crown Copyright, OGL v3.0) via i.AI Lex API.
|
| 122 |
+
"""
|
| 123 |
+
import os
|
| 124 |
+
import json
|
| 125 |
+
import logging
|
| 126 |
+
|
| 127 |
+
logger = logging.getLogger(__name__)
|
| 128 |
+
|
| 129 |
+
# --- Configuration ---
|
| 130 |
+
JSON_PATH = os.path.join(os.path.dirname(__file__), "nursing_sections.json")
|
| 131 |
+
|
| 132 |
+
# --- Load Sections ---
|
| 133 |
+
CACHED_SECTIONS = []
|
| 134 |
+
try:
|
| 135 |
+
if os.path.exists(JSON_PATH):
|
| 136 |
+
with open(JSON_PATH, "r", encoding="utf-8") as f:
|
| 137 |
+
CACHED_SECTIONS = json.load(f)
|
| 138 |
+
logger.info(f"Loaded {len(CACHED_SECTIONS)} sections from {JSON_PATH}")
|
| 139 |
+
else:
|
| 140 |
+
logger.warning(f"Metadata file {JSON_PATH} not found.")
|
| 141 |
+
except Exception as e:
|
| 142 |
+
logger.error(f"Error loading {JSON_PATH}: {e}")
|
| 143 |
+
|
| 144 |
+
# --- Keyword Map for Natural Language Shortcuts ---
|
| 145 |
+
# Direct section links for common nursing search terms
|
| 146 |
+
KEYWORD_MAP = {
|
| 147 |
+
"nurse holding power": ("ukpga/1983/20", "5(4)"),
|
| 148 |
+
"doctor holding power": ("ukpga/1983/20", "5(2)"),
|
| 149 |
+
"section 2": ("ukpga/1983/20", "2"),
|
| 150 |
+
"section 3": ("ukpga/1983/20", "3"),
|
| 151 |
+
"section 4": ("ukpga/1983/20", "4"),
|
| 152 |
+
"aftercare": ("ukpga/1983/20", "117"),
|
| 153 |
+
"section 117": ("ukpga/1983/20", "117"),
|
| 154 |
+
"leave of absence": ("ukpga/1983/20", "17"),
|
| 155 |
+
"section 17": ("ukpga/1983/20", "17"),
|
| 156 |
+
"place of safety": ("ukpga/1983/20", "136"),
|
| 157 |
+
"section 136": ("ukpga/1983/20", "136"),
|
| 158 |
+
"section 135": ("ukpga/1983/20", "135"),
|
| 159 |
+
"best interests": ("ukpga/2005/9", "4"),
|
| 160 |
+
"capacity test": ("ukpga/2005/9", "3"),
|
| 161 |
+
"functional test": ("ukpga/2005/9", "3"),
|
| 162 |
+
"mca principles": ("ukpga/2005/9", "1"),
|
| 163 |
+
"safeguarding": ("ukpga/2014/23", "42"),
|
| 164 |
+
"section 42": ("ukpga/2014/23", "42"),
|
| 165 |
+
"advocacy": ("ukpga/2014/23", "67"),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
def search_cached(query: str, max_results: int = 5) -> list:
|
| 169 |
+
"""
|
| 170 |
+
Search local sections by keyword, title, or legislation ID.
|
| 171 |
+
Returns a list of section dictionaries.
|
| 172 |
+
"""
|
| 173 |
+
if not query:
|
| 174 |
+
return []
|
| 175 |
+
|
| 176 |
+
query = query.lower().strip()
|
| 177 |
+
results = []
|
| 178 |
+
|
| 179 |
+
# 1. Check Keyword Map First (High Precision)
|
| 180 |
+
for kw, (leg_id, sec_num) in KEYWORD_MAP.items():
|
| 181 |
+
if kw in query:
|
| 182 |
+
# Find the specific section in our list
|
| 183 |
+
for s in CACHED_SECTIONS:
|
| 184 |
+
if s.get("legislation_id") == leg_id and str(s.get("number")) == sec_num:
|
| 185 |
+
if s not in results:
|
| 186 |
+
results.append(s)
|
| 187 |
+
# Find closest matches if exact number not found (e.g. 5(4) vs 5)
|
| 188 |
+
if not results:
|
| 189 |
+
for s in CACHED_SECTIONS:
|
| 190 |
+
if s.get("legislation_id") == leg_id and str(s.get("number")).startswith(sec_num.split('(')[0]):
|
| 191 |
+
if s not in results:
|
| 192 |
+
results.append(s)
|
| 193 |
+
|
| 194 |
+
# 2. Text-based Search
|
| 195 |
+
# Sort sections by relevance (title match > text match)
|
| 196 |
+
scored_results = []
|
| 197 |
+
for s in CACHED_SECTIONS:
|
| 198 |
+
score = 0
|
| 199 |
+
title = s.get("title", "").lower()
|
| 200 |
+
text = s.get("text", "").lower()
|
| 201 |
+
leg_id = s.get("legislation_id", "").lower()
|
| 202 |
+
num = str(s.get("number", "")).lower()
|
| 203 |
+
|
| 204 |
+
# Exact section reference (e.g. "Section 5")
|
| 205 |
+
if f"section {num}" in query or f"s.{num}" in query or f"s {num}" in query:
|
| 206 |
+
score += 100
|
| 207 |
+
|
| 208 |
+
# Title matches
|
| 209 |
+
if query in title:
|
| 210 |
+
score += 50
|
| 211 |
+
|
| 212 |
+
# Word matches in title
|
| 213 |
+
for word in query.split():
|
| 214 |
+
if len(word) > 3 and word in title:
|
| 215 |
+
score += 10
|
| 216 |
+
|
| 217 |
+
# Content matches
|
| 218 |
+
if query in text:
|
| 219 |
+
score += 5
|
| 220 |
+
|
| 221 |
+
if score > 0:
|
| 222 |
+
scored_results.append((score, s))
|
| 223 |
+
|
| 224 |
+
# Sort and add to results
|
| 225 |
+
scored_results.sort(key=lambda x: x[0], reverse=True)
|
| 226 |
+
for _, s in scored_results:
|
| 227 |
+
if s not in results:
|
| 228 |
+
results.append(s)
|
| 229 |
+
if len(results) >= max_results:
|
| 230 |
+
break
|
| 231 |
+
|
| 232 |
+
return results[:max_results]
|
| 233 |
+
>>>>>>> a4e257b16d56f80612b7c9ac6d2e7c198fef5bb6
|
download_notes.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import httpx
|
| 3 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 4 |
+
|
| 5 |
+
BASE_URL = 'https://lex.lab.i.ai.gov.uk'
|
| 6 |
+
INPUT_FILE = 'nursing_sections.json'
|
| 7 |
+
|
| 8 |
+
def fetch_note_for_section(section):
|
| 9 |
+
url = f'{BASE_URL}/explanatory_note/section/search'
|
| 10 |
+
|
| 11 |
+
act_title = section.get('act_name', '')
|
| 12 |
+
section_number = section.get('number', '')
|
| 13 |
+
parent_leg_id = section.get('legislation_id', '')
|
| 14 |
+
|
| 15 |
+
if not act_title or not section_number:
|
| 16 |
+
return None
|
| 17 |
+
|
| 18 |
+
query = f'"{act_title}" Section {section_number}'
|
| 19 |
+
|
| 20 |
+
payload = {
|
| 21 |
+
'query': query,
|
| 22 |
+
'limit': 5
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
r = httpx.post(url, json=payload, timeout=15)
|
| 27 |
+
r.raise_for_status()
|
| 28 |
+
data = r.json()
|
| 29 |
+
|
| 30 |
+
# We need to find a note that actually belongs to this Act
|
| 31 |
+
if isinstance(data, list):
|
| 32 |
+
for note in data:
|
| 33 |
+
note_leg_id = note.get('legislation_id', '')
|
| 34 |
+
if note_leg_id and parent_leg_id in note_leg_id:
|
| 35 |
+
# Double check the text or title mentions the section
|
| 36 |
+
# Explanatory notes usually format like "Section 2: ..." or "2. ..." or "Paragraph 2"
|
| 37 |
+
return {
|
| 38 |
+
'section_uri': section.get('uri'),
|
| 39 |
+
'act_name': act_title,
|
| 40 |
+
'section_number': section_number,
|
| 41 |
+
'note_text': note.get('text', '')
|
| 42 |
+
}
|
| 43 |
+
except Exception as e:
|
| 44 |
+
print(f"Error for {query}: {e}")
|
| 45 |
+
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
def main():
|
| 49 |
+
print("Loading sections...")
|
| 50 |
+
with open(INPUT_FILE, 'r', encoding='utf-8') as f:
|
| 51 |
+
sections = json.load(f)
|
| 52 |
+
|
| 53 |
+
print(f"Loaded {len(sections)} sections.")
|
| 54 |
+
|
| 55 |
+
# Test on a small but diverse subset (MHA 1983, MCA 2005)
|
| 56 |
+
test_sections = []
|
| 57 |
+
has_mca = False
|
| 58 |
+
for s in sections:
|
| 59 |
+
if s.get('number') in [2, 3, 5, 136]:
|
| 60 |
+
test_sections.append(s)
|
| 61 |
+
if 'Capacity' in s.get('act_name', ''):
|
| 62 |
+
has_mca = True
|
| 63 |
+
if len(test_sections) > 50 and has_mca:
|
| 64 |
+
break
|
| 65 |
+
|
| 66 |
+
test_sections = test_sections[:20]
|
| 67 |
+
|
| 68 |
+
print(f"Testing {len(test_sections)} sections...")
|
| 69 |
+
notes = {}
|
| 70 |
+
|
| 71 |
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
| 72 |
+
future_to_section = {executor.submit(fetch_note_for_section, s): s for s in test_sections}
|
| 73 |
+
for future in as_completed(future_to_section):
|
| 74 |
+
s = future_to_section[future]
|
| 75 |
+
result = future.result()
|
| 76 |
+
if result:
|
| 77 |
+
notes[s['uri']] = result
|
| 78 |
+
print(f"✅ Found note for {result['act_name']} S.{result['section_number']}")
|
| 79 |
+
else:
|
| 80 |
+
print(f"❌ No note found for {s.get('act_name')} S.{s.get('number')}")
|
| 81 |
+
|
| 82 |
+
print(f"Found {len(notes)} notes in test batch.")
|
| 83 |
+
|
| 84 |
+
with open('test_notes.json', 'w', encoding='utf-8') as f:
|
| 85 |
+
json.dump(notes, f, indent=2)
|
| 86 |
+
|
| 87 |
+
if __name__ == '__main__':
|
| 88 |
+
main()
|
download_sections.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download all sections for key nursing Acts from the Lex API.
|
| 3 |
+
Saves as nursing_sections.json for offline use.
|
| 4 |
+
"""
|
| 5 |
+
import httpx
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
BASE = "https://lex.lab.i.ai.gov.uk"
|
| 10 |
+
|
| 11 |
+
NURSING_ACTS = {
|
| 12 |
+
"Mental Health Act 1983": "ukpga/1983/20",
|
| 13 |
+
"Mental Capacity Act 2005": "ukpga/2005/9",
|
| 14 |
+
"Care Act 2014": "ukpga/2014/23",
|
| 15 |
+
"Human Rights Act 1998": "ukpga/1998/42",
|
| 16 |
+
"Equality Act 2010": "ukpga/2010/15",
|
| 17 |
+
"Health and Social Care Act 2012": "ukpga/2012/7",
|
| 18 |
+
"Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
|
| 19 |
+
"Autism Act 2009": "ukpga/2009/15",
|
| 20 |
+
"Children Act 1989": "ukpga/1989/41",
|
| 21 |
+
"Children Act 2004": "ukpga/2004/31",
|
| 22 |
+
"Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
all_sections = []
|
| 26 |
+
client = httpx.Client(timeout=60.0)
|
| 27 |
+
|
| 28 |
+
for act_name, leg_id in NURSING_ACTS.items():
|
| 29 |
+
print(f"\n--- {act_name} ({leg_id}) ---")
|
| 30 |
+
|
| 31 |
+
# Try searching for all sections of this Act
|
| 32 |
+
try:
|
| 33 |
+
# Use section search endpoint
|
| 34 |
+
resp = client.post(
|
| 35 |
+
f"{BASE}/legislation/section/search",
|
| 36 |
+
json={
|
| 37 |
+
"query": act_name,
|
| 38 |
+
"legislation_id": leg_id,
|
| 39 |
+
"size": 200,
|
| 40 |
+
"include_text": True,
|
| 41 |
+
},
|
| 42 |
+
)
|
| 43 |
+
resp.raise_for_status()
|
| 44 |
+
data = resp.json()
|
| 45 |
+
|
| 46 |
+
sections = data if isinstance(data, list) else data.get("results", data.get("sections", []))
|
| 47 |
+
print(f" Got {len(sections)} sections")
|
| 48 |
+
|
| 49 |
+
for section in sections:
|
| 50 |
+
section["act_name"] = act_name
|
| 51 |
+
section["legislation_id"] = leg_id
|
| 52 |
+
all_sections.append(section)
|
| 53 |
+
|
| 54 |
+
time.sleep(1) # Rate limiting
|
| 55 |
+
|
| 56 |
+
except Exception as e:
|
| 57 |
+
print(f" ERROR: {type(e).__name__}: {e}")
|
| 58 |
+
|
| 59 |
+
# Fallback: try getting sections one by one using browse
|
| 60 |
+
try:
|
| 61 |
+
resp2 = client.get(
|
| 62 |
+
f"{BASE}/legislation/{leg_id}/sections",
|
| 63 |
+
params={"limit": 200},
|
| 64 |
+
)
|
| 65 |
+
resp2.raise_for_status()
|
| 66 |
+
data2 = resp2.json()
|
| 67 |
+
sections2 = data2 if isinstance(data2, list) else data2.get("sections", [])
|
| 68 |
+
print(f" Fallback got {len(sections2)} sections")
|
| 69 |
+
|
| 70 |
+
for section in sections2:
|
| 71 |
+
section["act_name"] = act_name
|
| 72 |
+
section["legislation_id"] = leg_id
|
| 73 |
+
all_sections.append(section)
|
| 74 |
+
|
| 75 |
+
except Exception as e2:
|
| 76 |
+
print(f" Fallback also failed: {type(e2).__name__}: {e2}")
|
| 77 |
+
|
| 78 |
+
time.sleep(1)
|
| 79 |
+
|
| 80 |
+
client.close()
|
| 81 |
+
|
| 82 |
+
print(f"\n=== TOTAL: {len(all_sections)} sections ===")
|
| 83 |
+
|
| 84 |
+
# Save
|
| 85 |
+
output_path = r"c:\Users\g0226\Downloads\Ai Education\NurseLex\nursing_sections.json"
|
| 86 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 87 |
+
json.dump(all_sections, f, indent=2, ensure_ascii=False)
|
| 88 |
+
|
| 89 |
+
print(f"Saved to {output_path}")
|
| 90 |
+
print(f"File size: {len(json.dumps(all_sections)) // 1024} KB")
|
| 91 |
+
|
| 92 |
+
# Show sample
|
| 93 |
+
if all_sections:
|
| 94 |
+
s = all_sections[0]
|
| 95 |
+
print(f"\nSample keys: {list(s.keys())}")
|
| 96 |
+
print(f"Sample title: {s.get('title', '?')}")
|
| 97 |
+
print(f"Sample text preview: {str(s.get('text', ''))[:200]}")
|
fetch_schema.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
base_url = 'https://lex.lab.i.ai.gov.uk'
|
| 5 |
+
openapi_url = f'{base_url}/openapi.json'
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
r = httpx.get(openapi_url, timeout=10)
|
| 9 |
+
r.raise_for_status()
|
| 10 |
+
spec = r.json()
|
| 11 |
+
|
| 12 |
+
print('--- Available Endpoints ---')
|
| 13 |
+
for path, methods in spec.get('paths', {}).items():
|
| 14 |
+
if 'search' in path or 'explanatory_note' in path or 'similar' in path:
|
| 15 |
+
print(f'{path}:')
|
| 16 |
+
for method, details in methods.items():
|
| 17 |
+
print(f' - {method.upper()}: {details.get("summary", "")}')
|
| 18 |
+
|
| 19 |
+
print('\n--- Search Request Schemas ---')
|
| 20 |
+
schemas = spec.get('components', {}).get('schemas', {})
|
| 21 |
+
for name, schema in schemas.items():
|
| 22 |
+
if 'Search' in name or 'Explanatory' in name:
|
| 23 |
+
print(f'{name}:')
|
| 24 |
+
props = schema.get('properties', {})
|
| 25 |
+
for p_name, p_details in props.items():
|
| 26 |
+
p_type = p_details.get('type', 'unknown')
|
| 27 |
+
if p_type == 'unknown' and 'anyOf' in p_details:
|
| 28 |
+
p_type = 'anyOf'
|
| 29 |
+
print(f' - {p_name} ({p_type})')
|
| 30 |
+
except Exception as e:
|
| 31 |
+
print(f'Error: {type(e).__name__} - {e}')
|
filter_parquet.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Filter legislation.parquet to nursing-relevant Acts & SIs."""
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
df = pd.read_parquet(r"c:\Users\g0226\Downloads\Ai Education\NurseLex\legislation.parquet")
|
| 5 |
+
print(f"Total rows: {len(df)}")
|
| 6 |
+
|
| 7 |
+
# Search terms relevant to nursing
|
| 8 |
+
health_terms = [
|
| 9 |
+
"mental health", "mental capacity", "care act",
|
| 10 |
+
"human rights", "equality", "autism", "safeguarding",
|
| 11 |
+
"health and social care", "health and care",
|
| 12 |
+
"nurse", "nursing", "nhs", "disability",
|
| 13 |
+
"learning disability", "deprivation of liberty",
|
| 14 |
+
"children act", "vulnerable", "social care",
|
| 15 |
+
"community care", "welfare", "hospital", "patient",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
mask = pd.Series(False, index=df.index)
|
| 19 |
+
for term in health_terms:
|
| 20 |
+
mask = mask | df["title"].str.contains(term, case=False, na=False)
|
| 21 |
+
|
| 22 |
+
filtered = df[mask].copy()
|
| 23 |
+
print(f"Filtered: {len(filtered)} rows")
|
| 24 |
+
print(f"\nTypes breakdown:")
|
| 25 |
+
print(filtered["type"].value_counts().head(10).to_string())
|
| 26 |
+
|
| 27 |
+
# Show sample primary Acts
|
| 28 |
+
acts = filtered[filtered["type"] == "ukpga"].sort_values("year", ascending=False)
|
| 29 |
+
print(f"\nPrimary Acts: {len(acts)}")
|
| 30 |
+
for _, r in acts.head(15).iterrows():
|
| 31 |
+
print(f" {r['year']} - {r['title']}")
|
| 32 |
+
|
| 33 |
+
# Save filtered
|
| 34 |
+
filtered.to_parquet(
|
| 35 |
+
r"c:\Users\g0226\Downloads\Ai Education\NurseLex\nursing_legislation.parquet",
|
| 36 |
+
index=False,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
import os
|
| 40 |
+
fsize = os.path.getsize(r"c:\Users\g0226\Downloads\Ai Education\NurseLex\nursing_legislation.parquet")
|
| 41 |
+
print(f"\nSaved nursing_legislation.parquet: {fsize // 1024} KB")
|
inspect_parquet.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quick inspection of legislation.parquet"""
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
df = pd.read_parquet(r"c:\Users\g0226\Downloads\Ai Education\NurseLex\legislation.parquet")
|
| 5 |
+
print(f"Shape: {df.shape}")
|
| 6 |
+
print(f"\nColumns: {list(df.columns)}")
|
| 7 |
+
print(f"\nSample IDs: {df['id'].head(5).tolist()}")
|
| 8 |
+
print(f"\nSample URIs: {df['uri'].head(5).tolist()}")
|
| 9 |
+
print(f"\nSample titles: {df['title'].head(5).tolist()}")
|
| 10 |
+
|
| 11 |
+
# Search for nursing acts
|
| 12 |
+
for term in ["Mental Health Act", "Mental Capacity", "Care Act 2014", "Human Rights Act", "Equality Act 2010"]:
|
| 13 |
+
matches = df[df["title"].str.contains(term, case=False, na=False)]
|
| 14 |
+
print(f"\nTitle '{term}': {len(matches)} matches")
|
| 15 |
+
if len(matches) > 0:
|
| 16 |
+
row = matches.iloc[0]
|
| 17 |
+
print(f" title: {row['title']}")
|
| 18 |
+
print(f" id: {row['id']}")
|
| 19 |
+
print(f" year: {row.get('year', '?')}")
|
| 20 |
+
print(f" type: {row.get('type', '?')}")
|
| 21 |
+
desc = str(row.get("description", ""))
|
| 22 |
+
print(f" description ({len(desc)} chars): {desc[:300]}")
|
legislation.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8ea45e3d521df6017e698ee29c77cd210e05663290f036eaea152b3ab74fcb1e
|
| 3 |
+
size 67816228
|
lex_client.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<<<<<<< HEAD
|
| 2 |
+
"""
|
| 3 |
+
NurseLex — Lex API Client
|
| 4 |
+
Wraps the i.AI Lex API for nursing-focused UK legislation search.
|
| 5 |
+
"""
|
| 6 |
+
import httpx
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
LEX_API_BASE = "https://lex.lab.i.ai.gov.uk"
|
| 13 |
+
LEX_TIMEOUT = 60.0 # Lex API can be slow for semantic search
|
| 14 |
+
|
| 15 |
+
# Key legislation IDs for mental health & learning disability nursing
|
| 16 |
+
NURSING_LEGISLATION = {
|
| 17 |
+
"Mental Health Act 1983": "ukpga/1983/20",
|
| 18 |
+
"Mental Capacity Act 2005": "ukpga/2005/9",
|
| 19 |
+
"Care Act 2014": "ukpga/2014/23",
|
| 20 |
+
"Human Rights Act 1998": "ukpga/1998/42",
|
| 21 |
+
"Equality Act 2010": "ukpga/2010/15",
|
| 22 |
+
"Health and Social Care Act 2012": "ukpga/2012/7",
|
| 23 |
+
"Children Act 1989": "ukpga/1989/41",
|
| 24 |
+
"Children Act 2004": "ukpga/2004/31",
|
| 25 |
+
"Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
|
| 26 |
+
"Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
|
| 27 |
+
"Health and Care Act 2022": "ukpga/2022/31",
|
| 28 |
+
"Autism Act 2009": "ukpga/2009/15",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def _post(endpoint: str, payload: dict) -> dict | list:
|
| 33 |
+
"""Make a POST request to the Lex API with retry logic."""
|
| 34 |
+
url = f"{LEX_API_BASE}{endpoint}"
|
| 35 |
+
for attempt in range(3):
|
| 36 |
+
try:
|
| 37 |
+
async with httpx.AsyncClient(timeout=LEX_TIMEOUT) as client:
|
| 38 |
+
resp = await client.post(url, json=payload)
|
| 39 |
+
resp.raise_for_status()
|
| 40 |
+
return resp.json()
|
| 41 |
+
except httpx.TimeoutException:
|
| 42 |
+
logger.warning(f"Lex API timeout (attempt {attempt + 1}/3): {endpoint}")
|
| 43 |
+
if attempt == 2:
|
| 44 |
+
raise
|
| 45 |
+
except httpx.HTTPStatusError as e:
|
| 46 |
+
logger.error(f"Lex API error {e.response.status_code}: {endpoint}")
|
| 47 |
+
raise
|
| 48 |
+
return []
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def search_legislation_sections(
|
| 52 |
+
query: str,
|
| 53 |
+
legislation_id: Optional[str] = None,
|
| 54 |
+
size: int = 5,
|
| 55 |
+
) -> list[dict]:
|
| 56 |
+
"""Semantic search across legislation sections."""
|
| 57 |
+
payload = {
|
| 58 |
+
"query": query,
|
| 59 |
+
"size": size,
|
| 60 |
+
"include_text": True,
|
| 61 |
+
}
|
| 62 |
+
if legislation_id:
|
| 63 |
+
payload["legislation_id"] = legislation_id
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
return await _post("/legislation/section/search", payload)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Section search failed: {e}")
|
| 69 |
+
return []
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
async def search_legislation_acts(
|
| 73 |
+
query: str,
|
| 74 |
+
limit: int = 5,
|
| 75 |
+
) -> dict:
|
| 76 |
+
"""Search for Acts and Statutory Instruments."""
|
| 77 |
+
payload = {
|
| 78 |
+
"query": query,
|
| 79 |
+
"limit": limit,
|
| 80 |
+
"include_text": True,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
return await _post("/legislation/search", payload)
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"Act search failed: {e}")
|
| 87 |
+
return {"results": [], "total": 0, "offset": 0, "limit": limit}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def lookup_legislation(legislation_id: str) -> dict:
|
| 91 |
+
"""Look up a specific Act by its ID (e.g., 'ukpga/1983/20')."""
|
| 92 |
+
parts = legislation_id.split("/")
|
| 93 |
+
payload = {
|
| 94 |
+
"legislation_type": parts[0],
|
| 95 |
+
"year": int(parts[1]),
|
| 96 |
+
"number": int(parts[2]),
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return await _post("/legislation/lookup", payload)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
async def get_legislation_full_text(
|
| 103 |
+
legislation_id: str,
|
| 104 |
+
include_schedules: bool = False,
|
| 105 |
+
) -> dict:
|
| 106 |
+
"""Get the full text of a piece of legislation."""
|
| 107 |
+
payload = {
|
| 108 |
+
"legislation_id": legislation_id,
|
| 109 |
+
"include_schedules": include_schedules,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return await _post("/legislation/text", payload)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def get_sections_for_legislation(
|
| 116 |
+
legislation_id: str,
|
| 117 |
+
limit: int = 200,
|
| 118 |
+
) -> list[dict]:
|
| 119 |
+
"""Get all sections for a specific piece of legislation."""
|
| 120 |
+
payload = {
|
| 121 |
+
"legislation_id": legislation_id,
|
| 122 |
+
"limit": limit,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
return await _post("/legislation/section/lookup", payload)
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Section lookup failed: {e}")
|
| 129 |
+
return []
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def search_explanatory_notes(
|
| 133 |
+
query: str,
|
| 134 |
+
legislation_id: Optional[str] = None,
|
| 135 |
+
size: int = 5,
|
| 136 |
+
) -> list[dict]:
|
| 137 |
+
"""Search explanatory notes for legislation."""
|
| 138 |
+
payload = {
|
| 139 |
+
"query": query,
|
| 140 |
+
"size": size,
|
| 141 |
+
}
|
| 142 |
+
if legislation_id:
|
| 143 |
+
payload["legislation_id"] = legislation_id
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
return await _post("/explanatory_note/section/search", payload)
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.error(f"Explanatory note search failed: {e}")
|
| 149 |
+
return []
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
async def search_amendments(
|
| 153 |
+
legislation_id: str,
|
| 154 |
+
search_amended: bool = True,
|
| 155 |
+
size: int = 20,
|
| 156 |
+
) -> list[dict]:
|
| 157 |
+
"""Search for amendments to or by a piece of legislation."""
|
| 158 |
+
payload = {
|
| 159 |
+
"legislation_id": legislation_id,
|
| 160 |
+
"search_amended": search_amended,
|
| 161 |
+
"size": size,
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
return await _post("/amendment/search", payload)
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.error(f"Amendment search failed: {e}")
|
| 168 |
+
return []
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def format_sections_for_context(sections: list[dict], max_chars: int = 6000) -> str:
|
| 172 |
+
"""Format legislation sections into a readable context string for the LLM."""
|
| 173 |
+
context_parts = []
|
| 174 |
+
total_chars = 0
|
| 175 |
+
|
| 176 |
+
for section in sections:
|
| 177 |
+
title = section.get("title", "Untitled")
|
| 178 |
+
text = section.get("text", "")
|
| 179 |
+
leg_id = section.get("legislation_id", "")
|
| 180 |
+
section_num = section.get("number", "")
|
| 181 |
+
|
| 182 |
+
entry = f"### {title}\n"
|
| 183 |
+
entry += f"**Source:** {leg_id}, Section {section_num}\n\n"
|
| 184 |
+
entry += f"{text}\n\n---\n\n"
|
| 185 |
+
|
| 186 |
+
if total_chars + len(entry) > max_chars:
|
| 187 |
+
break
|
| 188 |
+
context_parts.append(entry)
|
| 189 |
+
total_chars += len(entry)
|
| 190 |
+
|
| 191 |
+
return "".join(context_parts) if context_parts else "No relevant legislation sections found."
|
| 192 |
+
=======
|
| 193 |
+
"""
|
| 194 |
+
NurseLex — Lex API Client
|
| 195 |
+
Wraps the i.AI Lex API for nursing-focused UK legislation search.
|
| 196 |
+
"""
|
| 197 |
+
import httpx
|
| 198 |
+
import logging
|
| 199 |
+
from typing import Optional
|
| 200 |
+
|
| 201 |
+
logger = logging.getLogger(__name__)
|
| 202 |
+
|
| 203 |
+
LEX_API_BASE = "https://lex.lab.i.ai.gov.uk"
|
| 204 |
+
LEX_TIMEOUT = 60.0 # Lex API can be slow for semantic search
|
| 205 |
+
|
| 206 |
+
# Key legislation IDs for mental health & learning disability nursing
|
| 207 |
+
NURSING_LEGISLATION = {
|
| 208 |
+
"Mental Health Act 1983": "ukpga/1983/20",
|
| 209 |
+
"Mental Capacity Act 2005": "ukpga/2005/9",
|
| 210 |
+
"Care Act 2014": "ukpga/2014/23",
|
| 211 |
+
"Human Rights Act 1998": "ukpga/1998/42",
|
| 212 |
+
"Equality Act 2010": "ukpga/2010/15",
|
| 213 |
+
"Health and Social Care Act 2012": "ukpga/2012/7",
|
| 214 |
+
"Children Act 1989": "ukpga/1989/41",
|
| 215 |
+
"Children Act 2004": "ukpga/2004/31",
|
| 216 |
+
"Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
|
| 217 |
+
"Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
|
| 218 |
+
"Health and Care Act 2022": "ukpga/2022/31",
|
| 219 |
+
"Autism Act 2009": "ukpga/2009/15",
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
async def _post(endpoint: str, payload: dict) -> dict | list:
|
| 224 |
+
"""Make a POST request to the Lex API with retry logic."""
|
| 225 |
+
url = f"{LEX_API_BASE}{endpoint}"
|
| 226 |
+
for attempt in range(3):
|
| 227 |
+
try:
|
| 228 |
+
async with httpx.AsyncClient(timeout=LEX_TIMEOUT) as client:
|
| 229 |
+
resp = await client.post(url, json=payload)
|
| 230 |
+
resp.raise_for_status()
|
| 231 |
+
return resp.json()
|
| 232 |
+
except httpx.TimeoutException:
|
| 233 |
+
logger.warning(f"Lex API timeout (attempt {attempt + 1}/3): {endpoint}")
|
| 234 |
+
if attempt == 2:
|
| 235 |
+
raise
|
| 236 |
+
except httpx.HTTPStatusError as e:
|
| 237 |
+
logger.error(f"Lex API error {e.response.status_code}: {endpoint}")
|
| 238 |
+
raise
|
| 239 |
+
return []
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
async def search_legislation_sections(
|
| 243 |
+
query: str,
|
| 244 |
+
legislation_id: Optional[str] = None,
|
| 245 |
+
size: int = 5,
|
| 246 |
+
) -> list[dict]:
|
| 247 |
+
"""Semantic search across legislation sections."""
|
| 248 |
+
payload = {
|
| 249 |
+
"query": query,
|
| 250 |
+
"size": size,
|
| 251 |
+
"include_text": True,
|
| 252 |
+
}
|
| 253 |
+
if legislation_id:
|
| 254 |
+
payload["legislation_id"] = legislation_id
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
return await _post("/legislation/section/search", payload)
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.error(f"Section search failed: {e}")
|
| 260 |
+
return []
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def search_legislation_acts(
|
| 264 |
+
query: str,
|
| 265 |
+
limit: int = 5,
|
| 266 |
+
) -> dict:
|
| 267 |
+
"""Search for Acts and Statutory Instruments."""
|
| 268 |
+
payload = {
|
| 269 |
+
"query": query,
|
| 270 |
+
"limit": limit,
|
| 271 |
+
"include_text": True,
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
try:
|
| 275 |
+
return await _post("/legislation/search", payload)
|
| 276 |
+
except Exception as e:
|
| 277 |
+
logger.error(f"Act search failed: {e}")
|
| 278 |
+
return {"results": [], "total": 0, "offset": 0, "limit": limit}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
async def lookup_legislation(legislation_id: str) -> dict:
|
| 282 |
+
"""Look up a specific Act by its ID (e.g., 'ukpga/1983/20')."""
|
| 283 |
+
parts = legislation_id.split("/")
|
| 284 |
+
payload = {
|
| 285 |
+
"legislation_type": parts[0],
|
| 286 |
+
"year": int(parts[1]),
|
| 287 |
+
"number": int(parts[2]),
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
return await _post("/legislation/lookup", payload)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
async def get_legislation_full_text(
|
| 294 |
+
legislation_id: str,
|
| 295 |
+
include_schedules: bool = False,
|
| 296 |
+
) -> dict:
|
| 297 |
+
"""Get the full text of a piece of legislation."""
|
| 298 |
+
payload = {
|
| 299 |
+
"legislation_id": legislation_id,
|
| 300 |
+
"include_schedules": include_schedules,
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
return await _post("/legislation/text", payload)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
async def get_sections_for_legislation(
|
| 307 |
+
legislation_id: str,
|
| 308 |
+
limit: int = 200,
|
| 309 |
+
) -> list[dict]:
|
| 310 |
+
"""Get all sections for a specific piece of legislation."""
|
| 311 |
+
payload = {
|
| 312 |
+
"legislation_id": legislation_id,
|
| 313 |
+
"limit": limit,
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
return await _post("/legislation/section/lookup", payload)
|
| 318 |
+
except Exception as e:
|
| 319 |
+
logger.error(f"Section lookup failed: {e}")
|
| 320 |
+
return []
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
async def search_explanatory_notes(
|
| 324 |
+
query: str,
|
| 325 |
+
legislation_id: Optional[str] = None,
|
| 326 |
+
size: int = 5,
|
| 327 |
+
) -> list[dict]:
|
| 328 |
+
"""Search explanatory notes for legislation."""
|
| 329 |
+
payload = {
|
| 330 |
+
"query": query,
|
| 331 |
+
"size": size,
|
| 332 |
+
}
|
| 333 |
+
if legislation_id:
|
| 334 |
+
payload["legislation_id"] = legislation_id
|
| 335 |
+
|
| 336 |
+
try:
|
| 337 |
+
return await _post("/explanatory_note/section/search", payload)
|
| 338 |
+
except Exception as e:
|
| 339 |
+
logger.error(f"Explanatory note search failed: {e}")
|
| 340 |
+
return []
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
async def search_amendments(
|
| 344 |
+
legislation_id: str,
|
| 345 |
+
search_amended: bool = True,
|
| 346 |
+
size: int = 20,
|
| 347 |
+
) -> list[dict]:
|
| 348 |
+
"""Search for amendments to or by a piece of legislation."""
|
| 349 |
+
payload = {
|
| 350 |
+
"legislation_id": legislation_id,
|
| 351 |
+
"search_amended": search_amended,
|
| 352 |
+
"size": size,
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
try:
|
| 356 |
+
return await _post("/amendment/search", payload)
|
| 357 |
+
except Exception as e:
|
| 358 |
+
logger.error(f"Amendment search failed: {e}")
|
| 359 |
+
return []
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def format_sections_for_context(sections: list[dict], max_chars: int = 6000) -> str:
|
| 363 |
+
"""Format legislation sections into a readable context string for the LLM."""
|
| 364 |
+
context_parts = []
|
| 365 |
+
total_chars = 0
|
| 366 |
+
|
| 367 |
+
for section in sections:
|
| 368 |
+
title = section.get("title", "Untitled")
|
| 369 |
+
text = section.get("text", "")
|
| 370 |
+
leg_id = section.get("legislation_id", "")
|
| 371 |
+
section_num = section.get("number", "")
|
| 372 |
+
|
| 373 |
+
entry = f"### {title}\n"
|
| 374 |
+
entry += f"**Source:** {leg_id}, Section {section_num}\n\n"
|
| 375 |
+
entry += f"{text}\n\n---\n\n"
|
| 376 |
+
|
| 377 |
+
if total_chars + len(entry) > max_chars:
|
| 378 |
+
break
|
| 379 |
+
context_parts.append(entry)
|
| 380 |
+
total_chars += len(entry)
|
| 381 |
+
|
| 382 |
+
return "".join(context_parts) if context_parts else "No relevant legislation sections found."
|
| 383 |
+
>>>>>>> a4e257b16d56f80612b7c9ac6d2e7c198fef5bb6
|
local_search.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
local_search.py
|
| 3 |
+
|
| 4 |
+
Locally loads the `i-dot-ai/all-miniLM-L6-v2-UKPGA-6k-finetune` model to encode
|
| 5 |
+
the cached `nursing_sections.json` into semantic embeddings for fast, reliable local searches.
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import logging
|
| 10 |
+
import torch
|
| 11 |
+
from sentence_transformers import SentenceTransformer, util
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# Constants
|
| 16 |
+
MODEL_NAME = "i-dot-ai/all-miniLM-L6-v2-UKPGA-6k-finetune"
|
| 17 |
+
CACHE_FILE = os.path.join(os.path.dirname(__file__), "nursing_sections.json")
|
| 18 |
+
|
| 19 |
+
# Global variables to hold the model and embeddings in memory
|
| 20 |
+
_model = None
|
| 21 |
+
_corpus_embeddings = None
|
| 22 |
+
_sections = []
|
| 23 |
+
|
| 24 |
+
def init_local_search():
|
| 25 |
+
"""Initializes the model and computes embeddings for all cached sections."""
|
| 26 |
+
global _model, _corpus_embeddings, _sections
|
| 27 |
+
|
| 28 |
+
if _model is not None:
|
| 29 |
+
return # Already initialized
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
logger.info(f"Loading local embedding model: {MODEL_NAME}...")
|
| 33 |
+
_model = SentenceTransformer(MODEL_NAME)
|
| 34 |
+
|
| 35 |
+
if not os.path.exists(CACHE_FILE):
|
| 36 |
+
logger.error(f"Cache file not found at {CACHE_FILE}")
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
| 40 |
+
_sections = json.load(f)
|
| 41 |
+
|
| 42 |
+
if not _sections:
|
| 43 |
+
logger.warning("No sections found in cache.")
|
| 44 |
+
return
|
| 45 |
+
|
| 46 |
+
logger.info(f"Computing embeddings for {len(_sections)} cached sections. This may take a minute on first run...")
|
| 47 |
+
# Prepare text for embedding: combine legislation title, section title, and text
|
| 48 |
+
corpus_texts = []
|
| 49 |
+
for s in _sections:
|
| 50 |
+
# Reconstruct the act name roughly from the URL to give the model context
|
| 51 |
+
leg_id = s.get("legislation_id", "")
|
| 52 |
+
act_name = leg_id.split("/")[-2] if "/" in leg_id else leg_id
|
| 53 |
+
|
| 54 |
+
# Create a rich text representation for the vector search
|
| 55 |
+
content = f"Act: {act_name}. Section {s.get('number', '')}: {s.get('title', '')}. {s.get('text', '')}"
|
| 56 |
+
corpus_texts.append(content)
|
| 57 |
+
|
| 58 |
+
# Encode all sections
|
| 59 |
+
_corpus_embeddings = _model.encode(corpus_texts, convert_to_tensor=True, show_progress_bar=False)
|
| 60 |
+
logger.info("Local semantic search engine ready.")
|
| 61 |
+
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Failed to initialize local search engine: {e}")
|
| 64 |
+
_model = None # Reset on failure
|
| 65 |
+
|
| 66 |
+
def search_scenarios_locally(query: str, top_k: int = 5) -> list[dict]:
|
| 67 |
+
"""Semantic search over the local cached sections using cosine similarity."""
|
| 68 |
+
global _model, _corpus_embeddings, _sections
|
| 69 |
+
|
| 70 |
+
if _model is None or _corpus_embeddings is None:
|
| 71 |
+
init_local_search()
|
| 72 |
+
|
| 73 |
+
if _model is None or _corpus_embeddings is None:
|
| 74 |
+
logger.error("Local search engine is unavailable.")
|
| 75 |
+
return []
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
query_embedding = _model.encode(query, convert_to_tensor=True)
|
| 79 |
+
# Compute cosine similarities
|
| 80 |
+
cos_scores = util.cos_sim(query_embedding, _corpus_embeddings)[0]
|
| 81 |
+
|
| 82 |
+
# Find the top_k scores
|
| 83 |
+
top_results = torch.topk(cos_scores, k=min(top_k, len(_sections)))
|
| 84 |
+
|
| 85 |
+
results = []
|
| 86 |
+
for score, idx in zip(top_results[0], top_results[1]):
|
| 87 |
+
# Only return highly relevant matches (tune this threshold if needed)
|
| 88 |
+
if score.item() > 0.4:
|
| 89 |
+
match = _sections[idx].copy()
|
| 90 |
+
match["score"] = score.item()
|
| 91 |
+
results.append(match)
|
| 92 |
+
|
| 93 |
+
return results
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"Error during local scenario search: {e}")
|
| 96 |
+
return []
|
mcp_server.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<<<<<<< HEAD
|
| 2 |
+
import asyncio
|
| 3 |
+
import httpx
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from mcp.server.fastmcp import FastMCP
|
| 8 |
+
|
| 9 |
+
# Initialize FastMCP server
|
| 10 |
+
mcp = FastMCP("NurseLex-LexAPI")
|
| 11 |
+
|
| 12 |
+
# Core Constants
|
| 13 |
+
BASE_URL = 'https://lex.lab.i.ai.gov.uk'
|
| 14 |
+
NURSING_ACTS = {
|
| 15 |
+
"Mental Health Act 1983": "ukpga/1983/20",
|
| 16 |
+
"Mental Capacity Act 2005": "ukpga/2005/9",
|
| 17 |
+
"Care Act 2014": "ukpga/2014/23",
|
| 18 |
+
"Human Rights Act 1998": "ukpga/1998/42",
|
| 19 |
+
"Equality Act 2010": "ukpga/2010/15",
|
| 20 |
+
"Health and Social Care Act 2012": "ukpga/2012/7",
|
| 21 |
+
"Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
|
| 22 |
+
"Autism Act 2009": "ukpga/2009/15",
|
| 23 |
+
"Children Act 1989": "ukpga/1989/41",
|
| 24 |
+
"Children Act 2004": "ukpga/2004/31",
|
| 25 |
+
"Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
|
| 26 |
+
"Health and Care Act 2022": "ukpga/2022/31",
|
| 27 |
+
}
|
| 28 |
+
REVERSE_ACTS = {v: k for k, v in NURSING_ACTS.items()}
|
| 29 |
+
|
| 30 |
+
# Load Cache (we need absolute paths since MCP might run from a different CWD)
|
| 31 |
+
DB_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 32 |
+
CACHE_FILE = os.path.join(DB_DIR, "nursing_sections.json")
|
| 33 |
+
|
| 34 |
+
def _load_sections():
|
| 35 |
+
try:
|
| 36 |
+
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
|
| 37 |
+
return json.load(f)
|
| 38 |
+
except FileNotFoundError:
|
| 39 |
+
return []
|
| 40 |
+
|
| 41 |
+
SECTIONS_CACHE = _load_sections()
|
| 42 |
+
|
| 43 |
+
@mcp.tool()
|
| 44 |
+
def search_local_nursing_cache(query: str, limit: int = 5) -> str:
|
| 45 |
+
"""
|
| 46 |
+
Search the local, curated cache of 1,128 critical nursing legislation sections
|
| 47 |
+
(from the Mental Health Act, Care Act, etc.) for a specific keyword or section number.
|
| 48 |
+
Returns the exact statutory text.
|
| 49 |
+
"""
|
| 50 |
+
if not SECTIONS_CACHE:
|
| 51 |
+
return "Error: Local cache not found."
|
| 52 |
+
|
| 53 |
+
query_lower = query.lower()
|
| 54 |
+
results = []
|
| 55 |
+
|
| 56 |
+
for section in SECTIONS_CACHE:
|
| 57 |
+
act_name = section.get('act_name', '').lower()
|
| 58 |
+
title = section.get('title', '').lower()
|
| 59 |
+
text = section.get('text', '').lower()
|
| 60 |
+
num_str = str(section.get('number', ''))
|
| 61 |
+
|
| 62 |
+
score = 0
|
| 63 |
+
if query_lower in act_name: score += 1
|
| 64 |
+
if query_lower in title: score += 3
|
| 65 |
+
if query_lower in text: score += 1
|
| 66 |
+
if query_lower == f"section {num_str}" or query_lower == num_str: score += 5
|
| 67 |
+
|
| 68 |
+
if score > 0:
|
| 69 |
+
results.append((score, section))
|
| 70 |
+
|
| 71 |
+
# Sort and take top matches
|
| 72 |
+
results.sort(key=lambda x: x[0], reverse=True)
|
| 73 |
+
|
| 74 |
+
if not results:
|
| 75 |
+
return "No sections found matching the query in the local cache."
|
| 76 |
+
|
| 77 |
+
out = f"## 📚 Local Cache Results for '{query}'\n\n"
|
| 78 |
+
for r in results[:limit]:
|
| 79 |
+
sec = r[1]
|
| 80 |
+
out += f"**{sec.get('act_name')} — Section {sec.get('number')}**\n"
|
| 81 |
+
out += f"*{sec.get('title')}*\n"
|
| 82 |
+
out += f"{sec.get('text')}\n\n---\n\n"
|
| 83 |
+
|
| 84 |
+
return out
|
| 85 |
+
|
| 86 |
+
@mcp.tool()
|
| 87 |
+
async def vector_search_lex_api(clinical_scenario: str) -> str:
|
| 88 |
+
"""
|
| 89 |
+
Translates a plain-English clinical scenario (e.g. 'Patient wants to leave but lacks capacity')
|
| 90 |
+
into relevant UK legislation by querying the i.AI Lex API semantic vector search engine.
|
| 91 |
+
This searches across the entire legislation database, not just the local cache.
|
| 92 |
+
"""
|
| 93 |
+
url = f'{BASE_URL}/legislation/section/search'
|
| 94 |
+
payload = {
|
| 95 |
+
'query': clinical_scenario,
|
| 96 |
+
'limit': 5
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
async with httpx.AsyncClient() as client:
|
| 101 |
+
r = await client.post(url, json=payload, timeout=15.0)
|
| 102 |
+
if r.status_code != 200:
|
| 103 |
+
return f"Lex API Vector Search Failed: Status Code {r.status_code}"
|
| 104 |
+
|
| 105 |
+
data = r.json()
|
| 106 |
+
if not isinstance(data, list) or not data:
|
| 107 |
+
return "No semantic matches found for this scenario."
|
| 108 |
+
|
| 109 |
+
out = f"## ⚖️ Vector Matches for Scenario:\n*{clinical_scenario}*\n\n"
|
| 110 |
+
for i, n in enumerate(data, 1):
|
| 111 |
+
leg_id = n.get("legislation_id", "")
|
| 112 |
+
|
| 113 |
+
# 1. Use the act_name from the API response if available
|
| 114 |
+
act_name = n.get("act_name", "")
|
| 115 |
+
|
| 116 |
+
# 2. If not, try our known mapping
|
| 117 |
+
if not act_name:
|
| 118 |
+
for known_id, known_name in REVERSE_ACTS.items():
|
| 119 |
+
if known_id in leg_id:
|
| 120 |
+
act_name = known_name
|
| 121 |
+
break
|
| 122 |
+
|
| 123 |
+
# 3. Final fallback: extract from the legislation_id URL
|
| 124 |
+
if not act_name:
|
| 125 |
+
act_name = leg_id.split("/id/")[-1] if "/id/" in leg_id else leg_id or "Legislation"
|
| 126 |
+
|
| 127 |
+
sec_num = n.get("number", "??")
|
| 128 |
+
title = n.get("title", "Untitled Section")
|
| 129 |
+
text = n.get("text", "")
|
| 130 |
+
|
| 131 |
+
out += f"### {i}. {act_name} — Section {sec_num}: {title}\n"
|
| 132 |
+
out += f"{text[:600]}...\n\n"
|
| 133 |
+
out += f"Source URI: {n.get('uri', f'https://www.legislation.gov.uk/id/{leg_id}/section/{sec_num}')}\n\n"
|
| 134 |
+
|
| 135 |
+
return out
|
| 136 |
+
except Exception as e:
|
| 137 |
+
return f"Error querying Lex Vector API: {str(e)}"
|
| 138 |
+
|
| 139 |
+
@mcp.tool()
|
| 140 |
+
async def get_official_explanatory_note(act_name: str, section_number: str) -> str:
|
| 141 |
+
"""
|
| 142 |
+
Dynamically fetches the Official Government Explanatory Note for a specific Act and section.
|
| 143 |
+
Explanatory Notes are plain English explainers written by the government.
|
| 144 |
+
Note: Acts passed prior to 1999 (e.g., Mental Health Act 1983) generally do not have these.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
act_name: The full name of the Act (e.g., 'Mental Capacity Act 2005').
|
| 148 |
+
section_number: The specific section number as a string (e.g., '3').
|
| 149 |
+
"""
|
| 150 |
+
url = f'{BASE_URL}/explanatory_note/section/search'
|
| 151 |
+
payload = {
|
| 152 |
+
'query': f'"{act_name}" Section {section_number}',
|
| 153 |
+
'limit': 3
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
try:
|
| 157 |
+
async with httpx.AsyncClient() as client:
|
| 158 |
+
r = await client.post(url, json=payload, timeout=10.0)
|
| 159 |
+
if r.status_code == 200:
|
| 160 |
+
data = r.json()
|
| 161 |
+
if isinstance(data, list):
|
| 162 |
+
parent_id = NURSING_ACTS.get(act_name, "")
|
| 163 |
+
for note in data:
|
| 164 |
+
# Match the parent ID to ensure this note belongs to the right Act
|
| 165 |
+
if parent_id and parent_id in note.get('legislation_id', ''):
|
| 166 |
+
text = note.get('text', '')
|
| 167 |
+
if text:
|
| 168 |
+
return f"### Official Explanatory Note ({act_name} S.{section_number})\n\n{text}"
|
| 169 |
+
|
| 170 |
+
return f"No Official Explanatory Note could be found for '{act_name}' Section {section_number}. The Act may pre-date the 1999 introduction of Explanatory Notes."
|
| 171 |
+
except Exception as e:
|
| 172 |
+
return f"Error fetching Explanatory Note: {str(e)}"
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
# Ensure this runs correctly when started via cursor/claude
|
| 176 |
+
mcp.run()
|
| 177 |
+
=======
|
| 178 |
+
import asyncio
|
| 179 |
+
import httpx
|
| 180 |
+
import pandas as pd
|
| 181 |
+
import json
|
| 182 |
+
import os
|
| 183 |
+
from mcp.server.fastmcp import FastMCP
|
| 184 |
+
|
| 185 |
+
# Initialize FastMCP server
|
| 186 |
+
mcp = FastMCP("NurseLex-LexAPI")
|
| 187 |
+
|
| 188 |
+
# Core Constants
|
| 189 |
+
BASE_URL = 'https://lex.lab.i.ai.gov.uk'
|
| 190 |
+
NURSING_ACTS = {
|
| 191 |
+
"Mental Health Act 1983": "ukpga/1983/20",
|
| 192 |
+
"Mental Capacity Act 2005": "ukpga/2005/9",
|
| 193 |
+
"Care Act 2014": "ukpga/2014/23",
|
| 194 |
+
"Human Rights Act 1998": "ukpga/1998/42",
|
| 195 |
+
"Equality Act 2010": "ukpga/2010/15",
|
| 196 |
+
"Health and Social Care Act 2012": "ukpga/2012/7",
|
| 197 |
+
"Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
|
| 198 |
+
"Autism Act 2009": "ukpga/2009/15",
|
| 199 |
+
"Children Act 1989": "ukpga/1989/41",
|
| 200 |
+
"Children Act 2004": "ukpga/2004/31",
|
| 201 |
+
"Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
|
| 202 |
+
"Health and Care Act 2022": "ukpga/2022/31",
|
| 203 |
+
}
|
| 204 |
+
REVERSE_ACTS = {v: k for k, v in NURSING_ACTS.items()}
|
| 205 |
+
|
| 206 |
+
# Load Cache (we need absolute paths since MCP might run from a different CWD)
|
| 207 |
+
DB_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 208 |
+
CACHE_FILE = os.path.join(DB_DIR, "nursing_sections.json")
|
| 209 |
+
|
| 210 |
+
def _load_sections():
|
| 211 |
+
try:
|
| 212 |
+
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
|
| 213 |
+
return json.load(f)
|
| 214 |
+
except FileNotFoundError:
|
| 215 |
+
return []
|
| 216 |
+
|
| 217 |
+
SECTIONS_CACHE = _load_sections()
|
| 218 |
+
|
| 219 |
+
@mcp.tool()
|
| 220 |
+
def search_local_nursing_cache(query: str, limit: int = 5) -> str:
|
| 221 |
+
"""
|
| 222 |
+
Search the local, curated cache of 1,128 critical nursing legislation sections
|
| 223 |
+
(from the Mental Health Act, Care Act, etc.) for a specific keyword or section number.
|
| 224 |
+
Returns the exact statutory text.
|
| 225 |
+
"""
|
| 226 |
+
if not SECTIONS_CACHE:
|
| 227 |
+
return "Error: Local cache not found."
|
| 228 |
+
|
| 229 |
+
query_lower = query.lower()
|
| 230 |
+
results = []
|
| 231 |
+
|
| 232 |
+
for section in SECTIONS_CACHE:
|
| 233 |
+
act_name = section.get('act_name', '').lower()
|
| 234 |
+
title = section.get('title', '').lower()
|
| 235 |
+
text = section.get('text', '').lower()
|
| 236 |
+
num_str = str(section.get('number', ''))
|
| 237 |
+
|
| 238 |
+
score = 0
|
| 239 |
+
if query_lower in act_name: score += 1
|
| 240 |
+
if query_lower in title: score += 3
|
| 241 |
+
if query_lower in text: score += 1
|
| 242 |
+
if query_lower == f"section {num_str}" or query_lower == num_str: score += 5
|
| 243 |
+
|
| 244 |
+
if score > 0:
|
| 245 |
+
results.append((score, section))
|
| 246 |
+
|
| 247 |
+
# Sort and take top matches
|
| 248 |
+
results.sort(key=lambda x: x[0], reverse=True)
|
| 249 |
+
|
| 250 |
+
if not results:
|
| 251 |
+
return "No sections found matching the query in the local cache."
|
| 252 |
+
|
| 253 |
+
out = f"## 📚 Local Cache Results for '{query}'\n\n"
|
| 254 |
+
for r in results[:limit]:
|
| 255 |
+
sec = r[1]
|
| 256 |
+
out += f"**{sec.get('act_name')} — Section {sec.get('number')}**\n"
|
| 257 |
+
out += f"*{sec.get('title')}*\n"
|
| 258 |
+
out += f"{sec.get('text')}\n\n---\n\n"
|
| 259 |
+
|
| 260 |
+
return out
|
| 261 |
+
|
| 262 |
+
@mcp.tool()
|
| 263 |
+
async def vector_search_lex_api(clinical_scenario: str) -> str:
|
| 264 |
+
"""
|
| 265 |
+
Translates a plain-English clinical scenario (e.g. 'Patient wants to leave but lacks capacity')
|
| 266 |
+
into relevant UK legislation by querying the i.AI Lex API semantic vector search engine.
|
| 267 |
+
This searches across the entire legislation database, not just the local cache.
|
| 268 |
+
"""
|
| 269 |
+
url = f'{BASE_URL}/legislation/section/search'
|
| 270 |
+
payload = {
|
| 271 |
+
'query': clinical_scenario,
|
| 272 |
+
'limit': 5
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
try:
|
| 276 |
+
async with httpx.AsyncClient() as client:
|
| 277 |
+
r = await client.post(url, json=payload, timeout=15.0)
|
| 278 |
+
if r.status_code != 200:
|
| 279 |
+
return f"Lex API Vector Search Failed: Status Code {r.status_code}"
|
| 280 |
+
|
| 281 |
+
data = r.json()
|
| 282 |
+
if not isinstance(data, list) or not data:
|
| 283 |
+
return "No semantic matches found for this scenario."
|
| 284 |
+
|
| 285 |
+
out = f"## ⚖️ Vector Matches for Scenario:\n*{clinical_scenario}*\n\n"
|
| 286 |
+
for i, n in enumerate(data, 1):
|
| 287 |
+
leg_id = n.get("legislation_id", "")
|
| 288 |
+
|
| 289 |
+
# 1. Use the act_name from the API response if available
|
| 290 |
+
act_name = n.get("act_name", "")
|
| 291 |
+
|
| 292 |
+
# 2. If not, try our known mapping
|
| 293 |
+
if not act_name:
|
| 294 |
+
for known_id, known_name in REVERSE_ACTS.items():
|
| 295 |
+
if known_id in leg_id:
|
| 296 |
+
act_name = known_name
|
| 297 |
+
break
|
| 298 |
+
|
| 299 |
+
# 3. Final fallback: extract from the legislation_id URL
|
| 300 |
+
if not act_name:
|
| 301 |
+
act_name = leg_id.split("/id/")[-1] if "/id/" in leg_id else leg_id or "Legislation"
|
| 302 |
+
|
| 303 |
+
sec_num = n.get("number", "??")
|
| 304 |
+
title = n.get("title", "Untitled Section")
|
| 305 |
+
text = n.get("text", "")
|
| 306 |
+
|
| 307 |
+
out += f"### {i}. {act_name} — Section {sec_num}: {title}\n"
|
| 308 |
+
out += f"{text[:600]}...\n\n"
|
| 309 |
+
out += f"Source URI: {n.get('uri', f'https://www.legislation.gov.uk/id/{leg_id}/section/{sec_num}')}\n\n"
|
| 310 |
+
|
| 311 |
+
return out
|
| 312 |
+
except Exception as e:
|
| 313 |
+
return f"Error querying Lex Vector API: {str(e)}"
|
| 314 |
+
|
| 315 |
+
@mcp.tool()
|
| 316 |
+
async def get_official_explanatory_note(act_name: str, section_number: str) -> str:
|
| 317 |
+
"""
|
| 318 |
+
Dynamically fetches the Official Government Explanatory Note for a specific Act and section.
|
| 319 |
+
Explanatory Notes are plain English explainers written by the government.
|
| 320 |
+
Note: Acts passed prior to 1999 (e.g., Mental Health Act 1983) generally do not have these.
|
| 321 |
+
|
| 322 |
+
Args:
|
| 323 |
+
act_name: The full name of the Act (e.g., 'Mental Capacity Act 2005').
|
| 324 |
+
section_number: The specific section number as a string (e.g., '3').
|
| 325 |
+
"""
|
| 326 |
+
url = f'{BASE_URL}/explanatory_note/section/search'
|
| 327 |
+
payload = {
|
| 328 |
+
'query': f'"{act_name}" Section {section_number}',
|
| 329 |
+
'limit': 3
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
try:
|
| 333 |
+
async with httpx.AsyncClient() as client:
|
| 334 |
+
r = await client.post(url, json=payload, timeout=10.0)
|
| 335 |
+
if r.status_code == 200:
|
| 336 |
+
data = r.json()
|
| 337 |
+
if isinstance(data, list):
|
| 338 |
+
parent_id = NURSING_ACTS.get(act_name, "")
|
| 339 |
+
for note in data:
|
| 340 |
+
# Match the parent ID to ensure this note belongs to the right Act
|
| 341 |
+
if parent_id and parent_id in note.get('legislation_id', ''):
|
| 342 |
+
text = note.get('text', '')
|
| 343 |
+
if text:
|
| 344 |
+
return f"### Official Explanatory Note ({act_name} S.{section_number})\n\n{text}"
|
| 345 |
+
|
| 346 |
+
return f"No Official Explanatory Note could be found for '{act_name}' Section {section_number}. The Act may pre-date the 1999 introduction of Explanatory Notes."
|
| 347 |
+
except Exception as e:
|
| 348 |
+
return f"Error fetching Explanatory Note: {str(e)}"
|
| 349 |
+
|
| 350 |
+
if __name__ == "__main__":
|
| 351 |
+
# Ensure this runs correctly when started via cursor/claude
|
| 352 |
+
mcp.run()
|
| 353 |
+
>>>>>>> a4e257b16d56f80612b7c9ac6d2e7c198fef5bb6
|
nursing_legislation.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:af7a272869fde7699564cad1f26948517931d12b1cf9ffa29347e13166e112e6
|
| 3 |
+
size 1123889
|
nursing_sections.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
probe_sections.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Probe for section-level parquet files on Lex CDN."""
|
| 2 |
+
import httpx
|
| 3 |
+
|
| 4 |
+
base = "https://lexdownloads.blob.core.windows.net/downloads/latest"
|
| 5 |
+
patterns = [
|
| 6 |
+
"legislation_section.parquet",
|
| 7 |
+
"legislation_sections.parquet",
|
| 8 |
+
"legislation_section_1983.parquet",
|
| 9 |
+
"legislation_section/1983.parquet",
|
| 10 |
+
"sections/1983.parquet",
|
| 11 |
+
"legislation_sections/1983.parquet",
|
| 12 |
+
"legislation_section_2005.parquet",
|
| 13 |
+
"legislation_section_2014.parquet",
|
| 14 |
+
"sections_1983.parquet",
|
| 15 |
+
"legislation-section.parquet",
|
| 16 |
+
"legislation-sections.parquet",
|
| 17 |
+
"explanatory_note.parquet",
|
| 18 |
+
"amendment.parquet",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
for p in patterns:
|
| 22 |
+
url = f"{base}/{p}"
|
| 23 |
+
try:
|
| 24 |
+
r = httpx.head(url, timeout=10, follow_redirects=True)
|
| 25 |
+
size = r.headers.get("content-length", "?")
|
| 26 |
+
mb = int(size) // 1024 // 1024 if size.isdigit() else "?"
|
| 27 |
+
status = "FOUND" if r.status_code == 200 else str(r.status_code)
|
| 28 |
+
print(f"{status}: {p} ({mb} MB)" if r.status_code == 200 else f" {r.status_code}: {p}")
|
| 29 |
+
except Exception as e:
|
| 30 |
+
print(f" ERROR: {p} -> {type(e).__name__}")
|
push.log
ADDED
|
Binary file (2.32 kB). View file
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<<<<<<< HEAD
|
| 2 |
+
gradio>=5.50
|
| 3 |
+
httpx>=0.27
|
| 4 |
+
pandas>=2.0
|
| 5 |
+
pyarrow>=14.0
|
| 6 |
+
sentence-transformers>=2.7.0
|
| 7 |
+
torch>=2.2.0
|
| 8 |
+
=======
|
| 9 |
+
gradio>=5.50
|
| 10 |
+
httpx>=0.27
|
| 11 |
+
pandas>=2.0
|
| 12 |
+
pyarrow>=14.0
|
| 13 |
+
>>>>>>> a4e257b16d56f80612b7c9ac6d2e7c198fef5bb6
|
test_amendment.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
|
| 3 |
+
base_url = 'https://lex.lab.i.ai.gov.uk'
|
| 4 |
+
url = f'{base_url}/amendment/search'
|
| 5 |
+
|
| 6 |
+
payload = {
|
| 7 |
+
'legislation_id': 'ukpga/1983/20'
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
print(f"POSTing to {url} with {payload}")
|
| 11 |
+
r = httpx.post(url, json=payload, timeout=10)
|
| 12 |
+
print('Status:', r.status_code)
|
| 13 |
+
|
| 14 |
+
d = r.json()
|
| 15 |
+
if isinstance(d, list) and d:
|
| 16 |
+
print(f'Found {len(d)} amendments.')
|
| 17 |
+
for a in d[:3]:
|
| 18 |
+
acting_law = a.get("amended_by_legislation_title", "Unknown")
|
| 19 |
+
target_prov = a.get("amended_provision_id", "Unknown")
|
| 20 |
+
amend_type = a.get("amendment_type", "Unknown")
|
| 21 |
+
print(f'-> {target_prov} amended by: {acting_law} (Type: {amend_type})')
|
| 22 |
+
else:
|
| 23 |
+
print('Raw:', d)
|
test_notes.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"http://www.legislation.gov.uk/ukpga/2005/9/section/2": {
|
| 3 |
+
"section_uri": "http://www.legislation.gov.uk/ukpga/2005/9/section/2",
|
| 4 |
+
"act_name": "Mental Capacity Act 2005",
|
| 5 |
+
"section_number": 2,
|
| 6 |
+
"note_text": "21.This sets out the Act\u2019s definition of a person who lacks capacity. It focuses on the particular time when a decision has to be made and on the particular matter to which the decision relates, not on any theoretical ability to make decisions generally. It follows that a person can lack capacity for the purposes of the Act even if the loss of capacity is partial or temporary or if his capacity fluctuates. It also follows that a person may lack capacity in relation to one matter but not in relation to another matter.\n22.The inability to make a decision must be caused by an impairment of or disturbance in the functioning of the mind or brain. This is the so-called \u201cdiagnostic test\u201d. This could cover a range of problems, such as psychiatric illness, learning disability, dementia, brain damage or even a toxic confusional state, as long as it has the necessary effect on the functioning of the mind or brain, causing the person to be unable to make the decision.\n23.Subsection (3) introduces a principle of equal consideration in relation to determinations of a person\u2019s capacity. It makes it clear that such determinations should not merely be made on the basis of a person\u2019s age, appearance or unjustified assumptions about capacity based on the person\u2019s condition or behaviour. Any preconceptions and prejudicial assumptions held by a person making the assessment of capacity must therefore have no input into the assessment of capacity. The reference to \u201ccondition\u201d captures a range of factors, including any physical disability a person may have. So, in making an assessment of capacity, the fact that the person in question has a learning difficulty should not in itself lead the person making the assessment to assume that the person with the learning difficulty would lack capacity to decide, for example, where to live. The reference to \u201cappearance\u201d would also include skin colour.\n24.Subsection (5) makes it clear that powers under the Act generally only arise where the person lacking capacity is 16 or over (although powers in relation to property might be exercised in relation to a younger person who has disabilities which will cause the incapacity to last into adulthood: see section 18(3)). Any overlap with the jurisdiction under the Children Act 1989 can be dealt with by orders about the transfer of proceedings to the more appropriate court (see section 21).\n25.Subsection (5) has the first use of the capital letter \u201cD\u201d to refer to a person exercising powers in relation to a person who lacks capacity. The use of capital letters sometimes makes complex provisions easier to follow (particularly where a number of different people are being referred to), and is a technique often adopted in recent legislation. In this Act, the fact that lack of capacity is specific to particular decisions and that there are many reasons why a person may lack capacity makes it necessary to use a neutral, rather than descriptive, label for the person concerned."
|
| 7 |
+
},
|
| 8 |
+
"http://www.legislation.gov.uk/ukpga/2005/9/section/5": {
|
| 9 |
+
"section_uri": "http://www.legislation.gov.uk/ukpga/2005/9/section/5",
|
| 10 |
+
"act_name": "Mental Capacity Act 2005",
|
| 11 |
+
"section_number": 5,
|
| 12 |
+
"note_text": "1.These explanatory notes relate to the Mental Capacity Act 2005 which received Royal Assent on 7 April 2005. They have been prepared by the Department for Constitutional Affairs and the Department of Health in order to assist the reader in understanding the Act. They do not form part of the Act and have not been endorsed by Parliament.\n2.The notes need to be read in conjunction with the Act. They are not, and are not meant to be, a comprehensive description of the Act. So where a provision or part of a provision does not seem to require any explanation or comment, none is given."
|
| 13 |
+
},
|
| 14 |
+
"http://www.legislation.gov.uk/ukpga/2005/9/schedule/5": {
|
| 15 |
+
"section_uri": "http://www.legislation.gov.uk/ukpga/2005/9/schedule/5",
|
| 16 |
+
"act_name": "Mental Capacity Act 2005",
|
| 17 |
+
"section_number": 5,
|
| 18 |
+
"note_text": "1.These explanatory notes relate to the Mental Capacity Act 2005 which received Royal Assent on 7 April 2005. They have been prepared by the Department for Constitutional Affairs and the Department of Health in order to assist the reader in understanding the Act. They do not form part of the Act and have not been endorsed by Parliament.\n2.The notes need to be read in conjunction with the Act. They are not, and are not meant to be, a comprehensive description of the Act. So where a provision or part of a provision does not seem to require any explanation or comment, none is given."
|
| 19 |
+
},
|
| 20 |
+
"http://www.legislation.gov.uk/ukpga/2005/9/schedule/3": {
|
| 21 |
+
"section_uri": "http://www.legislation.gov.uk/ukpga/2005/9/schedule/3",
|
| 22 |
+
"act_name": "Mental Capacity Act 2005",
|
| 23 |
+
"section_number": 3,
|
| 24 |
+
"note_text": "21.This sets out the Act\u2019s definition of a person who lacks capacity. It focuses on the particular time when a decision has to be made and on the particular matter to which the decision relates, not on any theoretical ability to make decisions generally. It follows that a person can lack capacity for the purposes of the Act even if the loss of capacity is partial or temporary or if his capacity fluctuates. It also follows that a person may lack capacity in relation to one matter but not in relation to another matter.\n22.The inability to make a decision must be caused by an impairment of or disturbance in the functioning of the mind or brain. This is the so-called \u201cdiagnostic test\u201d. This could cover a range of problems, such as psychiatric illness, learning disability, dementia, brain damage or even a toxic confusional state, as long as it has the necessary effect on the functioning of the mind or brain, causing the person to be unable to make the decision.\n23.Subsection (3) introduces a principle of equal consideration in relation to determinations of a person\u2019s capacity. It makes it clear that such determinations should not merely be made on the basis of a person\u2019s age, appearance or unjustified assumptions about capacity based on the person\u2019s condition or behaviour. Any preconceptions and prejudicial assumptions held by a person making the assessment of capacity must therefore have no input into the assessment of capacity. The reference to \u201ccondition\u201d captures a range of factors, including any physical disability a person may have. So, in making an assessment of capacity, the fact that the person in question has a learning difficulty should not in itself lead the person making the assessment to assume that the person with the learning difficulty would lack capacity to decide, for example, where to live. The reference to \u201cappearance\u201d would also include skin colour.\n24.Subsection (5) makes it clear that powers under the Act generally only arise where the person lacking capacity is 16 or over (although powers in relation to property might be exercised in relation to a younger person who has disabilities which will cause the incapacity to last into adulthood: see section 18(3)). Any overlap with the jurisdiction under the Children Act 1989 can be dealt with by orders about the transfer of proceedings to the more appropriate court (see section 21).\n25.Subsection (5) has the first use of the capital letter \u201cD\u201d to refer to a person exercising powers in relation to a person who lacks capacity. The use of capital letters sometimes makes complex provisions easier to follow (particularly where a number of different people are being referred to), and is a technique often adopted in recent legislation. In this Act, the fact that lack of capacity is specific to particular decisions and that there are many reasons why a person may lack capacity makes it necessary to use a neutral, rather than descriptive, label for the person concerned."
|
| 25 |
+
},
|
| 26 |
+
"http://www.legislation.gov.uk/ukpga/2005/9/schedule/2": {
|
| 27 |
+
"section_uri": "http://www.legislation.gov.uk/ukpga/2005/9/schedule/2",
|
| 28 |
+
"act_name": "Mental Capacity Act 2005",
|
| 29 |
+
"section_number": 2,
|
| 30 |
+
"note_text": "21.This sets out the Act\u2019s definition of a person who lacks capacity. It focuses on the particular time when a decision has to be made and on the particular matter to which the decision relates, not on any theoretical ability to make decisions generally. It follows that a person can lack capacity for the purposes of the Act even if the loss of capacity is partial or temporary or if his capacity fluctuates. It also follows that a person may lack capacity in relation to one matter but not in relation to another matter.\n22.The inability to make a decision must be caused by an impairment of or disturbance in the functioning of the mind or brain. This is the so-called \u201cdiagnostic test\u201d. This could cover a range of problems, such as psychiatric illness, learning disability, dementia, brain damage or even a toxic confusional state, as long as it has the necessary effect on the functioning of the mind or brain, causing the person to be unable to make the decision.\n23.Subsection (3) introduces a principle of equal consideration in relation to determinations of a person\u2019s capacity. It makes it clear that such determinations should not merely be made on the basis of a person\u2019s age, appearance or unjustified assumptions about capacity based on the person\u2019s condition or behaviour. Any preconceptions and prejudicial assumptions held by a person making the assessment of capacity must therefore have no input into the assessment of capacity. The reference to \u201ccondition\u201d captures a range of factors, including any physical disability a person may have. So, in making an assessment of capacity, the fact that the person in question has a learning difficulty should not in itself lead the person making the assessment to assume that the person with the learning difficulty would lack capacity to decide, for example, where to live. The reference to \u201cappearance\u201d would also include skin colour.\n24.Subsection (5) makes it clear that powers under the Act generally only arise where the person lacking capacity is 16 or over (although powers in relation to property might be exercised in relation to a younger person who has disabilities which will cause the incapacity to last into adulthood: see section 18(3)). Any overlap with the jurisdiction under the Children Act 1989 can be dealt with by orders about the transfer of proceedings to the more appropriate court (see section 21).\n25.Subsection (5) has the first use of the capital letter \u201cD\u201d to refer to a person exercising powers in relation to a person who lacks capacity. The use of capital letters sometimes makes complex provisions easier to follow (particularly where a number of different people are being referred to), and is a technique often adopted in recent legislation. In this Act, the fact that lack of capacity is specific to particular decisions and that there are many reasons why a person may lack capacity makes it necessary to use a neutral, rather than descriptive, label for the person concerned."
|
| 31 |
+
},
|
| 32 |
+
"http://www.legislation.gov.uk/ukpga/2005/9/section/3": {
|
| 33 |
+
"section_uri": "http://www.legislation.gov.uk/ukpga/2005/9/section/3",
|
| 34 |
+
"act_name": "Mental Capacity Act 2005",
|
| 35 |
+
"section_number": 3,
|
| 36 |
+
"note_text": "21.This sets out the Act\u2019s definition of a person who lacks capacity. It focuses on the particular time when a decision has to be made and on the particular matter to which the decision relates, not on any theoretical ability to make decisions generally. It follows that a person can lack capacity for the purposes of the Act even if the loss of capacity is partial or temporary or if his capacity fluctuates. It also follows that a person may lack capacity in relation to one matter but not in relation to another matter.\n22.The inability to make a decision must be caused by an impairment of or disturbance in the functioning of the mind or brain. This is the so-called \u201cdiagnostic test\u201d. This could cover a range of problems, such as psychiatric illness, learning disability, dementia, brain damage or even a toxic confusional state, as long as it has the necessary effect on the functioning of the mind or brain, causing the person to be unable to make the decision.\n23.Subsection (3) introduces a principle of equal consideration in relation to determinations of a person\u2019s capacity. It makes it clear that such determinations should not merely be made on the basis of a person\u2019s age, appearance or unjustified assumptions about capacity based on the person\u2019s condition or behaviour. Any preconceptions and prejudicial assumptions held by a person making the assessment of capacity must therefore have no input into the assessment of capacity. The reference to \u201ccondition\u201d captures a range of factors, including any physical disability a person may have. So, in making an assessment of capacity, the fact that the person in question has a learning difficulty should not in itself lead the person making the assessment to assume that the person with the learning difficulty would lack capacity to decide, for example, where to live. The reference to \u201cappearance\u201d would also include skin colour.\n24.Subsection (5) makes it clear that powers under the Act generally only arise where the person lacking capacity is 16 or over (although powers in relation to property might be exercised in relation to a younger person who has disabilities which will cause the incapacity to last into adulthood: see section 18(3)). Any overlap with the jurisdiction under the Children Act 1989 can be dealt with by orders about the transfer of proceedings to the more appropriate court (see section 21).\n25.Subsection (5) has the first use of the capital letter \u201cD\u201d to refer to a person exercising powers in relation to a person who lacks capacity. The use of capital letters sometimes makes complex provisions easier to follow (particularly where a number of different people are being referred to), and is a technique often adopted in recent legislation. In this Act, the fact that lack of capacity is specific to particular decisions and that there are many reasons why a person may lack capacity makes it necessary to use a neutral, rather than descriptive, label for the person concerned."
|
| 37 |
+
},
|
| 38 |
+
"http://www.legislation.gov.uk/ukpga/2010/15/section/136": {
|
| 39 |
+
"section_uri": "http://www.legislation.gov.uk/ukpga/2010/15/section/136",
|
| 40 |
+
"act_name": "Equality Act 2010",
|
| 41 |
+
"section_number": 136,
|
| 42 |
+
"note_text": "996.This paragraph replicates the effect of similar provisions in Part 2 of the Equality Act 2006 and the Equality Act (Sexual Orientation) Regulations 2007."
|
| 43 |
+
},
|
| 44 |
+
"http://www.legislation.gov.uk/ukpga/2010/15/schedule/3": {
|
| 45 |
+
"section_uri": "http://www.legislation.gov.uk/ukpga/2010/15/schedule/3",
|
| 46 |
+
"act_name": "Equality Act 2010",
|
| 47 |
+
"section_number": 3,
|
| 48 |
+
"note_text": "479.This section imposes a duty, known as the public sector equality duty, on the public bodies listed in Schedule 19 to have due regard to three specified matters when exercising their functions. The three matters are:\n\teliminating conduct that is prohibited by the Act, including breaches of non-discrimination rules in occupational pension schemes and equality clauses or rules which are read, respectively into a person\u2019s terms of work and into occupational pension schemes;\n\tadvancing equality of opportunity between people who share a protected characteristic and people who do not share it; and\n\tfostering good relations between people who share a protected characteristic and people who do not share it.\n480.The second and third matters apply to the protected characteristics of age, disability, gender reassignment, pregnancy and maternity, race, religion or belief, sex and sexual orientation. They do not apply to the protected characteristic of marriage and civil partnership.\n481.As well as the public bodies listed in Schedule 19, the section also imposes the public sector equality duty on others that exercise public functions, but only in respect of their public functions. Section 150 explains what is meant by \u201cpublic function\u201d.\n482.Subsections (3), (4) and (5) expand on what it means to have due regard to the need to advance equality of opportunity and foster good relations. In particular, subsection (4) makes clear that having due regard to the need to advance equality of opportunity between disabled people and non-disabled people includes consideration of the need to take steps to take account of disabled people\u2019s disabilities. Subsection (6) makes clear that complying with the duty might mean treating some people more favourably than others, where doing so is allowed by the Act. This includes treating disabled people more favourably than non-disabled people and making reasonable adjustments for them, making use of exceptions which permit different treatment, and using the positive action provisions in Chapter 2 of this Part where they are available.\n483.Schedule 18 sets out persons and functions to which the equality duty does not apply."
|
| 49 |
+
},
|
| 50 |
+
"http://www.legislation.gov.uk/ukpga/2010/15/schedule/2": {
|
| 51 |
+
"section_uri": "http://www.legislation.gov.uk/ukpga/2010/15/schedule/2",
|
| 52 |
+
"act_name": "Equality Act 2010",
|
| 53 |
+
"section_number": 2,
|
| 54 |
+
"note_text": "1.These explanatory notes relate to the Equality Act 2010 which received Royal Assent on 8 April 2010. They have been prepared by the Government Equalities Office, the Department for Work and Pensions (in respect of provisions relating to disability and pensions), the Department for Children, Schools and Families and the Department for Business, Innovation and Skills (in respect of provisions relating to education), the Department for Transport (in respect of provisions relating to disability and transport) and the Department for Business, Innovation and Skills (in respect of provisions relating to work exceptions). Their purpose is to assist the reader in understanding the Act. They do not form part of the Act and have not been endorsed by Parliament.\n2.The notes need to be read in conjunction with the Act. They are not, and are not meant to be, a comprehensive description of the Act. So where a section or part of a section does not seem to require any explanation or comment, none is given."
|
| 55 |
+
}
|
| 56 |
+
}
|
test_semantic.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
|
| 3 |
+
base_url = 'https://lex.lab.i.ai.gov.uk'
|
| 4 |
+
url = f'{base_url}/legislation/section/search'
|
| 5 |
+
|
| 6 |
+
payload = {
|
| 7 |
+
'query': 'patient wants to leave but is sectioned',
|
| 8 |
+
'limit': 5
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
r = httpx.post(url, json=payload, timeout=10)
|
| 12 |
+
print('Status:', r.status_code)
|
| 13 |
+
d = r.json()
|
| 14 |
+
if isinstance(d, list) and d:
|
| 15 |
+
for n in d:
|
| 16 |
+
print('---')
|
| 17 |
+
act_name = n.get('act_name', 'Unknown')
|
| 18 |
+
sec_num = n.get('number', '??')
|
| 19 |
+
sec_title = n.get('title', 'No Title')
|
| 20 |
+
print(f'{act_name} - Section {sec_num}: {sec_title}')
|
| 21 |
+
print(f'Text: {n.get("text", "")[:100]}...')
|
| 22 |
+
else:
|
| 23 |
+
print('Raw:', d)
|