Skip to content

Commit d9d6597

Browse files
Update to Claude 3 & Dynamic Prompt Management
Integrated AWS SSM for dynamic prompt management, replacing static prompts in BedrockSummaryService with a new PromptService. Updated to use Anthropic's Claude 3 Sonnet model, adjusting payload and response parsing. Enhanced dependency injection and logging. Added IPromptService interface and improved error handling. Updated project dependencies.
1 parent e478288 commit d9d6597

5 files changed

Lines changed: 202 additions & 39 deletions

File tree

Function.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Sqs_AI_Lambda.Models;
1010
using Sqs_AI_Lambda.Services;
1111
using System.Text.Json;
12+
using Amazon.SimpleSystemsManagement;
1213

1314
// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
1415
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
@@ -92,14 +93,16 @@ private static ServiceProvider ConfigureServices()
9293
builder.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Information);
9394
});
9495

95-
// Register AWS SQS client with default configuration for Lambda environment
96+
// Register AWS Service Clients as Singletons
9697
services.AddSingleton<IAmazonSQS>(provider => new AmazonSQSClient());
9798
services.AddSingleton<AmazonBedrockRuntimeClient>(provider => new AmazonBedrockRuntimeClient());
99+
services.AddSingleton<IAmazonSimpleSystemsManagement>(provider => new AmazonSimpleSystemsManagementClient());
98100

99101
// Register application services as transient for per-invocation isolation
100102
services.AddTransient<ISqsService, SqsService>();
101103
services.AddTransient<IMessageProcessor, MessageProcessor>();
102104
services.AddTransient<IMessageFactory, MessageFactory>();
105+
services.AddSingleton<IPromptService, PromptService>();
103106
services.AddTransient<IBedrockSummaryService, BedrockSummaryService>();
104107

105108
return services.BuildServiceProvider();

Interfaces/IPromptService.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
7+
namespace Sqs_AI_Lambda.Interfaces
8+
{
9+
/// <summary>
10+
/// Defines the contract for a service that retrieves Bedrock prompts,
11+
/// potentially caching them for performance.
12+
/// </summary>
13+
public interface IPromptService
14+
{
15+
/// <summary>
16+
/// Retrieves the combined system and source-specific prompt for a given tender source type.
17+
/// Fetches prompts from AWS Parameter Store and caches them locally.
18+
/// </summary>
19+
/// <param name="sourceType">The specific tender source (e.g., "Eskom", "SARS").</param>
20+
/// <returns>The combined prompt string to be used with Bedrock.</returns>
21+
/// <exception cref="ArgumentException">Thrown if sourceType is null or empty.</exception>
22+
/// <exception cref="KeyNotFoundException">Thrown if a required prompt parameter is not found in Parameter Store.</exception>
23+
Task<string> GetPromptAsync(string sourceType);
24+
}
25+
}

Services/BedrockSummaryService.cs

