Skip to content

Commit df21258

Browse files
committed
Refactor logic to support local mode with BedrockChat and improve code quality
1 parent 5ef22c5 commit df21258

File tree

8 files changed

+407
-338
lines changed

8 files changed

+407
-338
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,19 @@ You will now be able to use AWS and SAM CLI commands to access the dev account.
7272

7373
When the token expires, you may need to reauthorise using `make aws-login`
7474

75+
### Running locally
76+
77+
If you want to test the EPS query functionality without starting the Slack bot, you can run the application in local mode. This is useful for development, debugging, or when Slack credentials are not available.
78+
79+
To do this, set the `LOCAL_MODE` environment variable to `1`:
80+
81+
```bash
82+
export LOCAL_MODE=1
83+
poetry run python packages/slackbot/app.py
84+
```
85+
86+
For running the query tool only, see the [querytool instructions](packages/querytool/README.md).
87+
7588
### CI Setup
7689

7790
The GitHub Actions require a secret to exist on the repo called "SONAR_TOKEN".

packages/querytool/README.md

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,92 @@
1-
# Query tool
1+
# Query Tool
22

3-
This is the tool that augments incoming user queries with EPS specific data.
3+
This module powers EPS Assist's ability to interpret technical queries by leveraging EPS documentation and returning answers generated from retrieved context.
4+
5+
It uses LangChain, semantic embeddings, and DuckDB to provide retrieval-augmented generation (RAG) for queries related to EPS APIs, SCAL, and other NHS documentation.
46

57
## Prerequisites
68

7-
- Python 3.12
8-
- Poetry
9-
- .env file
9+
All runtime and development dependencies (Python, Node, Poetry, Widdershins, etc.) are installed automatically when you open the project in the devcontainer.
10+
11+
Environment variables required for authentication are managed via `.envrc`.
12+
13+
Ensure your .envrc file includes the following:
14+
15+
```bash
16+
export TOKENIZERS_PARALLELISM=false
17+
export BEDROCK_MODEL_ID=<your_bedrock_model_id>
18+
export AWS_BEARER_TOKEN_BEDROCK=<your_bearer_token>
19+
```
20+
21+
If you're working outside the devcontainer, you can install all dependencies manually by running:
22+
23+
```bash
24+
make install
25+
```
26+
27+
## Preparing the Document Corpus
28+
29+
Before the assistant can answer queries, the EPS documentation must be parsed and embedded into a searchable vector store (DuckDB).
30+
31+
### 1. Convert SCAL CSVs to Markdown
32+
33+
```bash
34+
poetry run python packages/querytool/eps_assist/preprocessors/prepare_scal.py
35+
```
36+
37+
### 2. Convert OAS JSON to Markdown
38+
39+
This pulls the latest NHS OpenAPI specification and converts it to Markdown using Widdershins.
1040

11-
Widdershins:
41+
> **Note**: To suppress noisy Node.js warnings during the conversion, use the `NODE_NO_WARNINGS=1` environment variable as shown below.
1242
13-
npm install -g widdershins
43+
```bash
44+
NODE_NO_WARNINGS=1 poetry run python packages/querytool/eps_assist/preprocessors/prepare_oas.py
45+
```
1446

15-
Load environment variables:
47+
### 3. Build or Rebuild the Vector Store
1648

17-
source .env
49+
This loads all Markdown files into DuckDB with semantic chunking and embeddings.
1850

19-
To set up run:
51+
```bash
52+
poetry run python packages/querytool/eps_assist/transform.py
53+
```
2054

21-
poetry install
55+
A new `eps_corpus.db` file will be created in the same directory.
2256

23-
## Updating corpus
57+
## Running Queries Locally
2458

25-
To prepare the SCAL files for processing, run:
59+
You can run sample questions against the vector store directly:
2660

27-
poetry run python querytool/eps_assist/preprocessors/prepare_scal.py
61+
```bash
62+
poetry run python packages/querytool/eps_assist/query.py
63+
```
2864

29-
To prepare the OAS file for processing, run:
65+
This script:
66+
- Connects to `eps_corpus.db`
67+
- Retrieves relevant document chunks
68+
- Sends the prompt to Claude 3 via Amazon Bedrock
69+
- Outputs the model's answer in your terminal
3070

