Lincoln Gombedza Claude Sonnet 4.6 commited on
Commit
bce01ad
·
unverified ·
1 Parent(s): 45d16b0

feat: unified search, Claude BYOK, expanded legislation coverage

Browse files

Search pipeline:
- Three-layer search in chat: keyword cache → semantic vectors → live Lex API
- Semantic search now also used in chat tab (was Scenario Matcher only)
- Section Lookup falls back to live Lex API on cache miss
- Scenario Matcher falls back to live Lex API when semantic returns nothing

LLM:
- Replace Gemini with Claude (claude-sonnet-4-6) via BYOK
- Users enter their own Anthropic API key (session only, never stored)
- Graceful fallback to raw statutory text when no key provided
- Consistent with other CQAI tools (nursing-research-writer pattern)

Dependencies:
- Remove torch (replaced numpy cosine similarity — saves ~2GB memory on CPU)
- Add anthropic>=0.40
- local_search.py now initialises embedding model at startup, not first request

Legislation coverage:
- Add Mental Health Act 2007, Mental Capacity (Amendment) Act 2019,
Health and Social Care Act 2008, Misuse of Drugs Act 1971,
Data Protection Act 2018 to NURSING_ACTS and lex_client.py
- Expand keyword map from 15 to 50+ entries:
CTOs, AMHP, nearest relative, DoLS, LPS, duty of candour,
data protection, controlled drugs, reasonable adjustments,
human rights, care needs assessment, court of protection, LPA