Lines changed: 61 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,39 +14,27 @@
1414
namespace Sqs_AI_Lambda.Services
1515
{
1616
/// <summary>
17-
/// Service for generating AI-powered tender summaries using AWS Bedrock
18-
/// Enhanced with retry logic, rate limiting, and robust error handling for production use
17+
/// Service for generating AI-powered tender summaries using AWS Bedrock.
18+
/// It now dynamically fetches prompts from a dedicated prompt service.
19+
/// It now uses Anthropic's Claude 3 Sonnet model.
1920
/// </summary>
2021
public class BedrockSummaryService : IBedrockSummaryService
2122
{
2223
private readonly ILogger<BedrockSummaryService> _logger;
2324
private readonly AmazonBedrockRuntimeClient _bedrockClient;
25+
private readonly IPromptService _promptService;
2426

2527
// Rate limiting and retry configuration
2628
private static readonly SemaphoreSlim _rateLimitSemaphore = new(3, 3); // Max 3 concurrent requests
2729
private const int MaxRetryAttempts = 5;
2830
private const int BaseDelayMs = 1000; // 1 second base delay
2931

30-
private const string SystemPrompt = @"You are a professional tender analyst.
31-
Analyse the tender data and create a structured summary with these sections:
32-
33-
**PURPOSE:** What the tender is for and scope
34-
**ELIGIBILITY:** Who can apply and requirements
35-
**APPLICATION:** How to apply, deadlines, procedures
36-
**LOCATION/CONTACT:** Location details and contact info
37-
**DOCUMENTS:** Required/supporting documents
38-
**DETAILS:** Other important information
39-
40-
Guidelines:
41-
- Use only provided information
42-
- State Information not provided for missing info
43-
- Be concise but comprehensive
44-
- Use bullet points for clarity";
45-
46-
public BedrockSummaryService(ILogger<BedrockSummaryService> logger, AmazonBedrockRuntimeClient bedrockClient)
32+
33+
public BedrockSummaryService(ILogger<BedrockSummaryService> logger, AmazonBedrockRuntimeClient bedrockClient, IPromptService promptService)
4734
{
4835
_logger = logger;
4936
_bedrockClient = bedrockClient;
37+
_promptService = promptService;
5038
}
5139

5240
/// <summary>
@@ -59,33 +47,37 @@ public async Task<string> GenerateSummaryAsync(TenderMessageBase tenderMessage)
5947
var tenderNumber = tenderMessage.TenderNumber ?? "Unknown";
6048
var sourceType = tenderMessage.GetSourceType();
6149

62-
_logger.LogInformation("Starting Nova Pro summary with rate limiting - TenderNumber: {TenderNumber}, Source: {SourceType}",
50+
_logger.LogInformation("Starting summary generation - TenderNumber: {TenderNumber}, Source: {SourceType}",
6351
tenderNumber, sourceType);
6452

6553
// Wait for rate limit semaphore to control concurrent requests
6654
await _rateLimitSemaphore.WaitAsync();
6755

6856
try
6957
{
58+
// Fetch the dynamic, combined prompt for the specific source
59+
var combinedPrompt = await _promptService.GetPromptAsync(sourceType);
60+
7061
// Convert to compact JSON format to minimize tokens
7162
var tenderJson = ConvertTenderToCompactJson(tenderMessage);
7263

7364
_logger.LogDebug("Tender JSON created - TenderNumber: {TenderNumber}, JsonLength: {JsonLength}",
7465
tenderNumber, tenderJson.Length);
7566

76-
// Execute with retry logic for handling throttling
77-
var summary = await ExecuteWithRetryAsync(tenderJson, tenderNumber);
67+
// Pass the combined prompt to the execution method
68+
var summary = await ExecuteWithRetryAsync(combinedPrompt, tenderJson, tenderNumber);
7869

7970
var duration = (DateTime.UtcNow - startTime).TotalMilliseconds;
80-
_logger.LogInformation("Nova Pro summary completed successfully - TenderNumber: {TenderNumber}, Duration: {Duration}ms, Length: {Length}",
81-
tenderNumber, duration, summary.Length);
71+
72+
_logger.LogInformation("Summary completed successfully - TenderNumber: {TenderNumber}, Duration: {Duration}ms",
73+
tenderNumber, duration);
8274

8375
return summary;
8476
}
8577
catch (Exception ex)
8678
{
8779
var duration = (DateTime.UtcNow - startTime).TotalMilliseconds;
88-
_logger.LogError(ex, "Nova Pro summary failed after all retries - TenderNumber: {TenderNumber}, Duration: {Duration}ms, ErrorType: {ErrorType}",
80+
_logger.LogError(ex, "Summary generation failed after all retries - TenderNumber: {TenderNumber}, Duration: {Duration}ms, ErrorType: {ErrorType}",
8981
tenderNumber, duration, ex.GetType().Name);
9082

9183
return GenerateFallbackSummary(tenderMessage);
@@ -98,9 +90,9 @@ public async Task<string> GenerateSummaryAsync(TenderMessageBase tenderMessage)
9890
}
9991

10092
/// <summary>
101-
/// Executes Bedrock request with exponential backoff retry logic for handling throttling
93+
/// Executes Bedrock request with retry logic, now using a dynamic prompt.
10294
/// </summary>
103-
private async Task<string> ExecuteWithRetryAsync(string tenderJson, string tenderNumber)
95+
private async Task<string> ExecuteWithRetryAsync(string combinedPrompt, string tenderJson, string tenderNumber)
10496
{
10597
var attempt = 0;
10698

@@ -113,9 +105,12 @@ private async Task<string> ExecuteWithRetryAsync(string tenderJson, string tende
113105
_logger.LogDebug("Bedrock request attempt {Attempt}/{MaxAttempts} - TenderNumber: {TenderNumber}",
114106
attempt, MaxRetryAttempts, tenderNumber);
115107

116-
// Optimised payload for token efficiency
108+
// Payload structured for Anthropic Claude 3 models
117109
var payload = new
118110
{
111+
anthropic_version = "bedrock-2023-05-31", // Required for Claude 3
112+
max_tokens = 900,
113+
temperature = 0.3,
119114
messages = new[]
120115
{
121116
new
@@ -125,22 +120,17 @@ private async Task<string> ExecuteWithRetryAsync(string tenderJson, string tende
125120
{
126121
new
127122
{
128-
text = $"{SystemPrompt}\n\nTender: {tenderJson}"
123+
type = "text",
124+
text = $"{combinedPrompt}\n\nTender Data:\n{tenderJson}"
129125
}
130126
}
131127
}
132-
},
133-
inferenceConfig = new
134-
{
135-
max_new_tokens = 800, // Efficient token usage
136-
temperature = 0.3, // Low for consistent output
137-
top_p = 0.9
138128
}
139129
};
140130

141131
var request = new InvokeModelRequest
142132
{
143-
ModelId = "amazon.nova-pro-v1:0",
133+
ModelId = "anthropic.claude-3-sonnet-20240229-v1:0", // Model ID changed to Claude 3 Sonnet
144134
ContentType = "application/json",
145135
Accept = "application/json",
146136
Body = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload)))
@@ -151,7 +141,8 @@ private async Task<string> ExecuteWithRetryAsync(string tenderJson, string tende
151141
using var responseStream = new StreamReader(response.Body);
152142
var responseText = await responseStream.ReadToEndAsync();
153143

154-
var summary = ParseNovaResponse(responseText);
144+
// Call the new response parser for Claude
145+
var summary = ParseClaudeResponse(responseText);
155146

156147
_logger.LogDebug("Bedrock request successful on attempt {Attempt} - TenderNumber: {TenderNumber}",
157148
attempt, tenderNumber);
@@ -361,7 +352,7 @@ private void AddSanralSpecificFields(Dictionary<string, object> compactTender, S
361352
}
362353

363354
/// <summary>
364-
/// Parses Amazon Nova Pro's response to extract the summary content
355+
/// Parses Amazon Nova's response to extract the summary content
365356
/// </summary>
366357
private string ParseNovaResponse(string responseJson)
367358
{
@@ -393,6 +384,38 @@ private string ParseNovaResponse(string responseJson)
393384
}
394385
}
395386

387+
388+
/// <summary>
389+
/// Parses the JSON response from an Anthropic Claude 3 model to extract the summary content.
390+
/// </summary>
391+
private string ParseClaudeResponse(string responseJson)
392+
{
393+
try
394+
{
395+
using var document = JsonDocument.Parse(responseJson);
396+
var contentArray = document.RootElement.GetProperty("content");
397+
398+
if (contentArray.ValueKind == JsonValueKind.Array)
399+
{
400+
foreach (var contentItem in contentArray.EnumerateArray())
401+
{
402+
if (contentItem.TryGetProperty("type", out var typeProperty) && typeProperty.GetString() == "text" &&
403+
contentItem.TryGetProperty("text", out var textProperty))
404+
{
405+
return textProperty.GetString() ?? "Summary generated but content extraction failed.";
406+
}
407+
}
408+
}
409+
_logger.LogWarning("Unexpected Claude 3 response format: 'text' field not found in content array.");
410+
return "Summary generated but response parsing failed (unexpected format).";
411+
}
412+
catch (Exception ex)
413+
{
414+
_logger.LogError(ex, "Failed to parse Claude 3 response JSON: {ResponseJson}", responseJson);
415+
return "Summary generation completed but response parsing failed (exception).";
416+
}
417+
}
418+
396419
/// <summary>
397420
/// Generates a model-aware fallback summary when Bedrock fails
398421
/// Enhanced with throttling context information

Services/PromptService.cs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using Amazon.SimpleSystemsManagement;
2+
using Amazon.SimpleSystemsManagement.Model;
3+
using Microsoft.Extensions.Logging;
4+
using Sqs_AI_Lambda.Interfaces;
5+
using System;
6+
using System.Collections.Concurrent;
7+
using System.Collections.Generic;
8+
using System.Linq;
9+
using System.Text;
10+
using System.Threading.Tasks;
11+
12+
namespace Sqs_AI_Lambda.Services
13+
{
14+
/// <summary>
15+
/// Service responsible for fetching and caching Bedrock prompts from AWS Parameter Store.
16+
/// Uses a static cache to minimize API calls during warm Lambda invocations.
17+
/// </summary>
18+
public class PromptService : IPromptService
19+
{
20+
private readonly IAmazonSimpleSystemsManagement _ssmClient;
21+
private readonly ILogger<PromptService> _logger;
22+
23+
// Base path for prompt parameters in Parameter Store
24+
private const string PromptBasePath = "/TenderSummary/Prompts/";
25+
private const string SystemPromptKey = "System"; // Special key for the system prompt
26+
27+
// Static cache using ConcurrentDictionary for thread safety during parallel fetches if needed
28+
private static readonly ConcurrentDictionary<string, string> _promptCache = new();
29+
30+
public PromptService(IAmazonSimpleSystemsManagement ssmClient, ILogger<PromptService> logger)
31+
{
32+
_ssmClient = ssmClient ?? throw new ArgumentNullException(nameof(ssmClient));
33+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
34+
}
35+
36+
/// <inheritdoc/>
37+
public async Task<string> GetPromptAsync(string sourceType)
38+
{
39+
if (string.IsNullOrWhiteSpace(sourceType))
40+
{
41+
throw new ArgumentException("Source type cannot be null or empty.", nameof(sourceType));
42+
}
43+
44+
// Ensure System prompt is cached
45+
string systemPrompt = await EnsurePromptCachedAsync(SystemPromptKey);
46+
47+
// Ensure source-specific prompt is cached
48+
string sourcePrompt = await EnsurePromptCachedAsync(sourceType);
49+
50+
// Combine prompts
51+
string combinedPrompt = $"{systemPrompt}\n\n{sourcePrompt}";
52+
53+
_logger.LogDebug("Combined prompt retrieved for source type: {SourceType}", sourceType);
54+
return combinedPrompt;
55+
}
56+
57+
/// <summary>
58+
/// Helper method to fetch a specific prompt from cache or Parameter Store.
59+
/// </summary>
60+
/// <param name="promptKey">The key of the prompt (e.g., "System", "Eskom").</param>
61+
/// <returns>The prompt text.</returns>
62+
private async Task<string> EnsurePromptCachedAsync(string promptKey)
63+
{
64+
// Check cache first (case-insensitive key comparison)
65+
if (_promptCache.TryGetValue(promptKey, out var cachedPrompt))
66+
{
67+
_logger.LogDebug("Prompt found in cache for key: {PromptKey}", promptKey);
68+
return cachedPrompt;
69+
}
70+
71+
// Construct the full parameter name
72+
string parameterName = $"{PromptBasePath}{promptKey}";
73+
_logger.LogInformation("Prompt not found in cache for key: {PromptKey}. Fetching from Parameter Store: {ParameterName}", promptKey, parameterName);
74+
75+
try
76+
{
77+
var request = new GetParameterRequest
78+
{
79+
Name = parameterName,
80+
WithDecryption = false
81+
};
82+
83+
var response = await _ssmClient.GetParameterAsync(request);
84+
85+
if (response.Parameter == null || string.IsNullOrEmpty(response.Parameter.Value))
86+
{
87+
_logger.LogError("Parameter {ParameterName} was found but has no value.", parameterName);
88+
throw new KeyNotFoundException($"Parameter '{parameterName}' retrieved from Parameter Store has no value.");
89+
}
90+
91+
string promptValue = response.Parameter.Value;
92+
93+
// Add to cache
94+
_promptCache.AddOrUpdate(promptKey, promptValue, (key, oldValue) => promptValue);
95+
96+
_logger.LogInformation("Successfully fetched and cached prompt for key: {PromptKey}", promptKey);
97+
return promptValue;
98+
}
99+
catch (ParameterNotFoundException ex)
100+
{
101+
_logger.LogError(ex, "Required prompt parameter not found in Parameter Store: {ParameterName}", parameterName);
102+
throw new KeyNotFoundException($"Required prompt parameter '{parameterName}' not found in AWS Parameter Store.", ex);
103+
}
104+
catch (Exception ex)
105+
{
106+
_logger.LogError(ex, "Failed to fetch parameter {ParameterName} from Parameter Store.", parameterName);
107+
throw;
108+
}
109+
}
110+
}
111+
}

Sqs_AI_Lambda.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.4" />
1616
<PackageReference Include="Amazon.Lambda.SQSEvents" Version="2.2.0" />
1717
<PackageReference Include="AWSSDK.BedrockRuntime" Version="4.0.6" />
18+
<PackageReference Include="AWSSDK.SimpleSystemsManagement" Version="4.0.5" />
1819
<PackageReference Include="AWSSDK.SQS" Version="4.0.1.2" />
1920
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
2021
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.8" />

0 commit comments

Comments
 (0)