31-
poetry run python querytool/eps_assist/preprocessors/prepare_oas.py
71+
## Notes
3272

33-
To run the ingestion and transformation of documents into the vector store, run:
73+
- Ensure that `eps_corpus.db` exists before querying. If in doubt, re-run the transformation step.
74+
- Claude 3 is accessed using the AWS Bedrock API via `boto3` and LangChain.
75+
- Vector storage is file-based (DuckDB), so no external database or service is required.
76+
- Environment variables (e.g., AWS credentials) are expected to be managed via `.envrc` in the project root.
3477

35-
poetry run python querytool/eps_assist/transform.py
78+
## File Structure Overview
3679

37-
## Running samples queries
80+
```
81+
eps_assist/
82+
├── docs/ # Source documentation (.md) for SCAL, OAS, etc.
83+
├── preprocessors/ # Scripts for cleaning and converting raw files
84+
├── query.py # Executes a full question-answering example
85+
├── transform.py # Converts docs to vector store (DuckDB)
3886
39-
To run a query, run:
87+
preprocessors/
88+
├── prepare_scal.py # Converts SCAL CSV to Markdown
89+
├── prepare_oas.py # Fetches & converts OpenAPI to Markdown
90+
```
4091

41-
poetry run python querytool/eps_assist/query.py
92+
This module is a self-contained tool that can also be used outside of Slack for testing or integration in other EPS-related projects.

packages/querytool/eps_assist/preprocessors/prepare_oas.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44
oas_url = "https://digital.nhs.uk/restapi/oas/324177"
55
oas_content = requests.get(oas_url)
66

7-
with open("./querytool/eps_assist/preprocessors/.eps_oas.json", "w") as f:
7+
with open("packages/querytool/eps_assist/preprocessors/.eps_oas.json", "w") as f:
88
f.write(oas_content.text)
99

1010
oas_version = oas_content.json()["info"]["version"]
1111

12-
with open("./querytool/eps_assist/docs/eps_oas.version", "w") as f:
12+
with open("packages/querytool/eps_assist/docs/eps_oas.version", "w") as f:
1313
f.write(oas_version)
1414

1515
subprocess.check_output(
1616
["widdershins",
1717
"--expandBody",
1818
"true",
19-
"querytool/eps_assist/preprocessors/.eps_oas.json",
20-
"querytool/eps_assist/docs/eps_output.md"])
19+
"packages/querytool/eps_assist/preprocessors/.eps_oas.json",
20+
"packages/querytool/eps_assist/docs/eps_output.md"])

packages/querytool/eps_assist/preprocessors/prepare_scal.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import csv
22

3-
file_paths = ["./querytool/eps_assist/preprocessors/prescribing_scal.csv",
4-
"./querytool/eps_assist/preprocessors/dispensing_scal.csv"]
5-
6-
7-
file_paths = ["./querytool/eps_assist/preprocessors/prescribing_scal.csv"]
3+
file_paths = ["packages/querytool/eps_assist/preprocessors/prescribing_scal.csv",
4+
"packages/querytool/eps_assist/preprocessors/dispensing_scal.csv"]
85

96

107
def clean_texts(texts: list[str]) -> str:
@@ -50,7 +47,17 @@ def process_file(path: str) -> str:
5047

5148
doc.append(
5249
clean_texts(
53-
[section_id, item, detail, ". Related docs: ", helpful_docs, ". Risk Logs: ", risk_logs, ". Requirement assessed by: ", assessment_type]
50+
[
51+
section_id,
52+
item,
53+
detail,
54+
". Related docs: ",
55+
helpful_docs,
56+
". Risk Logs: ",
57+
risk_logs,
58+
". Requirement assessed by: ",
59+
assessment_type,
60+
]
5461
)
5562
)
5663
continue
@@ -74,11 +81,13 @@ def process_file(path: str) -> str:
7481
else:
7582
if len(related_desc) > 0:
7683
doc.append(
77-
f"SCAL Requirement {section_id} {clean_text(requirement_section)}: {clean_text(requirement_or_section)} related info: {bullet_points_to_sentences(related_desc)}"
84+
f"SCAL Requirement {section_id} {clean_text(requirement_section)}: "
85+
f"{clean_text(requirement_or_section)} related info: {clean_text(related_desc)}"
7886
)
7987
else:
8088
doc.append(
81-
f"SCAL Requirement {section_id} {clean_text(requirement_section)}: {clean_text(requirement_or_section)}"
89+
f"SCAL Requirement {section_id} {clean_text(requirement_section)}: "
90+
f"{clean_text(requirement_or_section)}"
8291
)
8392

