-
Notifications
You must be signed in to change notification settings - Fork 337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: Add Amazon Bedrock Agents support #554
base: main
Are you sure you want to change the base?
Changes from 95 commits
f6f8853
8a62b43
988c57f
67224b6
facc201
91d1930
d11b1d1
569072a
9440095
e195072
924fd09
807b20b
031935d
495c5a4
3ad9ebb
c015f39
514f125
b475fa3
badc9ac
11e8de7
384c3fb
c883541
c3634e7
c4604d2
9272cae
5e97f59
dcd665f
433a647
0ace107
8c8694c
e97cbcb
8d16318
1128fb5
da7dc3e
13f568c
c2e1c05
1112a10
29b66ad
3c6ee87
486c311
ccf99cb
33293a2
775f735
aa9fcb9
06f5cad
f2ad37e
0c5d12e
ba132fd
8666b35
3a9a841
df4dff2
74bdf05
e69386a
2894f95
20414f1
83e3e9a
0d92e9f
4f4a9ff
c080caa
c9f20a4
73b3317
53033d4
8c62424
c7d9984
1426f59
5c82d03
41214f3
3e098eb
be5baea
246a5aa
56233f2
63bc6f5
dc6a3e4
2f75db8
09a09b3
1d0f22a
5efc171
5d2eed3
9bf5e43
206f84b
287940d
674093d
5ce94b5
fbb6159
8907882
e9b49d7
c8afae0
fa18001
6479bc0
cb25874
d3bac06
d9bab73
5d10c8d
0edeb63
3e09b8c
a1c44fd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,161 @@ | ||||||
import * as cdk from "aws-cdk-lib"; | ||||||
import { Construct } from "constructs"; | ||||||
import * as lambda from "aws-cdk-lib/aws-lambda"; | ||||||
import * as iam from "aws-cdk-lib/aws-iam"; | ||||||
import * as path from "path"; | ||||||
import * as geo from "aws-cdk-lib/aws-location"; | ||||||
import { bedrock } from "@cdklabs/generative-ai-cdk-constructs"; | ||||||
import { Bucket } from "aws-cdk-lib/aws-s3"; | ||||||
import { BucketDeployment, Source } from "aws-cdk-lib/aws-s3-deployment"; | ||||||
import { VectorIndex } from "@cdklabs/generative-ai-cdk-constructs/lib/cdk-lib/opensearch-vectorindex"; | ||||||
import { VectorCollection } from "@cdklabs/generative-ai-cdk-constructs/lib/cdk-lib/opensearchserverless"; | ||||||
import { NagSuppressions } from "cdk-nag"; | ||||||
|
||||||
export class BedrockWeatherAgent extends Construct { | ||||||
constructor(scope: Construct, id: string) { | ||||||
super(scope, id); | ||||||
|
||||||
const powertools = lambda.LayerVersion.fromLayerVersionArn( | ||||||
this, | ||||||
"powertools", | ||||||
`arn:aws:lambda:${ | ||||||
cdk.Stack.of(this).region | ||||||
}:017000801446:layer:AWSLambdaPowertoolsPythonV2:58` | ||||||
); | ||||||
|
||||||
const placeIndex = new geo.CfnPlaceIndex(this, "place-index", { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: other classes use this case for construct ids
Suggested change
|
||||||
dataSource: "Esri", | ||||||
indexName: "PlaceIndex", | ||||||
pricingPlan: "RequestBasedUsage", | ||||||
}); | ||||||
|
||||||
const vectorStore = new VectorCollection(this, "vector-collection"); | ||||||
const vectorIndex = new VectorIndex(this, "vector-index", { | ||||||
collection: vectorStore, | ||||||
indexName: "weather", | ||||||
vectorField: "vector", | ||||||
vectorDimensions: 1536, | ||||||
mappings: [ | ||||||
{ | ||||||
mappingField: "AMAZON_BEDROCK_TEXT_CHUNK", | ||||||
dataType: "text", | ||||||
filterable: true, | ||||||
}, | ||||||
{ | ||||||
mappingField: "AMAZON_BEDROCK_METADATA", | ||||||
dataType: "text", | ||||||
filterable: false, | ||||||
}, | ||||||
], | ||||||
}); | ||||||
|
||||||
const modulesLayer = new lambda.LayerVersion(this, "modules-layer", { | ||||||
code: lambda.Code.fromDockerBuild( | ||||||
path.join(__dirname, "weather/modules") | ||||||
), | ||||||
compatibleRuntimes: [lambda.Runtime.PYTHON_3_12], | ||||||
description: "Layer with required modules for the agent", | ||||||
}); | ||||||
|
||||||
const weather = new lambda.Function(this, "weather", { | ||||||
runtime: lambda.Runtime.PYTHON_3_12, | ||||||
description: | ||||||
"Lambda function that implements APIs to retrieve weather data", | ||||||
code: lambda.Code.fromAsset(path.join(__dirname, "weather")), | ||||||
handler: "lambda.handler", | ||||||
environment: { | ||||||
LOCATION_INDEX: placeIndex.indexName, | ||||||
}, | ||||||
memorySize: 512, | ||||||
timeout: cdk.Duration.seconds(10), | ||||||
layers: [powertools, modulesLayer], | ||||||
}); | ||||||
|
||||||
const policy = new iam.Policy(this, "weather-policy", { | ||||||
statements: [ | ||||||
new iam.PolicyStatement({ | ||||||
actions: ["geo:SearchPlaceIndexForText"], | ||||||
resources: ["*"], | ||||||
}), | ||||||
], | ||||||
}); | ||||||
weather.role?.attachInlinePolicy(policy); | ||||||
|
||||||
const kb = new bedrock.KnowledgeBase(this, "weather-kb", { | ||||||
embeddingsModel: bedrock.BedrockFoundationModel.TITAN_EMBED_TEXT_V1, | ||||||
vectorField: "vector", | ||||||
vectorIndex: vectorIndex, | ||||||
vectorStore: vectorStore, | ||||||
indexName: "weather", | ||||||
instruction: "answers questions about WMO and metereology", | ||||||
}); | ||||||
|
||||||
const bucket = new Bucket(this, "bedrock-kb-datasource-bucket", { | ||||||
enforceSSL: true, | ||||||
}); | ||||||
|
||||||
const keyPrefix = "my-docs"; | ||||||
new bedrock.S3DataSource(this, "my-docs-datasource", { | ||||||
bucket: bucket, | ||||||
dataSourceName: "my-docs", | ||||||
knowledgeBase: kb, | ||||||
inclusionPrefixes: [keyPrefix], | ||||||
}); | ||||||
|
||||||
const agent = new bedrock.Agent(this, "weather-agent", { | ||||||
foundationModel: | ||||||
bedrock.BedrockFoundationModel.ANTHROPIC_CLAUDE_INSTANT_V1_2, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better to switch to Sonnet 3 or Haiku now |
||||||
instruction: `You are a weather expert and answer user question about weather in different places. | ||||||
If you are asked to provide historical date, you need to answer with a summary of the weather over the period. | ||||||
You answer the questions in the same language they have been asked.`, | ||||||
description: "an agent to interact with a weather api", | ||||||
knowledgeBases: [kb], | ||||||
name: "WeatherTeller", | ||||||
}); | ||||||
|
||||||
agent.addActionGroup({ | ||||||
actionGroupExecutor: weather, | ||||||
apiSchema: bedrock.ApiSchema.fromAsset( | ||||||
path.join(__dirname, "weather", "schema.json") | ||||||
), | ||||||
actionGroupState: "ENABLED", | ||||||
}); | ||||||
|
||||||
agent.addActionGroup({ | ||||||
parentActionGroupSignature: "AMAZON.UserInput", | ||||||
actionGroupState: "ENABLED", | ||||||
actionGroupName: "UserInputAction", | ||||||
}); | ||||||
|
||||||
new BucketDeployment(this, "my-docs-files", { | ||||||
destinationBucket: bucket, | ||||||
destinationKeyPrefix: keyPrefix, | ||||||
sources: [Source.asset(path.join(__dirname, "my-documents"))], | ||||||
}); | ||||||
|
||||||
new cdk.CfnOutput(this, "weather-function-name", { | ||||||
value: weather.functionName, | ||||||
}); | ||||||
|
||||||
NagSuppressions.addResourceSuppressions(weather.role!, [ | ||||||
{ | ||||||
id: "AwsSolutions-IAM4", | ||||||
reason: "IAM role implicitly created by CDK.", | ||||||
}, | ||||||
]); | ||||||
|
||||||
NagSuppressions.addResourceSuppressions(policy, [ | ||||||
{ | ||||||
id: "AwsSolutions-IAM5", | ||||||
reason: "IAM role implicitly created by CDK.", | ||||||
}, | ||||||
]); | ||||||
|
||||||
NagSuppressions.addResourceSuppressions(bucket, [ | ||||||
{ | ||||||
id: "AwsSolutions-S1", | ||||||
reason: "Access logs not required", | ||||||
}, | ||||||
]); | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Add your documents to this folder to get them uploaded |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
{ | ||
"messageVersion": "1.0", | ||
"agent": { | ||
"name": "alfa", | ||
"id": "beta", | ||
"alias": "alfa", | ||
"version": "1.2" | ||
}, | ||
"inputText": "any text", | ||
"sessionId": "1234", | ||
"actionGroup": "mygroup", | ||
"apiPath": "/current_weather", | ||
"httpMethod": "GET", | ||
"parameters": [ | ||
{ | ||
"name": "place", | ||
"type": "string", | ||
"value": "Milan" | ||
} | ||
], | ||
"requestBody": { | ||
"content": { | ||
"application/json": { | ||
"properties": [ | ||
{ | ||
"name": "place", | ||
"type": "string", | ||
"value": "Milan" | ||
} | ||
] | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,140 @@ | ||
from aws_lambda_powertools import Tracer | ||
from aws_lambda_powertools.event_handler import BedrockAgentResolver | ||
from aws_lambda_powertools.event_handler.openapi.params import Query | ||
from pydantic import BaseModel | ||
from typing import List | ||
import boto3 | ||
import os | ||
import requests | ||
from typing import Annotated | ||
import pandas as pd | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nip: I believe pandas is large and would cause cold start due to numpy. I would remove this dependency if it's easy (I believe Panda has smaller managed lambda layers) |
||
import datetime | ||
|
||
tracer = Tracer() | ||
|
||
|
||
class Weather(BaseModel): | ||
time: str | ||
temperature: str | ||
precipitation: str | ||
|
||
|
||
class Period(BaseModel): | ||
start_date: str | ||
end_date: str | ||
|
||
|
||
app = BedrockAgentResolver() | ||
|
||
|
||
def get_metric_and_unit(w: dict, name: str) -> str: | ||
return f"{w['current'][name]} {w['current_units'][name]}" | ||
|
||
|
||
@app.get("/current_weather", description="get the current weather for a given place") | ||
def get_current_weather( | ||
place: Annotated[str, Query(description="the name of the place")] | ||
) -> Weather: | ||
resp = loc_client.search_place_index_for_text( | ||
IndexName=os.environ.get( | ||
"LOCATION_INDEX", os.environ.get("PLACE_INDEX", "Test") | ||
), | ||
Text=place, | ||
) | ||
[lon, lat] = resp["Results"][0]["Place"]["Geometry"]["Point"] | ||
q = ( | ||
"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" | ||
+ "¤t=temperature_2m,precipitation" | ||
) | ||
w = requests.get(q.format(lat=lat, lon=lon)).json() | ||
|
||
return Weather( | ||
time=w["current"]["time"], | ||
temperature=get_metric_and_unit(w, "temperature_2m"), | ||
precipitation=get_metric_and_unit(w, "precipitation"), | ||
) | ||
|
||
|
||
@app.get( | ||
"/absolute_period_dates", | ||
description="get the absolute start and end date for a period in YYYY-MM-DD" | ||
+ " format given the number of day difference from the today", | ||
) | ||
def get_absolute_period_dates( | ||
startPeriodDeltaDays: Annotated[ | ||
int, | ||
Query( | ||
description="the difference in days from the today" | ||
+ " to the start date of the period" | ||
), | ||
], | ||
endPeriodDeltaDays: Annotated[ | ||
int, | ||
Query( | ||
description="the difference in days from the today" | ||
+ " to the end date of the period" | ||
), | ||
], | ||
) -> Period: | ||
today = datetime.date.today() | ||
p = Period() | ||
p.start_date = (today - datetime.timedelta(startPeriodDeltaDays)).isoformat() | ||
p.end_date = (today - datetime.timedelta(endPeriodDeltaDays)).isoformat() | ||
return p | ||
|
||
|
||
@app.get( | ||
"/historical_weather", | ||
description="get the historical daily mean temperature and precipitation" | ||
+ " for a given place for a range of dates", | ||
) | ||
def get_historical_weather( | ||
place: Annotated[str, Query(description="the name of the place")], | ||
fromDate: Annotated[str, Query(description="starting date in YYYY-MM-DD format")], | ||
toDate: Annotated[str, Query(description="ending date in YYYY-MM-DD format")], | ||
) -> List[Weather]: | ||
resp = loc_client.search_place_index_for_text( | ||
IndexName=os.environ.get( | ||
"LOCATION_INDEX", os.environ.get("PLACE_INDEX", "Test") | ||
), | ||
Text=place, | ||
) | ||
[lon, lat] = resp["Results"][0]["Place"]["Geometry"]["Point"] | ||
q = ( | ||
"https://archive-api.open-meteo.com/v1/archive?latitude={lat}&longitude={lon}" | ||
+ "&start_date={fromDate}&end_date={toDate}&hourly=temperature_2m,precipitation" | ||
) | ||
resp = requests.get( | ||
q.format(lat=lat, lon=lon, fromDate=fromDate, toDate=toDate) | ||
).json() | ||
hourly_values = pd.DataFrame(resp["hourly"]) | ||
hourly_values["time"] = pd.to_datetime(hourly_values["time"]) | ||
hourly_values = hourly_values.set_index("time") | ||
hourly_values = hourly_values.resample("D").agg( | ||
{"temperature_2m": "mean", "precipitation": "sum"} | ||
) | ||
return [ | ||
Weather( | ||
time=str(hourly_values.iloc[i].name).split(" ")[0], | ||
temperature=hourly_values.iloc[i][0], | ||
precipitation=hourly_values.iloc[i][1], | ||
) | ||
for i in range(hourly_values.shape[0]) | ||
] | ||
|
||
|
||
def handler(event, context): | ||
print(event) | ||
resp = app.resolve(event, context) | ||
print(resp) | ||
return resp | ||
|
||
|
||
if __name__ == "__main__": | ||
with open("schema.json", "w") as f: | ||
f.write(app.get_openapi_json_schema()) | ||
else: | ||
sess = boto3.Session( | ||
region_name=os.environ.get("LOCATION_AWS_REGION", os.environ.get("AWS_REGION")) | ||
) | ||
loc_client = sess.client("location") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
FROM python:3.12-slim | ||
|
||
RUN apt update -y && apt install zip -y | ||
|
||
ADD requirements.txt . | ||
|
||
RUN mkdir -p /asset/python && pip install -r requirements.txt -t /asset/python |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
requests==2.29 | ||
pandas==2.2 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's add something like
(Agent with access to workspace and weather .. backed by Claude [insert model])
just to give an idea of the out of the box one