Skip to content

Commit 1a4c35c

Browse files
authored
New: [AEA-5546] - Use prompts for better answers (#27)
## Summary 🎫 [AEA-5546](https://nhsd-jira.digital.nhs.uk/browse/AEA-5546) Use prompts for better answers :sparkles: New Feature :robot: Operational or Infrastructure Change ### Details This change improves the relevance of Slack bot answers by introducing a prompt-building and query-rewriting layer before sending user questions to Bedrock’s Knowledge Base retrieve_and_generate API. Key updates: - Added system prompt to instruct Bedrock on response style, source restrictions, and EPS/NHS domain behaviour. - Implemented query rewriting to: - Strip Slack-specific noise (mentions, emojis, formatting). - Expand EPS/NHS domain acronyms and terminology. - Normalize and clarify vague user input. - Modified get_bedrock_knowledgebase_response to send the prompt + rewritten query instead of the raw Slack message. - Externalised prompt content for easier iteration without code changes. - Added logging to compare original vs rewritten queries and help with post-deployment tuning. Outcome: Bedrock receives clearer, more context-rich queries, resulting in answers that are more relevant and better grounded in the knowledge base.
1 parent 2f072ae commit 1a4c35c

File tree

20 files changed

+523
-65
lines changed

20 files changed

+523
-65
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
max-line-length = 120
33
exclude = .*,venv,node_modules,cdk.out
44
max-complexity = 10
5-
ignore = F821,E203
5+
ignore = F821,E203,W503

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ packages/
3636
├── slackBotFunction/ # Lambda function for Slack bot integration
3737
│ ├── app/ # Application code
3838
│ │ ├── config/ # Configuration and environment variables
39+
│ │ ├── services/ # Business logic services
3940
│ │ ├── slack/ # Slack-specific logic
4041
│ │ └── handler.py # Lambda handler
4142
│ └── tests/ # Unit tests
@@ -196,7 +197,7 @@ These are used to do common commands related to cdk
196197
This .github folder contains workflows and templates related to GitHub, along with actions and scripts pertaining to Jira.
197198

198199
- `dependabot.yml` Dependabot definition file.
199-
- `pull_request_template.yml` Template for pull requests.
200+
- `pull_request_template.md` Template for pull requests.
200201

201202
Actions are in the `.github/actions` folder:
202203

@@ -216,10 +217,11 @@ Scripts are in the `.github/scripts` folder:
216217
Workflows are in the `.github/workflows` folder:
217218

218219
- `combine-dependabot-prs.yml` Workflow for combining dependabot pull requests. Runs on demand.
220+
- `create_release_notes.yml` Generates release notes for deployments and environment updates.
219221
- `delete_old_cloudformation_stacks.yml` Workflow for deleting old cloud formation stacks. Runs daily.
220222
- `dependabot_auto_approve_and_merge.yml` Workflow to auto merge dependabot updates.
221223
- `pr_title_check.yml` Checks PR titles for required prefix and ticket or dependabot reference.
222-
- `pr-link.yaml` This workflow template links Pull Requests to Jira tickets and runs when a pull request is opened.
224+
- `pr-link.yml` This workflow template links Pull Requests to Jira tickets and runs when a pull request is opened.
223225
- `pull_request.yml` Called when pull request is opened or updated. Packages and deploys the code to dev AWS account for testing.
224226
- `release.yml` Runs on demand to create a release and deploy to all environments.
225227
- `cdk_package_code.yml` Packages code into a docker image and uploads to a github artifact for later deployment.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {Construct} from "constructs"
2+
import {CfnPrompt} from "aws-cdk-lib/aws-bedrock"
3+
4+
export interface BedrockPromptProps {
5+
promptName: string
6+
promptText: string
7+
description: string
8+
}
9+
10+
export class BedrockPrompt extends Construct {
11+
public readonly promptArn: string
12+
public readonly promptName: string
13+
14+
constructor(scope: Construct, id: string, props: BedrockPromptProps) {
15+
super(scope, id)
16+
17+
const prompt = new CfnPrompt(this, "Prompt", {
18+
name: props.promptName,
19+
description: props.description,
20+
variants: [
21+
{
22+
name: "default",
23+
templateType: "TEXT",
24+
templateConfiguration: {
25+
text: {
26+
text: props.promptText
27+
}
28+
}
29+
}
30+
]
31+
})
32+
33+
this.promptArn = prompt.attrArn
34+
this.promptName = prompt.name
35+
}
36+
}

packages/cdk/nagSuppressions.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,7 @@ export const nagSuppressions = (stack: Stack) => {
3535
[
3636
{
3737
id: "AwsSolutions-IAM5",
38-
reason: "Wildcard permissions are required for log stream access under known paths.",
39-
appliesTo: [
40-
"Resource::<FunctionsSyncKnowledgeBaseFunctionLambdaLogGroupB19BE2BE.Arn>:log-stream:*"
41-
]
38+
reason: "Wildcard permissions are required for log stream access under known paths."
4239
}
4340
]
4441
)
@@ -181,10 +178,7 @@ export const nagSuppressions = (stack: Stack) => {
181178
[
182179
{
183180
id: "AwsSolutions-IAM4",
184-
reason: "Auto-generated CDK role uses AWS managed policy for basic Lambda execution.",
185-
appliesTo: [
186-
"Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
187-
]
181+
reason: "Auto-generated CDK role uses AWS managed policy for basic Lambda execution."
188182
}
189183
]
190184
)
@@ -195,10 +189,7 @@ export const nagSuppressions = (stack: Stack) => {
195189
[
196190
{
197191
id: "AwsSolutions-IAM5",
198-
reason: "Auto-generated CDK role requires wildcard permissions for S3 bucket notifications.",
199-
appliesTo: [
200-
"Resource::*"
201-
]
192+
reason: "Auto-generated CDK role requires wildcard permissions for S3 bucket notifications."
202193
}
203194
]
204195
)
@@ -213,9 +204,6 @@ const safeAddNagSuppression = (stack: Stack, path: string, suppressions: Array<N
213204
}
214205
}
215206