8493
return "\n\n".join(doc)
Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,65 @@
1-
# LLM
1+
import duckdb
2+
import os
3+
from pathlib import Path
4+
25
from langchain.embeddings.sentence_transformer import SentenceTransformerEmbeddings
36
from langchain_community.vectorstores import DuckDB
4-
5-
# LLM
6-
from langchain_openai import AzureChatOpenAI
7+
from langchain_community.chat_models import BedrockChat
78
from langchain.callbacks.manager import CallbackManager
89
from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
9-
10-
# QA chain
1110
from langchain.chains import RetrievalQA
12-
from langchain import hub
1311
from langchain.prompts import PromptTemplate
1412

15-
import duckdb
16-
import os
17-
13+
# Load the model ID for Amazon Bedrock from
14+
model_id = os.getenv("BEDROCK_MODEL_ID")
15+
if not model_id:
16+
raise EnvironmentError("BEDROCK_MODEL_ID environment variable is not set.")
1817

18+
# Set up embeddings
1919
embedding_function = SentenceTransformerEmbeddings(model_name="all-mpnet-base-v2")
2020

21-
DB_PATH = "./eps_corpus.db"
22-
21+
# Connect to the local DuckDB vector store
22+
DB_PATH = Path(__file__).resolve().parent / "eps_corpus.db"
2323
if os.path.exists(DB_PATH):
24-
print(f"connecting to existing db ({DB_PATH})")
24+
print(f"Connecting to existing db ({DB_PATH})")
2525
conn = duckdb.connect(DB_PATH)
2626
vector_store = DuckDB(connection=conn, embedding=embedding_function)
2727

2828
else:
29-
# load into database
30-
raise Exception(f"db was not found ({DB_PATH})")
31-
29+
# Load into database
30+
raise FileNotFoundError(f"DB not found at ({DB_PATH})")
3231

