NurseCitizenDeveloper commited on
Commit
19a3093
·
0 Parent(s):

feat: complete local embedding search with i-dot-ai HF model

Browse files
.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)