Bug fixes:
- Resolve git merge conflict in lex_client.py (duplicate file content removed)
- lex_client.py now imported and used in app.py (was dead code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (5) hide show
  1. app.py +262 -146
  2. cached_legislation.py +103 -30
  3. lex_client.py +5 -192
  4. local_search.py +58 -49
  5. requirements.txt +2 -1
app.py CHANGED
@@ -2,8 +2,10 @@
2
  NurseLex — Legal Literacy Agent for All Nurses and Nursing Students
3
  Architecture:
4
  1. Local legislation.parquet — 219K health/social care Acts & SIs for browsing
5
- 2. cached_legislation.py — 1,128 sections loaded from nursing_sections.json
6
- 3. Gemini Flash REST API Plain English explanations (with retry logic)
 
 
7
  """
8
  import os
9
  import asyncio
@@ -11,9 +13,11 @@ import httpx
11
  import logging
12
  import pandas as pd
13
  import gradio as gr
 
14
 
15
  from cached_legislation import search_cached
16
  from local_search import search_scenarios_locally
 
17
 
18
  logging.basicConfig(level=logging.INFO)
19
  logger = logging.getLogger(__name__)
@@ -30,10 +34,13 @@ except Exception as e:
30
  # --- Key nursing legislation IDs ---
31
  NURSING_ACTS = {
32
  "Mental Health Act 1983": "ukpga/1983/20",
 
33
  "Mental Capacity Act 2005": "ukpga/2005/9",
 
34
  "Care Act 2014": "ukpga/2014/23",
35
  "Human Rights Act 1998": "ukpga/1998/42",
36
  "Equality Act 2010": "ukpga/2010/15",
 
37
  "Health and Social Care Act 2012": "ukpga/2012/7",
38
  "Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
39
  "Autism Act 2009": "ukpga/2009/15",
@@ -41,29 +48,26 @@ NURSING_ACTS = {
41
  "Children Act 2004": "ukpga/2004/31",
42
  "Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
43
  "Health and Care Act 2022": "ukpga/2022/31",
 
 
44
  }
45
  REVERSE_ACTS = {v: k for k, v in NURSING_ACTS.items()}
46
 
47
- # --- Gemini REST API ---
48
- GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
49
- GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
50
- GEMINI_MODELS = ["gemini-2.0-flash-lite", "gemini-2.0-flash"]
51
-
52
  SYSTEM_PROMPT = """You are NurseLex, a legal literacy assistant for all UK nurses and nursing students.
53
 
54
  Your role:
55
  1. Answer legal questions using ONLY the legislation text provided in the context.
56
  2. Explain the law in clear, plain English suitable for all nurses and nursing students.
57
  3. Always cite the specific Act, section number, and year.
58
- 4. If the context doesn't contain enough information, say so clearly.
59
  5. Add practical nursing implications (e.g., "In practice, this means...").
60
- 6. Include professional reminders (e.g., NMC Code, duty of care).
61
 
62
- Disclaimers to include:
63
  - "This is for educational purposes only — always consult your trust's legal team for specific cases."
64
  - "This reflects the legislation as written — local trust policies may add additional requirements."
65
 
66
- Format with clear headings, bullet points, and bold key terms."""
67
 
68
  QUICK_QUESTIONS = [
69
  "What is Section 5(4) of the Mental Health Act and when can a nurse use it?",
@@ -73,72 +77,99 @@ QUICK_QUESTIONS = [
73
  "What does Section 117 aftercare mean and who is entitled?",
74
  "What are a nurse's legal duties under the Care Act 2014 for safeguarding?",
75
  "What is Deprivation of Liberty and when do DoLS apply?",
76
- "What rights does a patient have under Section 136?",
77
  ]
78
 
79
- async def call_gemini(prompt: str) -> str:
80
- """Call Gemini via REST API with retry logic and model fallback."""
81
- if not GEMINI_API_KEY:
 
 
 
82
  return ""
83
 
84
- payload = {
85
- "system_instruction": {"parts": [{"text": SYSTEM_PROMPT}]},
86
- "contents": [{"parts": [{"text": prompt}]}],
87
- "generationConfig": {"temperature": 0.3, "maxOutputTokens": 2048},
88
- }
89
-
90
- async with httpx.AsyncClient(timeout=60.0) as client:
91
- for model in GEMINI_MODELS:
92
- url = f"{GEMINI_BASE}/{model}:generateContent?key={GEMINI_API_KEY}"
93
- for attempt in range(3):
94
- try:
95
- resp = await client.post(url, json=payload)
96
- if resp.status_code == 429:
97
- wait = 2 ** (attempt + 1)
98
- logger.warning(f"Rate limited ({model}), retrying in {wait}s")
99
- await asyncio.sleep(wait)
100
- continue
101
- resp.raise_for_status()
102
- data = resp.json()
103
- return data["candidates"][0]["content"]["parts"][0]["text"]
104
- except httpx.HTTPStatusError as e:
105
- if e.response.status_code == 429:
106
- wait = 2 ** (attempt + 1)
107
- logger.warning(f"Rate limited ({model}), retrying in {wait}s")
108
- await asyncio.sleep(wait)
109
- continue
110
- logger.error(f"Gemini API error ({model}): {e.response.status_code}")
111
- break
112
- except Exception as e:
113
- logger.error(f"Gemini error ({model}): {type(e).__name__}")
114
- break
115
- logger.info(f"Model {model} exhausted, trying next...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
- logger.error("All Gemini models failed")
118
- return ""
119
 
120
  def search_legislation_index(query: str, max_results: int = 10) -> pd.DataFrame:
121
  """Search the full legislation index parquet by title."""
122
  if LEG_DF.empty:
123
  return pd.DataFrame()
124
-
125
  mask = LEG_DF["title"].str.contains(query, case=False, na=False)
126
- results = LEG_DF[mask].sort_values("year", ascending=False).head(max_results)
127
- return results
128
 
129
- async def query_and_respond(user_question: str, history: list) -> str:
130
- """Main RAG pipeline: local cached sections (1,128) + Gemini explanation."""
 
131
  if not user_question.strip():
132
  return "Please enter a question about UK healthcare legislation."
133
 
134
- # Step 1: Search local legislation sections
135
- sections = search_cached(user_question, max_results=5)
136
- logger.info(f"Local search returned {len(sections)} sections for: {user_question[:60]}")
137
 
138
- # Step 2: Search parquet index for related Acts
139
  related_acts = search_legislation_index(user_question, max_results=5)
140
 
141
- # Step 3: Build context
142
  context_parts = []
143
  for section in sections:
144
  title = section.get("title", "Untitled")
@@ -147,35 +178,45 @@ async def query_and_respond(user_question: str, history: list) -> str:
147
  num = section.get("number", "")
148
  context_parts.append(f"### {title}\n**Source:** {leg_id}, Section {num}\n\n{text}\n")
149
 
150
- context = "\n---\n".join(context_parts) if context_parts else "No matching legislation sections found in cache."
 
 
 
 
 
 
151
 
152
- # Step 4: Generate Gemini response
153
- 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."
154
-
155
- answer = await call_gemini(prompt)
156
 
157
- if not answer:
158
- # Fallback: show raw legislation if Gemini fails or is missing key
 
 
 
 
 
 
 
 
 
 
159
  answer = _build_fallback(user_question, sections)
160
- if not GEMINI_API_KEY:
161
- answer += "\n\n⚠️ *Set `GEMINI_API_KEY` in Space secrets for AI-powered plain English explanations.*"
162
- elif "rate limit" in answer.lower():
163
- answer += "\n\n⚠️ *Gemini is currently rate limited, falling back to raw legislation.*"
164
-
165
- # Add source citations
166
- source_acts = set()
167
- for s in sections:
168
- leg_id = s.get("legislation_id", "")
169
- if leg_id:
170
- source_acts.add(leg_id)
171
 
 
 
172
  if source_acts:
173
  answer += "\n\n---\n📚 **Sources:** "
174
- answer += " | ".join(f"[{sid}](https://www.legislation.gov.uk/id/{sid})" for sid in sorted(source_acts))
 
 
175
 
176
- # Add related Acts from parquet
177
  if not related_acts.empty:
178
- answer += "\n\n📖 **Related legislation:** "
179
  act_links = []
180
  for _, row in related_acts.head(3).iterrows():
181
  uri = row.get("uri", "")
@@ -183,23 +224,24 @@ async def query_and_respond(user_question: str, history: list) -> str:
183
  if uri and title:
184
  act_links.append(f"[{title}]({uri})")
185
  if act_links:
186
- answer += " | ".join(act_links)
187
 
188
  answer += "\n\n🏛️ *Data from [legislation.gov.uk](https://www.legislation.gov.uk/) — Crown Copyright, OGL v3.0*"
189
  answer += "\n\n> 📌 **Always verify:** Check the linked source text above before relying on any explanation for practice."
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
 
@@ -219,11 +261,12 @@ def _build_fallback(question: str, sections: list) -> str:
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)
@@ -231,13 +274,28 @@ async def section_lookup(act_name: str, section_input: str) -> str:
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"
@@ -260,23 +318,20 @@ async def section_lookup(act_name: str, section_input: str) -> str:
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:
@@ -284,57 +339,70 @@ async def fetch_explanatory_note(act_name: str, section_input: str) -> str:
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."""
@@ -344,7 +412,13 @@ def browse_legislation(search_term: str, act_type: str) -> str:
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
 
@@ -357,7 +431,6 @@ def browse_legislation(search_term: str, act_type: str) -> str:
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")
@@ -366,11 +439,13 @@ def browse_legislation(search_term: str, act_type: str) -> str:
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",
@@ -417,17 +492,39 @@ with gr.Blocks(theme=THEME, css=CSS, title="NurseLex — UK Law for All Nurses")
417
  </div>
418
  """)
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  with gr.Tabs():
421
  # --- Tab 1: Chat ---
422
  with gr.TabItem("💬 Ask a Legal Question", id="chat"):
423
- gr.Markdown("Ask about UK healthcare legislation — answers are grounded in **real statutory text**. (Cache: 1,128 Sections + 219K Acts)")
 
 
 
424
 
425
  chatbot = gr.Chatbot(
426
  label="NurseLex",
427
  height=480,
428
  type="messages",
429
  show_copy_button=True,
430
- avatar_images=(None, "https://em-content.zobj.net/source/twitter/376/classical-building_1f3db-fe0f.png"),
 
 
 
431
  )
432
  msg = gr.Textbox(
433
  label="Your question",
@@ -454,19 +551,23 @@ with gr.Blocks(theme=THEME, css=CSS, title="NurseLex — UK Law for All Nurses")
454
  variant="secondary",
455
  ).click(fn=lambda q=QUICK_QUESTIONS[i]: q, outputs=msg)
456
 
457
- async def respond(message, history):
458
  history = history or []
459
  history.append({"role": "user", "content": message})
460
- answer = await query_and_respond(message, history)
461
  history.append({"role": "assistant", "content": answer})
462
  return "", history
463
 
464
- submit_btn.click(respond, [msg, chatbot], [msg, chatbot])
465
- msg.submit(respond, [msg, chatbot], [msg, chatbot])
466
 
467
  # --- Tab 2: Section Lookup ---
468
  with gr.TabItem("📖 Section Lookup", id="lookup"):
469
- gr.Markdown("Look up a **specific section** of key nursing Acts. Includes **Official Explanatory Notes** where available.")
 
 
 
 
470
 
471
  with gr.Row():
472
  act_dropdown = gr.Dropdown(
@@ -476,11 +577,11 @@ with gr.Blocks(theme=THEME, css=CSS, title="NurseLex — UK Law for All Nurses")
476
  )
477
  section_input_box = gr.Textbox(
478
  label="Section number",
479
- placeholder="e.g., 5 or 117 or 136",
480
  )
481
 
482
  lookup_btn = gr.Button("🔍 Look Up Law & Notes", variant="primary")
483
-
484
  with gr.Row():
485
  lookup_output = gr.Markdown(label="Statutory Text")
486
  note_output = gr.Markdown(label="Official Explanatory Note")
@@ -490,23 +591,29 @@ with gr.Blocks(theme=THEME, css=CSS, title="NurseLex — UK Law for All Nurses")
490
 
491
  # --- Tab 3: Scenario Matcher ---
492
  with gr.TabItem("🧠 Scenario Matcher", id="scenario"):
493
- gr.Markdown("Describe a clinical scenario in plain English, and the **Lex Vector Search Engine** will map it to the most relevant UK laws.")
494
-
 
 
 
 
495
  with gr.Row():
496
  scenario_input = gr.Textbox(
497
  label="Clinical Scenario",
498
  placeholder="e.g. 'Patient wants to leave the ward but lacks capacity' or 'Doctor orders restraint without DoLS'",
499
- lines=3
500
  )
501
-
502
  scenario_btn = gr.Button("🤖 Find Relevant Law", variant="primary")
503
  scenario_output = gr.Markdown(label="Semantic Search Results")
504
-
505
  scenario_btn.click(scenario_search, [scenario_input], scenario_output)
506
 
507
  # --- Tab 4: Browse Legislation ---
508
  with gr.TabItem("📚 Browse Legislation", id="browse"):
509
- gr.Markdown(f"Browse **219,678** health & social care Acts and Statutory Instruments from the i.AI Lex dataset.")
 
 
510
 
511
  with gr.Row():
512
  browse_search = gr.Textbox(
@@ -524,9 +631,9 @@ with gr.Blocks(theme=THEME, css=CSS, title="NurseLex — UK Law for All Nurses")
524
 
525
  browse_btn.click(browse_legislation, [browse_search, browse_type], browse_output)
526
 
527
- # --- Tab 4: About ---
528
  with gr.TabItem("ℹ️ About", id="about"):
529
- gr.Markdown(f"""
530
  ## About NurseLex
531
 
532
  **NurseLex** is a universal legal literacy tool for **all nurses and nursing students**.
@@ -534,23 +641,33 @@ with gr.Blocks(theme=THEME, css=CSS, title="NurseLex — UK Law for All Nurses")
534
  ### How It Works
535
 
536
  1. **You ask a question** about UK healthcare law
537
- 2. **Cached legislation** provides the actual statutory text instantly
538
- 3. **Gemini Flash** explains it in plain English with practical nursing implications
539
- 4. **Every answer cites** the specific Act, section, and year
 
 
 
540
 
541
  ### Data
542
 
543
  - **219,678 legislation entries** from the [i.AI Lex](https://lex.lab.i.ai.gov.uk/) bulk dataset
544
- - **1,128 key sections** pre-cached with full text (MHA 1983, MCA 2005, Care Act 2014)
545
  - **Crown Copyright** — Open Government Licence v3.0
546
 
547
  ### Key Acts Covered
548
 
549
  | Act | Key Sections | Nursing Relevance |
550
  |---|---|---|
551
- | 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 |
552
- | Mental Capacity Act 2005 | S.1 (Principles), S.2-3 (Capacity), S.4 (Best Interests), S.5 | Capacity assessments, best interests, DoLS |
553
- | Care Act 2014 | S.42 (Safeguarding), S.67 (Advocacy) | Safeguarding adults, independent advocates |
 
 
 
 
 
 
 
554
 
555
  ### Built By
556
 
@@ -565,4 +682,3 @@ app.queue()
565
 
566
  if __name__ == "__main__":
567
  app.launch(server_name="0.0.0.0", server_port=7860)
568
-
 
2
  NurseLex — Legal Literacy Agent for All Nurses and Nursing Students
3
  Architecture:
4
  1. Local legislation.parquet — 219K health/social care Acts & SIs for browsing
5
+ 2. cached_legislation.py — 1,128 sections (keyword + BM25-style search)
6
+ 3. local_search.pysemantic vector search via i-dot-ai fine-tuned MiniLM
7
+ 4. lex_client.py — live i.AI Lex API fallback for cache misses
8
+ 5. Claude (BYOK) via Anthropic SDK — plain English explanations
9
  """
10
  import os
11
  import asyncio
 
13
  import logging
14
  import pandas as pd
15
  import gradio as gr
16
+ from anthropic import AsyncAnthropic
17
 
18
  from cached_legislation import search_cached
19
  from local_search import search_scenarios_locally
20
+ from lex_client import search_legislation_sections
21
 
22
  logging.basicConfig(level=logging.INFO)
23
  logger = logging.getLogger(__name__)
 
34
  # --- Key nursing legislation IDs ---
35
  NURSING_ACTS = {
36
  "Mental Health Act 1983": "ukpga/1983/20",
37
+ "Mental Health Act 2007": "ukpga/2007/12",
38
  "Mental Capacity Act 2005": "ukpga/2005/9",
39
+ "Mental Capacity (Amendment) Act 2019": "ukpga/2019/17",
40
  "Care Act 2014": "ukpga/2014/23",
41
  "Human Rights Act 1998": "ukpga/1998/42",
42
  "Equality Act 2010": "ukpga/2010/15",
43
+ "Health and Social Care Act 2008": "ukpga/2008/14",
44
  "Health and Social Care Act 2012": "ukpga/2012/7",
45
  "Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
46
  "Autism Act 2009": "ukpga/2009/15",
 
48
  "Children Act 2004": "ukpga/2004/31",
49
  "Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
50
  "Health and Care Act 2022": "ukpga/2022/31",
51
+ "Misuse of Drugs Act 1971": "ukpga/1971/38",
52
+ "Data Protection Act 2018": "ukpga/2018/12",
53
  }
54
  REVERSE_ACTS = {v: k for k, v in NURSING_ACTS.items()}
55
 
 
 
 
 
 
56
  SYSTEM_PROMPT = """You are NurseLex, a legal literacy assistant for all UK nurses and nursing students.
57
 
58
  Your role:
59
  1. Answer legal questions using ONLY the legislation text provided in the context.
60
  2. Explain the law in clear, plain English suitable for all nurses and nursing students.
61
  3. Always cite the specific Act, section number, and year.
62
+ 4. If the context doesn't contain enough information, say so clearly — do not invent statutory text.
63
  5. Add practical nursing implications (e.g., "In practice, this means...").
64
+ 6. Include professional reminders (e.g., NMC Code, duty of care, trust policy).
65
 
66
+ Always include:
67
  - "This is for educational purposes only — always consult your trust's legal team for specific cases."
68
  - "This reflects the legislation as written — local trust policies may add additional requirements."
69
 
70
+ Format responses with clear headings, bullet points, and bold key terms."""
71
 
72
  QUICK_QUESTIONS = [
73
  "What is Section 5(4) of the Mental Health Act and when can a nurse use it?",
 
77
  "What does Section 117 aftercare mean and who is entitled?",
78
  "What are a nurse's legal duties under the Care Act 2014 for safeguarding?",
79
  "What is Deprivation of Liberty and when do DoLS apply?",
80
+ "What is a Community Treatment Order and when can it be used?",
81
  ]
82
 
83
+
84
+ # --- Claude BYOK ---
85
+
86
+ async def call_claude(prompt: str, api_key: str) -> str:
87
+ """Call Claude via the Anthropic SDK with the user's own API key."""
88
+ if not api_key or not api_key.strip().startswith("sk-"):
89
  return ""
90
 
91
+ try:
92
+ client = AsyncAnthropic(api_key=api_key.strip())
93
+ message = await client.messages.create(
94
+ model="claude-sonnet-4-6",
95
+ max_tokens=2048,
96
+ system=SYSTEM_PROMPT,
97
+ messages=[{"role": "user", "content": prompt}],
98
+ )
99
+ return message.content[0].text
100
+ except Exception as e:
101
+ err = str(e).lower()
102
+ logger.error(f"Claude API error: {type(e).__name__}: {e}")
103
+ if "authentication" in err or "401" in err or "invalid x-api-key" in err:
104
+ return "__AUTH_ERROR__"
105
+ if "rate" in err or "429" in err:
106
+ return "__RATE_LIMIT__"
107
+ return ""
108
+
109
+
110
+ # --- Unified search pipeline ---
111
+
112
+ async def _gather_sections(user_question: str) -> list[dict]:
113
+ """
114
+ Three-layer search: keyword/BM25 cache semantic vector search → live Lex API.
115
+ Results are deduplicated by (legislation_id, section_number).
116
+ """
117
+ seen: set[tuple] = set()
118
+ sections: list[dict] = []
119
+
120
+ def add(s: dict) -> bool:
121
+ key = (s.get("legislation_id"), s.get("number"))
122
+ if key not in seen:
123
+ seen.add(key)
124
+ sections.append(s)
125
+ return True
126
+ return False
127
+
128
+ # Layer 1: keyword + BM25 cache (instant)
129
+ for s in search_cached(user_question, max_results=5):
130
+ add(s)
131
+ logger.info(f"Cache returned {len(sections)} sections")
132
+
133
+ # Layer 2: local semantic vector search
134
+ semantic = search_scenarios_locally(user_question, top_k=5)
135
+ for s in semantic:
136
+ add(s)
137
+ logger.info(f"After semantic search: {len(sections)} sections")
138
+
139
+ # Layer 3: live Lex API fallback when local sources are sparse
140
+ if len(sections) < 3:
141
+ logger.info("Falling back to live Lex API...")
142
+ try:
143
+ live = await search_legislation_sections(user_question, size=5)
144
+ for s in live:
145
+ add(s)
146
+ logger.info(f"After live Lex API: {len(sections)} sections")
147
+ except Exception as e:
148
+ logger.warning(f"Live Lex API fallback failed: {e}")
149
+
150
+ return sections
151
 
 
 
152
 
153
  def search_legislation_index(query: str, max_results: int = 10) -> pd.DataFrame:
154
  """Search the full legislation index parquet by title."""
155
  if LEG_DF.empty:
156
  return pd.DataFrame()
 
157
  mask = LEG_DF["title"].str.contains(query, case=False, na=False)
158
+ return LEG_DF[mask].sort_values("year", ascending=False).head(max_results)
 
159
 
160
+
161
+ async def query_and_respond(user_question: str, history: list, api_key: str = "") -> str:
162
+ """Main RAG pipeline: three-layer search + Claude explanation."""
163
  if not user_question.strip():
164
  return "Please enter a question about UK healthcare legislation."
165
 
166
+ # Gather sections from all three layers
167
+ sections = await _gather_sections(user_question)
 
168
 
169
+ # Search parquet index for related Acts
170
  related_acts = search_legislation_index(user_question, max_results=5)
171
 
172
+ # Build context string for the LLM
173
  context_parts = []
174
  for section in sections:
175
  title = section.get("title", "Untitled")
 
178
  num = section.get("number", "")
179
  context_parts.append(f"### {title}\n**Source:** {leg_id}, Section {num}\n\n{text}\n")
180
 
181
+ context = "\n---\n".join(context_parts) if context_parts else "No matching legislation sections found."
182
+
183
+ prompt = (
184
+ f"## Nurse's Question\n{user_question}\n\n"
185
+ f"## Relevant UK Legislation\n{context}\n\n"
186
+ f"Please answer the nurse's question using the legislation above."
187
+ )
188
 
189
+ answer = await call_claude(prompt, api_key)
 
 
 
190
 
191
+ # Handle API errors gracefully
192
+ if answer == "__AUTH_ERROR__":
193
+ answer = (
194
+ "❌ **Invalid API key.** Please check your Anthropic API key in the sidebar.\n\n"
195
+ + _build_fallback(user_question, sections)
196
+ )
197
+ elif answer == "__RATE_LIMIT__":
198
+ answer = (
199
+ "⏳ **Rate limited.** Please wait a moment and try again.\n\n"
200
+ + _build_fallback(user_question, sections)
201
+ )
202
+ elif not answer:
203
  answer = _build_fallback(user_question, sections)
204
+ if not api_key or not api_key.strip().startswith("sk-"):
205
+ answer += (
206
+ "\n\n💡 *Enter your Anthropic API key in the sidebar for AI-powered plain English explanations. "
207
+ "Get a free key at [console.anthropic.com](https://console.anthropic.com).*"
208
+ )
 
 
 
 
 
 
209
 
210
+ # Append source citations
211
+ source_acts = {s.get("legislation_id") for s in sections if s.get("legislation_id")}
212
  if source_acts:
213
  answer += "\n\n---\n📚 **Sources:** "
214
+ answer += " | ".join(
215
+ f"[{sid}](https://www.legislation.gov.uk/id/{sid})" for sid in sorted(source_acts)
216
+ )
217
 
218
+ # Append related Acts from parquet
219
  if not related_acts.empty:
 
220
  act_links = []
221
  for _, row in related_acts.head(3).iterrows():
222
  uri = row.get("uri", "")
 
224
  if uri and title:
225
  act_links.append(f"[{title}]({uri})")
226
  if act_links:
227
+ answer += "\n\n📖 **Related legislation:** " + " | ".join(act_links)
228
 
229
  answer += "\n\n🏛️ *Data from [legislation.gov.uk](https://www.legislation.gov.uk/) — Crown Copyright, OGL v3.0*"
230
  answer += "\n\n> 📌 **Always verify:** Check the linked source text above before relying on any explanation for practice."
231
  return answer
232
 
233
+
234
  def _build_fallback(question: str, sections: list) -> str:
235
+ """Display raw legislation text when no LLM is available."""
236
  response = f"## Legislation relevant to: *{question}*\n\n"
237
 
238
  if not sections:
239
  response += (
240
+ "No matching sections found. Try the **Browse Legislation** tab, or use specific terms like:\n"
241
  "- **\"Section 5(4)\"** or **\"nurse holding power\"**\n"
242
  "- **\"best interests\"** or **\"capacity\"**\n"
243
  "- **\"safeguarding\"** or **\"Section 42\"**\n"
244
+ "- **\"community treatment order\"** or **\"DoLS\"**\n"
245
  )
246
  return response
247
 
 
261
 
262
  return response
263
 
264
+
265
  async def section_lookup(act_name: str, section_input: str) -> str:
266
+ """Look up sections from cached legislation, with live Lex API fallback."""
267
  legislation_id = NURSING_ACTS.get(act_name)
268
  if not legislation_id:
269
+ return "❌ Act not found in NurseLex."
270
 
271
  cache_query = f"{act_name} section {section_input}" if section_input.strip() else act_name
272
  sections = search_cached(cache_query, max_results=10)
 
274
 
275
  if section_input.strip() and sections:
276
  try:
277
+ target_num = int(
278
+ section_input.strip()
279
+ .replace("Section ", "")
280
+ .replace("s.", "")
281
+ .replace("S.", "")
282
+ )
283
  matching = [s for s in sections if s.get("number") == target_num]
284
  if matching:
285
  sections = matching
286
  except ValueError:
287
  pass
288
 
289
+ # Live Lex API fallback if cache misses
290
+ if not sections:
291
+ logger.info(f"Section lookup cache miss — trying live Lex API for {act_name} s.{section_input}")
292
+ try:
293
+ query = f"{act_name} section {section_input}" if section_input.strip() else act_name
294
+ live = await search_legislation_sections(query, legislation_id=legislation_id, size=5)
295
+ sections = live
296
+ except Exception as e:
297
+ logger.warning(f"Live Lex API lookup failed: {e}")
298
+
299
  if not sections:
300
  return (
301
  f"⏳ Section not found in cache for **{act_name}**.\n\n"
 
318
  result += "\n🏛️ *Crown Copyright, OGL v3.0*"
319
  return result
320
 
321
+
322
  async def fetch_explanatory_note(act_name: str, section_input: str) -> str:
323
+ """Fetch Explanatory Notes from the i.AI Lex API."""
324
  if not section_input.strip():
325
  return "Please specify a section number to view its Explanatory Note."
326
+
327
  try:
 
328
  section_number = "".join([c for c in section_input if c.isdigit()])
329
  if not section_number:
330
  return "Please enter a valid section number."
331
+
332
+ url = "https://lex.lab.i.ai.gov.uk/explanatory_note/section/search"
333
+ payload = {"query": f'"{act_name}" Section {section_number}', "limit": 5}
334
+
 
 
 
335
  async with httpx.AsyncClient() as client:
336
  r = await client.post(url, json=payload, timeout=10.0)
337
  if r.status_code == 200:
 
339
  if isinstance(data, list):
340
  parent_id = NURSING_ACTS.get(act_name, "")
341
  for note in data:
342
+ if parent_id and parent_id in note.get("legislation_id", ""):
343
+ text = note.get("text", "")
344
  if text:
345
  return f"### Official Explanatory Note\n\n{text}\n\n*Source: i.AI Lex API*"
346
+
347
+ return (
348
+ f"No official Explanatory Note found for {act_name} Section {section_number}.\n\n"
349
+ "*(Acts passed prior to 1999 generally do not have Explanatory Notes.)*"
350
+ )
351
  except httpx.TimeoutException:
352
+ return "⏳ API timeout while fetching Explanatory Note."
353
  except Exception as e:
354
  return f"Error fetching note: {str(e)}"
355
 
356
+
357
  async def scenario_search(scenario_text: str) -> str:
358
+ """Semantic search: map a clinical scenario to relevant UK law sections."""
359
  if not scenario_text.strip():
360
  return "Please describe a clinical scenario."
361
+
362
  try:
363
  results = search_scenarios_locally(scenario_text, top_k=5)
364
+
365
+ # Fallback to live Lex API if semantic search returns nothing
366
+ if not results:
367
+ logger.info("Scenario semantic search returned nothing — trying live Lex API...")
368
+ try:
369
+ live = await search_legislation_sections(scenario_text, size=5)
370
+ results = live
371
+ except Exception as e:
372
+ logger.warning(f"Live Lex API scenario fallback failed: {e}")
373
+
374
  if not results:
375
+ return "No matching legislation found for this scenario. Try the **Chat** tab for a broader search."
376
+
377
  result = f"## ⚖️ Probable Legislation Matches for:\n*{scenario_text}*\n\n"
378
+
379
  for i, n in enumerate(results, 1):
380
  leg_id = n.get("legislation_id", "")
381
+
 
382
  act_name = ""
383
  for known_id, known_name in REVERSE_ACTS.items():
384
  if known_id in leg_id:
385
  act_name = known_name
386
  break
 
 
387
  if not act_name:
388
  act_name = leg_id.split("/id/")[-1] if "/id/" in leg_id else leg_id or "Legislation"
389
+
390
  sec_num = n.get("number", "??")
391
  title = n.get("title", "Untitled Section")
392
  text = n.get("text", "")
393
  uri = n.get("uri", f"https://www.legislation.gov.uk/id/{leg_id}/section/{sec_num}")
394
  score = n.get("score", 0.0)
395
+
396
+ result += f"### {i}. {act_name} — Section {sec_num}: {title}"
397
+ if score:
398
+ result += f" *(Match: {score:.2f})*"
399
+ result += f"\n{text[:800]}...\n\n"
400
  result += f"🔗 [Read full text on legislation.gov.uk]({uri})\n\n---\n\n"
401
+
402
  return result
403
  except Exception as e:
404
+ return f"Error during scenario search: {str(e)}"
405
+
406
 
407
  def browse_legislation(search_term: str, act_type: str) -> str:
408
  """Browse the legislation index from the parquet file."""
 
412
  filtered = LEG_DF.copy()
413
 
414
  if act_type != "All":
415
+ type_map = {
416
+ "Primary Acts": "ukpga",
417
+ "Statutory Instruments": "uksi",
418
+ "Scottish SIs": "ssi",
419
+ "NI SRs": "nisr",
420
+ "Welsh SIs": "wsi",
421
+ }
422
  if act_type in type_map:
423
  filtered = filtered[filtered["type"] == type_map[act_type]]
424
 
 
431
  return f"No legislation found matching '{search_term}'."
432
 
433
  result = f"## 📖 Legislation Index ({len(filtered)} results)\n\n| Year | Title | Type |\n|---|---|---|\n"
 
434
  for _, row in filtered.iterrows():
435
  year = row.get("year", "—")
436
  title = row.get("title", "Untitled")
 
439
  title_link = f"[{title}]({uri})" if uri else title
440
  result += f"| {year} | {title_link} | {leg_type} |\n"
441
 
442
+ result += f"\n\n*Showing top 50 of {len(LEG_DF)} health & social care entries — {len(LEG_DF[LEG_DF['type'] == 'ukpga'])} Primary Acts*"
443
  result += "\n\n🏛️ *Data from i.AI Lex bulk downloads — Crown Copyright, OGL v3.0*"
444
  return result
445
 
446
+
447
  # --- Gradio UI ---
448
+
449
  THEME = gr.themes.Soft(
450
  primary_hue="indigo",
451
  secondary_hue="violet",
 
492
  </div>
493
  """)
494
 
495
+ # --- API Key sidebar ---
496
+ with gr.Accordion("🔑 Anthropic API Key (for AI explanations)", open=False):
497
+ gr.Markdown(
498
+ "Enter your own [Anthropic API key](https://console.anthropic.com) to enable AI-powered plain English explanations "
499
+ "via **Claude**. Your key is used only for your session and is never stored."
500
+ )
501
+ api_key_input = gr.Textbox(
502
+ label="API Key",
503
+ placeholder="sk-ant-...",
504
+ type="password",
505
+ show_label=False,
506
+ )
507
+ gr.Markdown(
508
+ "*Without a key, NurseLex still shows the raw statutory text from legislation.gov.uk.*"
509
+ )
510
+
511
  with gr.Tabs():
512
  # --- Tab 1: Chat ---
513
  with gr.TabItem("💬 Ask a Legal Question", id="chat"):
514
+ gr.Markdown(
515
+ "Ask about UK healthcare legislation — answers are grounded in **real statutory text** "
516
+ "from three sources: cached sections, semantic vector search, and the live i.AI Lex API."
517
+ )
518
 
519
  chatbot = gr.Chatbot(
520
  label="NurseLex",
521
  height=480,
522
  type="messages",
523
  show_copy_button=True,
524
+ avatar_images=(
525
+ None,
526
+ "https://em-content.zobj.net/source/twitter/376/classical-building_1f3db-fe0f.png",
527
+ ),
528
  )
529
  msg = gr.Textbox(
530
  label="Your question",
 
551
  variant="secondary",
552
  ).click(fn=lambda q=QUICK_QUESTIONS[i]: q, outputs=msg)
553
 
554
+ async def respond(message, history, api_key):
555
  history = history or []
556
  history.append({"role": "user", "content": message})
557
+ answer = await query_and_respond(message, history, api_key)
558
  history.append({"role": "assistant", "content": answer})
559
  return "", history
560
 
561
+ submit_btn.click(respond, [msg, chatbot, api_key_input], [msg, chatbot])
562
+ msg.submit(respond, [msg, chatbot, api_key_input], [msg, chatbot])
563
 
564
  # --- Tab 2: Section Lookup ---
565
  with gr.TabItem("📖 Section Lookup", id="lookup"):
566
+ gr.Markdown(
567
+ "Look up a **specific section** of key nursing Acts. "
568
+ "Includes **Official Explanatory Notes** where available. "
569
+ "Falls back to the live Lex API when not in local cache."
570
+ )
571
 
572
  with gr.Row():
573
  act_dropdown = gr.Dropdown(
 
577
  )
578
  section_input_box = gr.Textbox(
579
  label="Section number",
580
+ placeholder="e.g., 5 or 117 or 136 or 17A",
581
  )
582
 
583
  lookup_btn = gr.Button("🔍 Look Up Law & Notes", variant="primary")
584
+
585
  with gr.Row():
586
  lookup_output = gr.Markdown(label="Statutory Text")
587
  note_output = gr.Markdown(label="Official Explanatory Note")
 
591
 
592
  # --- Tab 3: Scenario Matcher ---
593
  with gr.TabItem("🧠 Scenario Matcher", id="scenario"):
594
+ gr.Markdown(
595
+ "Describe a clinical scenario in plain English. "
596
+ "The **semantic vector search engine** maps it to the most relevant UK laws, "
597
+ "with a live Lex API fallback for scenarios not in local cache."
598
+ )
599
+
600
  with gr.Row():
601
  scenario_input = gr.Textbox(
602
  label="Clinical Scenario",
603
  placeholder="e.g. 'Patient wants to leave the ward but lacks capacity' or 'Doctor orders restraint without DoLS'",
604
+ lines=3,
605
  )
606
+
607
  scenario_btn = gr.Button("🤖 Find Relevant Law", variant="primary")
608
  scenario_output = gr.Markdown(label="Semantic Search Results")
609
+
610
  scenario_btn.click(scenario_search, [scenario_input], scenario_output)
611
 
612
  # --- Tab 4: Browse Legislation ---
613
  with gr.TabItem("📚 Browse Legislation", id="browse"):
614
+ gr.Markdown(
615
+ "Browse **219,678** health & social care Acts and Statutory Instruments from the i.AI Lex dataset."
616
+ )
617
 
618
  with gr.Row():
619
  browse_search = gr.Textbox(
 
631
 
632
  browse_btn.click(browse_legislation, [browse_search, browse_type], browse_output)
633
 
634
+ # --- Tab 5: About ---
635
  with gr.TabItem("ℹ️ About", id="about"):
636
+ gr.Markdown("""
637
  ## About NurseLex
638
 
639
  **NurseLex** is a universal legal literacy tool for **all nurses and nursing students**.
 
641
  ### How It Works
642
 
643
  1. **You ask a question** about UK healthcare law
644
+ 2. **Three search layers** retrieve actual statutory text:
645
+ - Local cached sections (1,128 sections instant)
646
+ - Semantic vector search using an i.AI fine-tuned MiniLM model
647
+ - Live i.AI Lex API fallback for cache misses
648
+ 3. **Claude** (your own API key) explains it in plain English with practical nursing implications
649
+ 4. **Every answer cites** the specific Act, section, and year with a direct link to legislation.gov.uk
650
 
651
  ### Data
652
 
653
  - **219,678 legislation entries** from the [i.AI Lex](https://lex.lab.i.ai.gov.uk/) bulk dataset
654
+ - **1,128 key sections** pre-cached with full text (MHA 1983, MCA 2005, Care Act 2014, and more)
655
  - **Crown Copyright** — Open Government Licence v3.0
656
 
657
  ### Key Acts Covered
658
 
659
  | Act | Key Sections | Nursing Relevance |
660
  |---|---|---|
661
+ | Mental Health Act 1983 | S.2, S.3, S.4, S.5(2), S.5(4), S.17, S.17A, S.26, S.114, S.117, S.135, S.136 | Detention, holding powers, CTOs, AMHP, aftercare |
662
+ | Mental Health Act 2007 | Amends MHA 1983 — CTOs, AMHP role, Supervised Community Treatment | Community treatment, amended criteria |
663
+ | Mental Capacity Act 2005 | S.1–5 (Principles/Capacity), S.4A (DoLS), S.9 (LPA), S.45 (CoP) | Capacity, best interests, DoLS, LPA |
664
+ | Mental Capacity (Amendment) Act 2019 | Liberty Protection Safeguards | Replaces DoLS (implementation pending) |
665
+ | Care Act 2014 | S.9 (Assessment), S.13 (Eligibility), S.42 (Safeguarding), S.67 (Advocacy) | Safeguarding adults, advocacy, care assessment |
666
+ | Health and Social Care Act 2008 | S.20 (Duty of Candour) | Notifiable safety incidents, openness |
667
+ | Misuse of Drugs Act 1971 | S.2 (Schedules), S.4 (Supply) | Controlled drug management |
668
+ | Data Protection Act 2018 | S.1, S.45 (Subject access) | Patient data, records, consent |
669
+ | Equality Act 2010 | S.4 (Characteristics), S.6 (Disability), S.20 (Adjustments) | Reasonable adjustments, discrimination |
670
+ | Human Rights Act 1998 | Article 5 (Liberty), Article 8 (Privacy) | Detention rights, confidentiality |
671
 
672
  ### Built By
673
 
 
682
 
683
  if __name__ == "__main__":
684
  app.launch(server_name="0.0.0.0", server_port=7860)
 
cached_legislation.py CHANGED
@@ -25,13 +25,24 @@ except Exception as e:
25
  logger.error(f"Error loading {JSON_PATH}: {e}")
26
 
27
  # --- Keyword Map for Natural Language Shortcuts ---
28
- # Direct section links for common nursing search terms
 
 
29
  KEYWORD_MAP = {
30
- "nurse holding power": ("ukpga/1983/20", "5(4)"),
31
- "doctor holding power": ("ukpga/1983/20", "5(2)"),
 
 
 
 
32
  "section 2": ("ukpga/1983/20", "2"),
 
33
  "section 3": ("ukpga/1983/20", "3"),
 
34
  "section 4": ("ukpga/1983/20", "4"),
 
 
 
35
  "aftercare": ("ukpga/1983/20", "117"),
36
  "section 117": ("ukpga/1983/20", "117"),
37
  "leave of absence": ("ukpga/1983/20", "17"),
@@ -39,77 +50,139 @@ KEYWORD_MAP = {
39
  "place of safety": ("ukpga/1983/20", "136"),
40
  "section 136": ("ukpga/1983/20", "136"),
41
  "section 135": ("ukpga/1983/20", "135"),
42
- "best interests": ("ukpga/2005/9", "4"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  "capacity test": ("ukpga/2005/9", "3"),
44
  "functional test": ("ukpga/2005/9", "3"),
45
- "mca principles": ("ukpga/2005/9", "1"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  "safeguarding": ("ukpga/2014/23", "42"),
47
  "section 42": ("ukpga/2014/23", "42"),
 
48
  "advocacy": ("ukpga/2014/23", "67"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  }
50
 
 
51
  def search_cached(query: str, max_results: int = 5) -> list:
52
  """
53
- Search local sections by keyword, title, or legislation ID.
54
  Returns a list of section dictionaries.
55
  """
56
  if not query:
57
  return []
58
-
59
  query = query.lower().strip()
60
  results = []
61
-
62
- # 1. Check Keyword Map First (High Precision)
63
  for kw, (leg_id, sec_num) in KEYWORD_MAP.items():
64
  if kw in query:
65
- # Find the specific section in our list
66
  for s in CACHED_SECTIONS:
67
  if s.get("legislation_id") == leg_id and str(s.get("number")) == sec_num:
68
  if s not in results:
69
  results.append(s)
70
- # Find closest matches if exact number not found (e.g. 5(4) vs 5)
71
  if not results:
72
- for s in CACHED_SECTIONS:
73
- if s.get("legislation_id") == leg_id and str(s.get("number")).startswith(sec_num.split('(')[0]):
74
  if s not in results:
75
- results.append(s)
76
 
77
- # 2. Text-based Search
78
- # Sort sections by relevance (title match > text match)
79
  scored_results = []
80
  for s in CACHED_SECTIONS:
81
  score = 0
82
  title = s.get("title", "").lower()
83
  text = s.get("text", "").lower()
84
- leg_id = s.get("legislation_id", "").lower()
85
  num = str(s.get("number", "")).lower()
86
-
87
- # Exact section reference (e.g. "Section 5")
88
  if f"section {num}" in query or f"s.{num}" in query or f"s {num}" in query:
89
- score += 100
90
-
91
- # Title matches
92
  if query in title:
93
  score += 50
94
-
95
- # Word matches in title
96
  for word in query.split():
97
  if len(word) > 3 and word in title:
98
  score += 10
99
-
100
- # Content matches
101
  if query in text:
102
  score += 5
103
-
104
  if score > 0:
105
  scored_results.append((score, s))
106
-
107
- # Sort and add to results
108
  scored_results.sort(key=lambda x: x[0], reverse=True)
109
  for _, s in scored_results:
110
  if s not in results:
111
  results.append(s)
112
  if len(results) >= max_results:
113
  break
114
-
115
  return results[:max_results]
 
25
  logger.error(f"Error loading {JSON_PATH}: {e}")
26
 
27
  # --- Keyword Map for Natural Language Shortcuts ---
28
+ # Maps common nursing search terms to (legislation_id, section_number).
29
+ # CTOs and AMHP were inserted into MHA 1983 by MHA 2007, so their
30
+ # legislation_id remains ukpga/1983/20.
31
  KEYWORD_MAP = {
32
+ # MHA 1983 — holding powers
33
+ "nurse holding power": ("ukpga/1983/20", "5"),
34
+ "doctor holding power": ("ukpga/1983/20", "5"),
35
+ "section 5": ("ukpga/1983/20", "5"),
36
+
37
+ # MHA 1983 — admission / detention
38
  "section 2": ("ukpga/1983/20", "2"),
39
+ "admission for assessment": ("ukpga/1983/20", "2"),
40
  "section 3": ("ukpga/1983/20", "3"),
41
+ "admission for treatment": ("ukpga/1983/20", "3"),
42
  "section 4": ("ukpga/1983/20", "4"),
43
+ "emergency admission": ("ukpga/1983/20", "4"),
44
+
45
+ # MHA 1983 — leave, aftercare, place of safety
46
  "aftercare": ("ukpga/1983/20", "117"),
47
  "section 117": ("ukpga/1983/20", "117"),
48
  "leave of absence": ("ukpga/1983/20", "17"),
 
50
  "place of safety": ("ukpga/1983/20", "136"),
51
  "section 136": ("ukpga/1983/20", "136"),
52
  "section 135": ("ukpga/1983/20", "135"),
53
+ "warrant to search": ("ukpga/1983/20", "135"),
54
+
55
+ # MHA 1983 — community treatment orders (inserted by MHA 2007, s.32)
56
+ "community treatment order": ("ukpga/1983/20", "17a"),
57
+ "cto": ("ukpga/1983/20", "17a"),
58
+ "supervised community treatment": ("ukpga/1983/20", "17a"),
59
+ "conditional discharge": ("ukpga/1983/20", "17a"),
60
+ "recall": ("ukpga/1983/20", "17e"),
61
+
62
+ # MHA 1983 — roles (AMHP inserted by MHA 2007, s.21; nearest relative s.26)
63
+ "nearest relative": ("ukpga/1983/20", "26"),
64
+ "approved mental health professional": ("ukpga/1983/20", "114"),
65
+ "amhp": ("ukpga/1983/20", "114"),
66
+ "responsible clinician": ("ukpga/1983/20", "34"),
67
+ "rc": ("ukpga/1983/20", "34"),
68
+
69
+ # MCA 2005 — capacity principles
70
+ "mca principles": ("ukpga/2005/9", "1"),
71
  "capacity test": ("ukpga/2005/9", "3"),
72
  "functional test": ("ukpga/2005/9", "3"),
73
+ "best interests": ("ukpga/2005/9", "4"),
74
+ "lasting power of attorney": ("ukpga/2005/9", "9"),
75
+ "lpa": ("ukpga/2005/9", "9"),
76
+ "court of protection": ("ukpga/2005/9", "45"),
77
+
78
+ # MCA 2005 — DoLS (Schedule A1, accessed via s.4a)
79
+ "deprivation of liberty": ("ukpga/2005/9", "4a"),
80
+ "dols": ("ukpga/2005/9", "4a"),
81
+ "deprivation of liberty safeguards": ("ukpga/2005/9", "4a"),
82
+ "standard authorisation": ("ukpga/2005/9", "4a"),
83
+ "urgent authorisation": ("ukpga/2005/9", "4a"),
84
+
85
+ # Mental Capacity (Amendment) Act 2019 — LPS
86
+ "liberty protection safeguards": ("ukpga/2019/17", "1"),
87
+ "lps": ("ukpga/2019/17", "1"),
88
+
89
+ # Care Act 2014
90
  "safeguarding": ("ukpga/2014/23", "42"),
91
  "section 42": ("ukpga/2014/23", "42"),
92
+ "safeguarding adult review": ("ukpga/2014/23", "44"),
93
  "advocacy": ("ukpga/2014/23", "67"),
94
+ "care needs assessment": ("ukpga/2014/23", "9"),
95
+ "eligible needs": ("ukpga/2014/23", "13"),
96
+
97
+ # Health and Social Care Act 2008 — Duty of Candour
98
+ "duty of candour": ("ukpga/2008/14", "20"),
99
+ "candour": ("ukpga/2008/14", "20"),
100
+ "notifiable safety incident": ("ukpga/2008/14", "20"),
101
+
102
+ # Data Protection Act 2018 / UK GDPR
103
+ "data protection": ("ukpga/2018/12", "1"),
104
+ "gdpr": ("ukpga/2018/12", "1"),
105
+ "patient data": ("ukpga/2018/12", "1"),
106
+ "subject access request": ("ukpga/2018/12", "45"),
107
+
108
+ # Misuse of Drugs Act 1971 — controlled drugs
109
+ "controlled drugs": ("ukpga/1971/38", "2"),
110
+ "schedule 2": ("ukpga/1971/38", "2"),
111
+ "cd cupboard": ("ukpga/1971/38", "2"),
112
+ "misuse of drugs": ("ukpga/1971/38", "4"),
113
+
114
+ # Equality Act 2010
115
+ "reasonable adjustments": ("ukpga/2010/15", "20"),
116
+ "disability discrimination": ("ukpga/2010/15", "6"),
117
+ "protected characteristic": ("ukpga/2010/15", "4"),
118
+
119
+ # Human Rights Act 1998
120
+ "article 5": ("ukpga/1998/42", "1"),
121
+ "right to liberty": ("ukpga/1998/42", "1"),
122
+ "article 8": ("ukpga/1998/42", "1"),
123
+ "right to privacy": ("ukpga/1998/42", "1"),
124
+ "human rights": ("ukpga/1998/42", "1"),
125
  }
126
 
127
+
128
  def search_cached(query: str, max_results: int = 5) -> list:
129
  """
130
+ Search local sections by keyword map, title match, or text content.
131
  Returns a list of section dictionaries.
132
  """
133
  if not query:
134
  return []
135
+
136
  query = query.lower().strip()
137
  results = []
138
+
139
+ # 1. Keyword Map high precision shortcuts
140
  for kw, (leg_id, sec_num) in KEYWORD_MAP.items():
141
  if kw in query:
 
142
  for s in CACHED_SECTIONS:
143
  if s.get("legislation_id") == leg_id and str(s.get("number")) == sec_num:
144
  if s not in results:
145
  results.append(s)
146
+ # Closest match if exact section number not cached (e.g. 5(4) vs 5)
147
  if not results:
148
+ for s in CACHED_SECTIONS:
149
+ if s.get("legislation_id") == leg_id and str(s.get("number")).startswith(sec_num.split("(")[0]):
150
  if s not in results:
151
+ results.append(s)
152
 
153
+ # 2. Scored text search — title and content
 
154
  scored_results = []
155
  for s in CACHED_SECTIONS:
156
  score = 0
157
  title = s.get("title", "").lower()
158
  text = s.get("text", "").lower()
 
159
  num = str(s.get("number", "")).lower()
160
+
161
+ # Exact section reference
162
  if f"section {num}" in query or f"s.{num}" in query or f"s {num}" in query:
163
+ score += 100
164
+
165
+ # Full query in title
166
  if query in title:
167
  score += 50
168
+
169
+ # Individual words in title
170
  for word in query.split():
171
  if len(word) > 3 and word in title:
172
  score += 10
173
+
174
+ # Full query in text
175
  if query in text:
176
  score += 5
177
+
178
  if score > 0:
179
  scored_results.append((score, s))
180
+
 
181
  scored_results.sort(key=lambda x: x[0], reverse=True)
182
  for _, s in scored_results:
183
  if s not in results:
184
  results.append(s)
185
  if len(results) >= max_results:
186
  break
187
+
188
  return results[:max_results]
lex_client.py CHANGED
@@ -14,17 +14,22 @@ LEX_TIMEOUT = 60.0 # Lex API can be slow for semantic search
14
  # Key legislation IDs for mental health & learning disability nursing
15
  NURSING_LEGISLATION = {
16
  "Mental Health Act 1983": "ukpga/1983/20",
 
17
  "Mental Capacity Act 2005": "ukpga/2005/9",
 
18
  "Care Act 2014": "ukpga/2014/23",
19
  "Human Rights Act 1998": "ukpga/1998/42",
20
  "Equality Act 2010": "ukpga/2010/15",
21
  "Health and Social Care Act 2012": "ukpga/2012/7",
 
22
  "Children Act 1989": "ukpga/1989/41",
23
  "Children Act 2004": "ukpga/2004/31",
24
  "Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
25
  "Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
26
  "Health and Care Act 2022": "ukpga/2022/31",
27
  "Autism Act 2009": "ukpga/2009/15",
 
 
28
  }
29
 
30
 
@@ -188,195 +193,3 @@ def format_sections_for_context(sections: list[dict], max_chars: int = 6000) ->
188
  total_chars += len(entry)
189
 
190
  return "".join(context_parts) if context_parts else "No relevant legislation sections found."
191
- =======
192
- """
193
- NurseLex — Lex API Client
194
- Wraps the i.AI Lex API for nursing-focused UK legislation search.
195
- """
196
- import httpx
197
- import logging
198
- from typing import Optional
199
-
200
- logger = logging.getLogger(__name__)
201
-
202
- LEX_API_BASE = "https://lex.lab.i.ai.gov.uk"
203
- LEX_TIMEOUT = 60.0 # Lex API can be slow for semantic search
204
-
205
- # Key legislation IDs for mental health & learning disability nursing
206
- NURSING_LEGISLATION = {
207
- "Mental Health Act 1983": "ukpga/1983/20",
208
- "Mental Capacity Act 2005": "ukpga/2005/9",
209
- "Care Act 2014": "ukpga/2014/23",
210
- "Human Rights Act 1998": "ukpga/1998/42",
211
- "Equality Act 2010": "ukpga/2010/15",
212
- "Health and Social Care Act 2012": "ukpga/2012/7",
213
- "Children Act 1989": "ukpga/1989/41",
214
- "Children Act 2004": "ukpga/2004/31",
215
- "Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
216
- "Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
217
- "Health and Care Act 2022": "ukpga/2022/31",
218
- "Autism Act 2009": "ukpga/2009/15",
219
- }
220
-
221
-
222
- async def _post(endpoint: str, payload: dict) -> dict | list:
223
- """Make a POST request to the Lex API with retry logic."""
224
- url = f"{LEX_API_BASE}{endpoint}"
225
- for attempt in range(3):
226
- try:
227
- async with httpx.AsyncClient(timeout=LEX_TIMEOUT) as client:
228
- resp = await client.post(url, json=payload)
229
- resp.raise_for_status()
230
- return resp.json()
231
- except httpx.TimeoutException:
232
- logger.warning(f"Lex API timeout (attempt {attempt + 1}/3): {endpoint}")
233
- if attempt == 2:
234
- raise
235
- except httpx.HTTPStatusError as e:
236
- logger.error(f"Lex API error {e.response.status_code}: {endpoint}")
237
- raise
238
- return []
239
-
240
-
241
- async def search_legislation_sections(
242
- query: str,
243
- legislation_id: Optional[str] = None,
244
- size: int = 5,
245
- ) -> list[dict]:
246
- """Semantic search across legislation sections."""
247
- payload = {
248
- "query": query,
249
- "size": size,
250
- "include_text": True,
251
- }
252
- if legislation_id:
253
- payload["legislation_id"] = legislation_id
254
-
255
- try:
256
- return await _post("/legislation/section/search", payload)
257
- except Exception as e:
258
- logger.error(f"Section search failed: {e}")
259
- return []
260
-
261
-
262
- async def search_legislation_acts(
263
- query: str,
264
- limit: int = 5,
265
- ) -> dict:
266
- """Search for Acts and Statutory Instruments."""
267
- payload = {
268
- "query": query,
269
- "limit": limit,
270
- "include_text": True,
271
- }
272
-
273
- try:
274
- return await _post("/legislation/search", payload)
275
- except Exception as e:
276
- logger.error(f"Act search failed: {e}")
277
- return {"results": [], "total": 0, "offset": 0, "limit": limit}
278
-
279
-
280
- async def lookup_legislation(legislation_id: str) -> dict:
281
- """Look up a specific Act by its ID (e.g., 'ukpga/1983/20')."""
282
- parts = legislation_id.split("/")
283
- payload = {
284
- "legislation_type": parts[0],
285
- "year": int(parts[1]),
286
- "number": int(parts[2]),
287
- }
288
-
289
- return await _post("/legislation/lookup", payload)
290
-
291
-
292
- async def get_legislation_full_text(
293
- legislation_id: str,
294
- include_schedules: bool = False,
295
- ) -> dict:
296
- """Get the full text of a piece of legislation."""
297
- payload = {
298
- "legislation_id": legislation_id,
299
- "include_schedules": include_schedules,
300
- }
301
-
302
- return await _post("/legislation/text", payload)
303
-
304
-
305
- async def get_sections_for_legislation(
306
- legislation_id: str,
307
- limit: int = 200,
308
- ) -> list[dict]:
309
- """Get all sections for a specific piece of legislation."""
310
- payload = {
311
- "legislation_id": legislation_id,
312
- "limit": limit,
313
- }
314
-
315
- try:
316
- return await _post("/legislation/section/lookup", payload)
317
- except Exception as e:
318
- logger.error(f"Section lookup failed: {e}")
319
- return []
320
-
321
-
322
- async def search_explanatory_notes(
323
- query: str,
324
- legislation_id: Optional[str] = None,
325
- size: int = 5,
326
- ) -> list[dict]:
327
- """Search explanatory notes for legislation."""
328
- payload = {
329
- "query": query,
330
- "size": size,
331
- }
332
- if legislation_id:
333
- payload["legislation_id"] = legislation_id
334
-
335
- try:
336
- return await _post("/explanatory_note/section/search", payload)
337
- except Exception as e:
338
- logger.error(f"Explanatory note search failed: {e}")
339
- return []
340
-
341
-
342
- async def search_amendments(
343
- legislation_id: str,
344
- search_amended: bool = True,
345
- size: int = 20,
346
- ) -> list[dict]:
347
- """Search for amendments to or by a piece of legislation."""
348
- payload = {
349
- "legislation_id": legislation_id,
350
- "search_amended": search_amended,
351
- "size": size,
352
- }
353
-
354
- try:
355
- return await _post("/amendment/search", payload)
356
- except Exception as e:
357
- logger.error(f"Amendment search failed: {e}")
358
- return []
359
-
360
-
361
- def format_sections_for_context(sections: list[dict], max_chars: int = 6000) -> str:
362
- """Format legislation sections into a readable context string for the LLM."""
363
- context_parts = []
364
- total_chars = 0
365
-
366
- for section in sections:
367
- title = section.get("title", "Untitled")
368
- text = section.get("text", "")
369
- leg_id = section.get("legislation_id", "")
370
- section_num = section.get("number", "")
371
-
372
- entry = f"### {title}\n"
373
- entry += f"**Source:** {leg_id}, Section {section_num}\n\n"
374
- entry += f"{text}\n\n---\n\n"
375
-
376
- if total_chars + len(entry) > max_chars:
377
- break
378
- context_parts.append(entry)
379
- total_chars += len(entry)
380
-
381
- return "".join(context_parts) if context_parts else "No relevant legislation sections found."
382
- >>>>>>> a4e257b16d56f80612b7c9ac6d2e7c198fef5bb6
 
14
  # Key legislation IDs for mental health & learning disability nursing
15
  NURSING_LEGISLATION = {
16
  "Mental Health Act 1983": "ukpga/1983/20",
17
+ "Mental Health Act 2007": "ukpga/2007/12",
18
  "Mental Capacity Act 2005": "ukpga/2005/9",
19
+ "Mental Capacity (Amendment) Act 2019": "ukpga/2019/17",
20
  "Care Act 2014": "ukpga/2014/23",
21
  "Human Rights Act 1998": "ukpga/1998/42",
22
  "Equality Act 2010": "ukpga/2010/15",
23
  "Health and Social Care Act 2012": "ukpga/2012/7",
24
+ "Health and Social Care Act 2008": "ukpga/2008/14",
25
  "Children Act 1989": "ukpga/1989/41",
26
  "Children Act 2004": "ukpga/2004/31",
27
  "Safeguarding Vulnerable Groups Act 2006": "ukpga/2006/47",
28
  "Mental Health Units (Use of Force) Act 2018": "ukpga/2018/27",
29
  "Health and Care Act 2022": "ukpga/2022/31",
30
  "Autism Act 2009": "ukpga/2009/15",
31
+ "Misuse of Drugs Act 1971": "ukpga/1971/38",
32
+ "Data Protection Act 2018": "ukpga/2018/12",
33
  }
34
 
35
 
 
193
  total_chars += len(entry)
194
 
195
  return "".join(context_parts) if context_parts else "No relevant legislation sections found."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
local_search.py CHANGED
@@ -3,110 +3,119 @@ 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
  import numpy as np
12
- from sentence_transformers import SentenceTransformer, util
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
- # Constants
17
  MODEL_NAME = "i-dot-ai/all-miniLM-L6-v2-UKPGA-6k-finetune"
18
  CACHE_FILE = os.path.join(os.path.dirname(__file__), "nursing_sections.json")
19
  EMBEDDINGS_FILE = os.path.join(os.path.dirname(__file__), "nursing_sections_embeddings.npy")
20
 
21
- # Global variables to hold the model and embeddings in memory
22
  _model = None
23
- _corpus_embeddings = None
24
  _sections = []
25
 
 
26
  def init_local_search():
27
- """Initializes the model and computes embeddings for all cached sections."""
28
  global _model, _corpus_embeddings, _sections
29
-
30
  if _model is not None:
31
- return # Already initialized
32
-
33
  try:
34
  logger.info(f"Loading local embedding model: {MODEL_NAME}...")
35
  _model = SentenceTransformer(MODEL_NAME)
36
-
37
  if not os.path.exists(CACHE_FILE):
38
  logger.error(f"Cache file not found at {CACHE_FILE}")
39
  return
40
-
41
  with open(CACHE_FILE, "r", encoding="utf-8") as f:
42
  _sections = json.load(f)
43
-
44
  if not _sections:
45
  logger.warning("No sections found in cache.")
46
  return
47
 
48
  if os.path.exists(EMBEDDINGS_FILE):
49
- logger.info("Loading precomputed numpy embeddings from disk (Instant)...")
50
- np_embeddings = np.load(EMBEDDINGS_FILE)
51
- # Convert back to tensor for cosine similarity
52
- _corpus_embeddings = torch.from_numpy(np_embeddings)
53
- logger.info("Local semantic search engine ready.")
54
  return
55
 
56
- logger.info(f"Computing embeddings for {len(_sections)} cached sections. This may take a minute on first run...")
57
- # Prepare text for embedding: combine legislation title, section title, and text
58
  corpus_texts = []
59
  for s in _sections:
60
- # Reconstruct the act name roughly from the URL to give the model context
61
  leg_id = s.get("legislation_id", "")
62
  act_name = leg_id.split("/")[-2] if "/" in leg_id else leg_id
63
-
64
- # Create a rich text representation for the vector search
65
  content = f"Act: {act_name}. Section {s.get('number', '')}: {s.get('title', '')}. {s.get('text', '')}"
66
  corpus_texts.append(content)
67
-
68
- # Encode all sections
69
- _corpus_embeddings = _model.encode(corpus_texts, convert_to_tensor=True, show_progress_bar=False)
70
- logger.info("Saving computed numpy embeddings for future use...")
 
71
  try:
72
- np.save(EMBEDDINGS_FILE, _corpus_embeddings.cpu().numpy())
73
  except Exception as save_err:
74
  logger.warning(f"Failed to save embeddings cache: {save_err}")
75
-
76
- logger.info("Local semantic search engine ready.")
77
-
78
  except Exception as e:
79
- logger.error(f"Failed to initialize local search engine: {e}")
80
- _model = None # Reset on failure
 
 
 
 
 
 
 
 
 
81
 
82
  def search_scenarios_locally(query: str, top_k: int = 5) -> list[dict]:
83
  """Semantic search over the local cached sections using cosine similarity."""
84
- global _model, _corpus_embeddings, _sections
85
-
86
  if _model is None or _corpus_embeddings is None:
 
87
  init_local_search()
88
-
89
  if _model is None or _corpus_embeddings is None:
90
- logger.error("Local search engine is unavailable.")
91
  return []
92
-
93
  try:
94
- query_embedding = _model.encode(query, convert_to_tensor=True)
95
- # Compute cosine similarities
96
- cos_scores = util.cos_sim(query_embedding, _corpus_embeddings)[0]
97
-
98
- # Find the top_k scores
99
- top_results = torch.topk(cos_scores, k=min(top_k, len(_sections)))
100
-
101
  results = []
102
- for score, idx in zip(top_results[0], top_results[1]):
103
- # Only return highly relevant matches (tune this threshold if needed)
104
- if score.item() > 0.4:
105
  match = _sections[idx].copy()
106
- match["score"] = score.item()
107
  results.append(match)
108
-
109
  return results
 
110
  except Exception as e:
111
  logger.error(f"Error during local scenario search: {e}")
112
  return []
 
 
 
 
 
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
+ Uses numpy for cosine similarity — no torch dependency required.
8
+ Initialises at module load time so the first user request is instant.
9
  """
10
  import json
11
  import os
12
  import logging
 
13
  import numpy as np
14
+ from sentence_transformers import SentenceTransformer
15
 
16
  logger = logging.getLogger(__name__)
17
 
 
18
  MODEL_NAME = "i-dot-ai/all-miniLM-L6-v2-UKPGA-6k-finetune"
19
  CACHE_FILE = os.path.join(os.path.dirname(__file__), "nursing_sections.json")
20
  EMBEDDINGS_FILE = os.path.join(os.path.dirname(__file__), "nursing_sections_embeddings.npy")
21
 
 
22
  _model = None
23
+ _corpus_embeddings = None # shape: (N, D) numpy array
24
  _sections = []
25
 
26
+
27
  def init_local_search():
28
+ """Initialises the model and loads/computes embeddings for all cached sections."""
29
  global _model, _corpus_embeddings, _sections
30
+
31
  if _model is not None:
32
+ return # Already initialised
33
+
34
  try:
35
  logger.info(f"Loading local embedding model: {MODEL_NAME}...")
36
  _model = SentenceTransformer(MODEL_NAME)
37
+
38
  if not os.path.exists(CACHE_FILE):
39
  logger.error(f"Cache file not found at {CACHE_FILE}")
40
  return
41
+
42
  with open(CACHE_FILE, "r", encoding="utf-8") as f:
43
  _sections = json.load(f)
44
+
45
  if not _sections:
46
  logger.warning("No sections found in cache.")
47
  return
48
 
49
  if os.path.exists(EMBEDDINGS_FILE):
50
+ logger.info("Loading precomputed numpy embeddings from disk (instant)...")
51
+ _corpus_embeddings = np.load(EMBEDDINGS_FILE)
52
+ logger.info(f"Local semantic search engine ready ({len(_sections)} sections).")
 
 
53
  return
54
 
55
+ logger.info(f"Computing embeddings for {len(_sections)} cached sections...")
 
56
  corpus_texts = []
57
  for s in _sections:
 
58
  leg_id = s.get("legislation_id", "")
59
  act_name = leg_id.split("/")[-2] if "/" in leg_id else leg_id
 
 
60
  content = f"Act: {act_name}. Section {s.get('number', '')}: {s.get('title', '')}. {s.get('text', '')}"
61
  corpus_texts.append(content)
62
+
63
+ embeddings = _model.encode(corpus_texts, convert_to_tensor=False, show_progress_bar=False)
64
+ _corpus_embeddings = np.array(embeddings, dtype=np.float32)
65
+
66
+ logger.info("Saving computed embeddings to disk for future use...")
67
  try:
68
+ np.save(EMBEDDINGS_FILE, _corpus_embeddings)
69
  except Exception as save_err:
70
  logger.warning(f"Failed to save embeddings cache: {save_err}")
71
+
72
+ logger.info(f"Local semantic search engine ready ({len(_sections)} sections).")
73
+
74
  except Exception as e:
75
+ logger.error(f"Failed to initialise local search engine: {e}")
76
+ _model = None
77
+
78
+
79
+ def _cosine_similarity(query_emb: np.ndarray, corpus_emb: np.ndarray) -> np.ndarray:
80
+ """Cosine similarity between a single query vector and a corpus matrix."""
81
+ query_norm = query_emb / (np.linalg.norm(query_emb) + 1e-10)
82
+ corpus_norms = np.linalg.norm(corpus_emb, axis=1, keepdims=True)
83
+ corpus_normalized = corpus_emb / np.where(corpus_norms == 0, 1.0, corpus_norms)
84
+ return corpus_normalized @ query_norm
85
+
86
 
87
  def search_scenarios_locally(query: str, top_k: int = 5) -> list[dict]:
88
  """Semantic search over the local cached sections using cosine similarity."""
 
 
89
  if _model is None or _corpus_embeddings is None:
90
+ logger.warning("Local search engine not ready — attempting re-init.")
91
  init_local_search()
92
+
93
  if _model is None or _corpus_embeddings is None:
94
+ logger.error("Local search engine unavailable.")
95
  return []
96
+
97
  try:
98
+ query_embedding = np.array(
99
+ _model.encode(query, convert_to_tensor=False), dtype=np.float32
100
+ )
101
+ scores = _cosine_similarity(query_embedding, _corpus_embeddings)
102
+
103
+ top_indices = np.argsort(scores)[::-1][:top_k]
104
+
105
  results = []
106
+ for idx in top_indices:
107
+ score = float(scores[idx])
108
+ if score > 0.4:
109
  match = _sections[idx].copy()
110
+ match["score"] = score
111
  results.append(match)
112
+
113
  return results
114
+
115
  except Exception as e:
116
  logger.error(f"Error during local scenario search: {e}")
117
  return []
118
+
119
+
120
+ # Initialise at module load time so first request is fast
121
+ init_local_search()
requirements.txt CHANGED
@@ -3,4 +3,5 @@ httpx>=0.27
3
  pandas>=2.0
4
  pyarrow>=14.0
5
  sentence-transformers>=2.7.0
6
- torch>=2.2.0
 
 
3
  pandas>=2.0
4
  pyarrow>=14.0
5
  sentence-transformers>=2.7.0
6
+ numpy>=1.24
7
+ anthropic>=0.40