33-
prompt = hub.pull("rlm/rag-prompt")
34-
35-
llm = AzureChatOpenAI(
36-
openai_api_version="2023-08-01-preview",
37-
azure_deployment="eps-assistant-model",
38-
callback_manager=CallbackManager([StreamingStdOutCallbackHandler()]),
39-
model="gpt-4",
32+
# Load Claude via Amazon Bedrock
33+
llm = BedrockChat(
34+
model_id=model_id,
35+
callback_manager=CallbackManager([StreamingStdOutCallbackHandler()])
4036
)
4137

42-
43-
question = """Are Rate Limits applied to the EPS for Dispenser API or any part thereof? If so, what are they and where do they apply, please?"""
44-
38+
# Build the prompt
4539
prompt = PromptTemplate.from_template(
46-
"""[INST]<<SYS>> You are an assistant for question-answering tasks relating to the Electronic Prescribing Services EPS API.
47-
Use the following pieces of retrieved context to answer the question.
48-
Rate limits apply to all APIs (e.g. EPS dispensing, EPS prescribing, PDS)
49-
If you don't know the answer, just say that you don't know. keep the answer concise and include technical references where possible
50-
code samples should be used to support the answer where appropriate.<</SYS>> \nQuestion: {question} \nContext: {context} \n Answer: [/INST]"""
40+
"""[INST]<<SYS>> You are an assistant for question-answering tasks relating to the Electronic Prescribing
41+
Services EPS API. Use the following pieces of retrieved context to answer the question. Rate limits apply
42+
to all APIs (e.g. EPS dispensing, EPS prescribing, PDS). If you don't know the answer, just say that you
43+
don't know. Keep the answer concise and include technical references where possible. Code samples should
44+
be used to support the answer where appropriate.<</SYS>> \nQuestion: {question} \nContext: {context} \n
45+
Answer: [/INST]"""
5146
)
5247

48+
# Create the Retrieval QA chain
5349
qa_chain = RetrievalQA.from_chain_type(
54-
llm,
50+
llm=llm,
5551
retriever=vector_store.as_retriever(),
56-
chain_type_kwargs={"prompt": prompt},
52+
chain_type_kwargs={"prompt": prompt}
5753
)
5854

59-
result = qa_chain.invoke(question)
55+
# Define the question
56+
question = (
57+
"Are Rate Limits applied to the EPS for Dispenser API or any part thereof? If so, what are they and "
58+
"where do they apply, please?"
59+
)
6060

61+
# Run the chain
62+
result = qa_chain.invoke(question)
6163

64+
# Output the result
6265
print(result["result"])

packages/querytool/eps_assist/transform.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
import duckdb
22
import os
3+
from pathlib import Path
34

45
from langchain.embeddings.sentence_transformer import SentenceTransformerEmbeddings
56
from langchain.docstore.document import Document
67
from langchain_community.vectorstores import DuckDB
78
from semantic_text_splitter import MarkdownSplitter
89
from tokenizers import Tokenizer
910

10-
tokenizer = Tokenizer.from_pretrained("bert-base-uncased")
11-
splitter = MarkdownSplitter.from_huggingface_tokenizer(tokenizer)
11+
# Set up the base directory and paths
12+
BASE_DIR = Path(__file__).resolve().parent
13+
DB_PATH = BASE_DIR / "eps_corpus.db"
14+
CORPUS_PATH = BASE_DIR / "docs"
1215

16+
# Toggle this flag to control DB rebuilding
17+
REBUILD_DB = True
18+
19+
# Set up embeddings
1320
embedding_function = SentenceTransformerEmbeddings(model_name="all-mpnet-base-v2")
1421

15-
DB_PATH = "./eps_corpus.db"
16-
CORPUS_PATH = "./querytool/eps_assist/docs/"
22+
# Set up splitter
23+
tokenizer = Tokenizer.from_pretrained("bert-base-uncased")
24+
splitter = MarkdownSplitter.from_huggingface_tokenizer(tokenizer)
1725

1826

1927
def connect_to_existing_vector_store():
@@ -30,8 +38,7 @@ def create_vector_store_file():
3038
vector_store = DuckDB(connection=conn, embedding=embedding_function)
3139

3240
for file in os.listdir(CORPUS_PATH):
33-
34-
file_path = f"{CORPUS_PATH}{file}"
41+
file_path = CORPUS_PATH / file
3542

3643
with open(file_path) as doc:
3744
doc_text = doc.read()
@@ -42,7 +49,6 @@ def create_vector_store_file():
4249
# chunks = doc_text.split("SCAL requirement")
4350
# else:
4451
chunks = splitter.chunks(doc_text, chunk_capacity=(200, 1000))
45-
4652
docs = [Document(page_content=chunk) for chunk in chunks]
4753

4854
print(f"adding {len(docs)} documents to vector store...")
@@ -51,8 +57,8 @@ def create_vector_store_file():
5157

5258
if __name__ == "__main__":
5359

54-
# normally we just want to recreate the file, remove this if you want to test queries
55-
if True and os.path.exists(DB_PATH):
60+
if REBUILD_DB and os.path.exists(DB_PATH):
61+
print(f"removing existing db file ({DB_PATH})")
5662
os.remove(DB_PATH)
5763

5864
if os.path.exists(DB_PATH):
@@ -61,13 +67,17 @@ def create_vector_store_file():
6167
create_vector_store_file()
6268
vector_store = connect_to_existing_vector_store()
6369

64-
results = vector_store.similarity_search(
65-
"""1.6.5 “For eRD prescriptions, the dispenser must see:
66-
• the current issue
67-
• the total number of authorised issues for both the prescription and line items on the prescription.
68-
There is nothing we can see at prescription level that defines the current issue or the number of authorised issues (we can only see it at MedicationRequest level) Eg: Prescription number: 8F4A22-C81007-000012"""
70+
test_query = (
71+
"1.6.5 “For eRD prescriptions, the dispenser must see:\n"
72+
"• the current issue\n"
73+
"• the total number of authorised issues for both the prescription and line items on the prescription.\n"
74+
"There is nothing we can see at prescription level that defines the current issue or the number of "
75+
"authorised issues (we can only see it at MedicationRequest level)\n"
76+
"Eg: Prescription number: 8F4A22-C81007-000012"
6977
)
7078

79+
results = vector_store.similarity_search(test_query)
80+
7181
for result in results:
7282
print("*" * 100)
7383
print(result)

0 commit comments

Comments
 (0)