216-
// Apply the same nag suppression to multiple resources
217207
const safeAddNagSuppressionGroup = (stack: Stack, paths: Array<string>, suppressions: Array<NagPackSuppression>) => {
218-
for (const p of paths) {
219-
safeAddNagSuppression(stack, p, suppressions)
220-
}
208+
paths.forEach(path => safeAddNagSuppression(stack, path, suppressions))
221209
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {Construct} from "constructs"
2+
import {BedrockPrompt} from "../constructs/BedrockPrompt"
3+
4+
export interface BedrockPromptResourcesProps {
5+
readonly stackName: string
6+
}
7+
8+
export class BedrockPromptResources extends Construct {
9+
public readonly queryReformulationPrompt: BedrockPrompt
10+
11+
constructor(scope: Construct, id: string, props: BedrockPromptResourcesProps) {
12+
super(scope, id)
13+
14+
this.queryReformulationPrompt = new BedrockPrompt(this, "QueryReformulationPrompt", {
15+
promptName: `${props.stackName}-queryReformulation`,
16+
promptText: `Return the user query exactly as provided without any modifications, changes, or reformulations.
17+
Do not alter, rephrase, or modify the input in any way.
18+
Simply return: {{user_query}}
19+
20+
User Query: {{user_query}}`,
21+
description: "Prompt for reformulating user queries to improve RAG retrieval"
22+
})
23+
}
24+
}

packages/cdk/resources/Functions.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {TableV2} from "aws-cdk-lib/aws-dynamodb"
77

88
// Claude model for RAG responses
99
const RAG_MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"
10+
// Claude model for query reformulation
11+
const QUERY_REFORMULATION_MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"
12+
const QUERY_REFORMULATION_PROMPT_VERSION = "DRAFT"
1013
const BEDROCK_KB_DATA_SOURCE = "eps-assist-kb-ds"
1114
const LAMBDA_MEMORY_SIZE = "265"
1215

@@ -31,6 +34,7 @@ export interface FunctionsProps {
3134
readonly slackBotTokenSecret: Secret
3235
readonly slackBotSigningSecret: Secret
3336
readonly slackBotStateTable: TableV2
37+
readonly promptName: string
3438
}
3539

3640
export class Functions extends Construct {
@@ -62,14 +66,17 @@ export class Functions extends Construct {
6266
additionalPolicies: [props.slackBotManagedPolicy],
6367
environmentVariables: {
6468
"RAG_MODEL_ID": RAG_MODEL_ID,
69+
"QUERY_REFORMULATION_MODEL_ID": QUERY_REFORMULATION_MODEL_ID,
6570
"KNOWLEDGEBASE_ID": props.knowledgeBaseId,
6671
"BEDROCK_KB_DATA_SOURCE": BEDROCK_KB_DATA_SOURCE,
6772
"LAMBDA_MEMORY_SIZE": LAMBDA_MEMORY_SIZE,
6873
"SLACK_BOT_TOKEN_PARAMETER": props.slackBotTokenParameter.parameterName,
6974
"SLACK_SIGNING_SECRET_PARAMETER": props.slackSigningSecretParameter.parameterName,
7075
"GUARD_RAIL_ID": props.guardrailId,
7176
"GUARD_RAIL_VERSION": props.guardrailVersion,
72-
"SLACK_BOT_STATE_TABLE": props.slackBotStateTable.tableName
77+
"SLACK_BOT_STATE_TABLE": props.slackBotStateTable.tableName,
78+
"QUERY_REFORMULATION_PROMPT_NAME": props.promptName,
79+
"QUERY_REFORMULATION_PROMPT_VERSION": QUERY_REFORMULATION_PROMPT_VERSION
7380
}
7481
})
7582

packages/cdk/resources/RuntimePolicies.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {PolicyStatement, ManagedPolicy} from "aws-cdk-lib/aws-iam"
33

44
// Claude model for RAG responses
55
const RAG_MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0"
6+
// Claude model for query reformulation
7+
const QUERY_REFORMULATION_MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"
68

79
export interface RuntimePoliciesProps {
810
readonly region: string
@@ -14,6 +16,7 @@ export interface RuntimePoliciesProps {
1416
readonly knowledgeBaseArn: string
1517
readonly guardrailArn: string
1618
readonly dataSourceArn: string
19+
readonly promptName: string
1720
}
1821

1922
export class RuntimePolicies extends Construct {
@@ -53,7 +56,35 @@ export class RuntimePolicies extends Construct {
5356
// Create managed policy for SlackBot Lambda function
5457
const slackBotPolicy = new PolicyStatement({
5558
actions: ["bedrock:InvokeModel"],
56-
resources: [`arn:aws:bedrock:${props.region}::foundation-model/${RAG_MODEL_ID}`]
59+
resources: [
60+
`arn:aws:bedrock:${props.region}::foundation-model/${RAG_MODEL_ID}`,
61+
`arn:aws:bedrock:${props.region}::foundation-model/${QUERY_REFORMULATION_MODEL_ID}`
62+
]
63+
})
64+
65+
// Compehensive Bedrock prompt policy - includes all prompt management permissions
66+
const slackBotPromptPolicy = new PolicyStatement({
67+
sid: "PromptManagementPermissions",
68+
actions: [
69+
"bedrock:CreatePrompt",
70+
"bedrock:UpdatePrompt",
71+
"bedrock:GetPrompt",
72+
"bedrock:ListPrompts",
73+
"bedrock:DeletePrompt",
74+
"bedrock:CreatePromptVersion",
75+
"bedrock:OptimizePrompt",
76+
"bedrock:GetFoundationModel",
77+
"bedrock:ListFoundationModels",
78+
"bedrock:GetInferenceProfile",
79+
"bedrock:ListInferenceProfiles",
80+
"bedrock:InvokeModel",
81+
"bedrock:InvokeModelWithResponseStream",
82+
"bedrock:RenderPrompt",
83+
"bedrock:TagResource",
84+
"bedrock:UntagResource",
85+
"bedrock:ListTagsForResource"
86+
],
87+
resources: ["*"] // Use wildcard as recommended by AWS docs
5788
})
5889

5990
const slackBotKnowledgeBasePolicy = new PolicyStatement({
@@ -108,6 +139,7 @@ export class RuntimePolicies extends Construct {
108139
description: "Policy for SlackBot Lambda to access Bedrock, SSM, Lambda, DynamoDB, and KMS",
109140
statements: [
110141
slackBotPolicy,
142+
slackBotPromptPolicy,
111143
slackBotKnowledgeBasePolicy,
112144
slackBotSSMPolicy,
113145
slackBotLambdaPolicy,

packages/cdk/stacks/EpsAssistMeStack.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {BedrockExecutionRole} from "../resources/BedrockExecutionRole"
1515
import {RuntimePolicies} from "../resources/RuntimePolicies"
1616
import {VectorIndex} from "../resources/VectorIndex"
1717
import {DatabaseTables} from "../resources/DatabaseTables"
18+
import {BedrockPromptResources} from "../resources/BedrockPromptResources"
1819
import {S3LambdaNotification} from "../constructs/S3LambdaNotification"
1920

2021
const VECTOR_INDEX_NAME = "eps-assist-os-index"
@@ -55,6 +56,11 @@ export class EpsAssistMeStack extends Stack {
5556
stackName: props.stackName
5657
})
5758

59+
// Create Bedrock Prompt Resources
60+
const bedrockPromptResources = new BedrockPromptResources(this, "BedrockPromptResources", {
61+
stackName: props.stackName
62+
})
63+
5864
// Create Storage construct first as it has no dependencies
5965
const storage = new Storage(this, "Storage", {
6066
stackName: props.stackName
@@ -88,7 +94,7 @@ export class EpsAssistMeStack extends Stack {
8894
account
8995
})
9096

91-
// Create runtime policies that depend on VectorKB ARNs
97+
// Create runtime policies with resource dependencies
9298
const runtimePolicies = new RuntimePolicies(this, "RuntimePolicies", {
9399
region,
94100
account,
@@ -98,7 +104,8 @@ export class EpsAssistMeStack extends Stack {
98104
slackBotStateTableKmsKeyArn: tables.slackBotStateTable.kmsKey.keyArn,
99105
knowledgeBaseArn: vectorKB.knowledgeBase.attrKnowledgeBaseArn,
100106
guardrailArn: vectorKB.guardrail.attrGuardrailArn,
101-
dataSourceArn: vectorKB.dataSourceArn
107+
dataSourceArn: vectorKB.dataSourceArn,
108+
promptName: bedrockPromptResources.queryReformulationPrompt.promptName
102109
})
103110

104111
// Create Functions construct with actual values from VectorKB
@@ -122,7 +129,8 @@ export class EpsAssistMeStack extends Stack {
122129
account,
123130
slackBotTokenSecret: secrets.slackBotTokenSecret,
124131
slackBotSigningSecret: secrets.slackBotSigningSecret,
125-
slackBotStateTable: tables.slackBotStateTable.table
132+
slackBotStateTable: tables.slackBotStateTable.table,
133+
promptName: bedrockPromptResources.queryReformulationPrompt.promptName
126134
})
127135

128136
// Create vector index after Functions are created
@@ -158,6 +166,12 @@ export class EpsAssistMeStack extends Stack {
158166
description: "Slack Events API endpoint for @mentions and direct messages"
159167
})
160168

169+
// Output: Bedrock Prompt ARN
170+
new CfnOutput(this, "QueryReformulationPromptArn", {
171+
value: bedrockPromptResources.queryReformulationPrompt.promptArn,
172+
description: "ARN of the query reformulation prompt in Bedrock"
173+
})
174+
161175
// Final CDK Nag Suppressions
162176
nagSuppressions(this)
163177
}

packages/createIndexFunction/app/handler.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,8 @@
1010
import time
1111
import boto3
1212
from opensearchpy import OpenSearch, RequestsHttpConnection, AWSV4SignerAuth
13-
from aws_lambda_powertools import Logger
1413
from aws_lambda_powertools.utilities.typing import LambdaContext
15-
from app.config.config import AWS_REGION
16-
17-
logger = Logger(service="createIndexFunction")
14+
from app.config.config import AWS_REGION, logger
1815

1916

2017
def get_opensearch_client(endpoint):
@@ -23,7 +20,7 @@ def get_opensearch_client(endpoint):
2320
"""
2421
# Determine service type: AOSS (Serverless) or ES (managed)
2522
service = "aoss" if "aoss" in endpoint else "es"
26-
logger.debug(f"Connecting to OpenSearch service: {service} at {endpoint}")
23+
logger.debug("Connecting to OpenSearch service", extra={"service": service, "endpoint": endpoint})
2724
return OpenSearch(
2825
hosts=[{"host": endpoint, "port": 443}],
2926
http_auth=AWSV4SignerAuth(
@@ -45,22 +42,22 @@ def wait_for_index_aoss(opensearch_client, index_name, timeout=300, poll_interva
4542
AOSS has eventual consistency, so we need to poll until the index
4643
is fully created and mappings are available.
4744
"""
48-
logger.info(f"Waiting for index '{index_name}' to be available in AOSS...")
45+
logger.info("Waiting for index to be available in AOSS", extra={"index_name": index_name})
4946
start = time.time()
5047
while True:
5148
try:
5249
if opensearch_client.indices.exists(index=index_name):
5350
# Verify mappings are also available (not just index existence)
5451
mapping = opensearch_client.indices.get_mapping(index=index_name)
5552
if mapping and index_name in mapping:
56-
logger.info(f"Index '{index_name}' exists and mappings are ready.")
53+
logger.info("Index exists and mappings are ready", extra={"index_name": index_name})
5754
return True
5855
else:
59-
logger.info(f"Index '{index_name}' does not exist yet...")
56+
logger.info("Index does not exist yet", extra={"index_name": index_name})
6057
except Exception as exc:
61-
logger.info(f"Still waiting for index '{index_name}': {exc}")
58+
logger.info("Still waiting for index", extra={"index_name": index_name, "error": str(exc)})
6259
if time.time() - start > timeout:
63-
logger.error(f"Timed out waiting for index '{index_name}' to be available.")
60+
logger.error("Timed out waiting for index to be available", extra={"index_name": index_name})
6461
return False
6562
time.sleep(poll_interval)
6663

@@ -109,18 +106,18 @@ def create_and_wait_for_index(client, index_name):
109106

110107
try:
111108
if not client.indices.exists(index=params["index"]):
112-
logger.info(f"Creating index {params['index']}")
109+
logger.info("Creating index", extra={"index_name": params["index"]})
113110
client.indices.create(index=params["index"], body=params["body"])
114-
logger.info(f"Index {params['index']} creation initiated.")
111+
logger.info("Index creation initiated", extra={"index_name": params["index"]})
115112
else:
116-
logger.info(f"Index {params['index']} already exists")
113+
logger.info("Index already exists", extra={"index_name": params["index"]})
117114

118115
if not wait_for_index_aoss(client, params["index"]):
119116
raise RuntimeError(f"Index {params['index']} failed to appear in time")
120117

121-
logger.info(f"Index {params['index']} is ready and active.")
118+
logger.info("Index is ready and active", extra={"index_name": params["index"]})
122119
except Exception as e:
123-
logger.error(f"Error creating or waiting for index: {e}")
120+
logger.error("Error creating or waiting for index", extra={"error": str(e)})
124121
raise e
125122

126123

@@ -181,10 +178,10 @@ def handler(event: dict, context: LambdaContext) -> dict:
181178
try:
182179
if client.indices.exists(index=index_name):
183180
client.indices.delete(index=index_name)
184-
logger.info(f"Deleted index {index_name}")
181+
logger.info("Deleted index", extra={"index_name": index_name})
185182
except Exception as e:
186183
# Don't fail deletion if index cleanup fails
187-
logger.error(f"Error deleting index: {e}")
184+
logger.error("Error deleting index", extra={"error": str(e)})
188185
return {
189186
"PhysicalResourceId": event.get("PhysicalResourceId", f"index-{index_name}"),
190187
"Status": "SUCCESS",
@@ -193,5 +190,5 @@ def handler(event: dict, context: LambdaContext) -> dict:
193190
raise ValueError(f"Invalid request type: {request_type}")
194191

195192
except Exception as e:
196-
logger.error(f"Error processing request: {e}")
193+
logger.error("Error processing request", extra={"error": str(e)})
197194
raise

0 commit comments

Comments
 (0)