Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -513,3 +513,5 @@ todos/
.github/agents/
# Squad (local AI team - not committed)
.ai-team/

src/NoteBookmark.BlazorApp/Data/
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
<PackageVersion Include="Reka.SDK" Version="0.1.0" />
<!-- Test packages -->
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
Expand Down
27 changes: 14 additions & 13 deletions src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace NoteBookmark.AIServices.Tests;
public class ResearchServiceTests
{
private readonly Mock<ILogger<ResearchService>> _mockLogger;
private readonly HttpClient _httpClient = new();

public ResearchServiceTests()
{
Expand All @@ -18,7 +19,7 @@ public async Task SearchSuggestionsAsync_WithMissingApiKey_ThrowsInvalidOperatio
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: null);
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "AI" };

// Act
Expand All @@ -35,7 +36,7 @@ public async Task SearchSuggestionsAsync_WithMissingApiKey_LogsError()
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: null);
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt");

// Act
Expand All @@ -50,7 +51,7 @@ public async Task SearchSuggestionsAsync_WithApiKeyFromAppSettings_UsesCorrectVa
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: "test-api-key-from-settings");
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "Testing" };

// Act - Will fail to connect but won't throw missing config exception
Expand All @@ -65,7 +66,7 @@ public async Task SearchSuggestionsAsync_WithApiKeyFromRekaEnvVar_UsesCorrectVal
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: "test-reka-key");
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "Testing" };

// Act
Expand All @@ -81,7 +82,7 @@ public async Task SearchSuggestionsAsync_WithCustomBaseUrl_UsesProvidedUrl()
// Arrange
const string customUrl = "https://custom.api.example.com/v1";
var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: customUrl);
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt");

// Act
Expand All @@ -96,7 +97,7 @@ public async Task SearchSuggestionsAsync_WithDefaultBaseUrl_UsesRekaApi()
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "https://api.reka.ai/v1");
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt");

// Act
Expand All @@ -112,7 +113,7 @@ public async Task SearchSuggestionsAsync_WithCustomModelName_UsesProvidedModel()
// Arrange
const string customModel = "custom-model-v2";
var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: customModel);
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt");

// Act
Expand All @@ -127,7 +128,7 @@ public async Task SearchSuggestionsAsync_WithDefaultModelName_UsesRekaFlashResea
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: "test-key", modelName: "reka-flash-research");
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt");

// Act
Expand All @@ -142,7 +143,7 @@ public async Task SearchSuggestionsAsync_WithInvalidUrl_ReturnsEmptyPostSuggesti
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: "test-key", baseUrl: "not-a-valid-url");
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt");

// Act
Expand All @@ -159,7 +160,7 @@ public async Task SearchSuggestionsAsync_OnException_ReturnsEmptyPostSuggestions
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: "test-key");
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt");

// Act
Expand All @@ -175,7 +176,7 @@ public async Task SearchSuggestionsAsync_WithValidSearchCriterias_ProcessesPromp
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: "test-key");
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Find articles about {topic}")
{
SearchTopic = "Machine Learning",
Expand All @@ -199,7 +200,7 @@ public async Task SearchSuggestionsAsync_WithEmptyApiKey_ThrowsInvalidOperationE
{
// Arrange
var settingsProvider = CreateSettingsProvider(apiKey: emptyKey);
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt");

// Act
Expand All @@ -216,7 +217,7 @@ public async Task SearchSuggestionsAsync_ApiKeyPriority_AppSettingsOverridesEnvV
{
// Arrange - Both AppSettings and env var set, AppSettings should take precedence
var settingsProvider = CreateSettingsProvider(apiKey: "settings-key");
var service = new ResearchService(_mockLogger.Object, settingsProvider);
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
var searchCriterias = new SearchCriterias("Test prompt");

// Act
Expand Down
1 change: 1 addition & 0 deletions src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Agents.AI" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Reka.SDK" />
</ItemGroup>

<ItemGroup>
Expand Down
122 changes: 98 additions & 24 deletions src/NoteBookmark.AIServices/ResearchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,63 +7,100 @@
using OpenAI;
using OpenAI.Chat;
using NoteBookmark.Domain;
using Reka.SDK;
using System.Text;

namespace NoteBookmark.AIServices;

public class ResearchService
{
private readonly ILogger<ResearchService> _logger;
private readonly Func<Task<(string ApiKey, string BaseUrl, string ModelName)>> _settingsProvider;
private readonly HttpClient _client;

public ResearchService(
HttpClient client,
ILogger<ResearchService> logger,
Func<Task<(string ApiKey, string BaseUrl, string ModelName)>> settingsProvider)
{
_logger = logger;
_client = client;
_settingsProvider = settingsProvider;
}

public async Task<PostSuggestions> SearchSuggestionsAsync(SearchCriterias searchCriterias)
{
PostSuggestions suggestions = new PostSuggestions();

HttpResponseMessage? response = null;

try
{
var settings = await _settingsProvider();

IChatClient chatClient = new ChatClient(
settings.ModelName,
new ApiKeyCredential(settings.ApiKey),
new OpenAIClientOptions { Endpoint = new Uri(settings.BaseUrl) }
).AsIChatClient();

var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
var webSearch = new Dictionary<string, object>
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
["max_uses"] = 3
};

JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(PostSuggestions), serializerOptions: jsonOptions);

ChatOptions chatOptions = new()
var allowedDomains = searchCriterias.GetSplittedAllowedDomains();
var blockedDomains = searchCriterias.GetSplittedBlockedDomains();

if (allowedDomains != null && allowedDomains.Length > 0)
{
webSearch["allowed_domains"] = allowedDomains;
}
else if (blockedDomains != null && blockedDomains.Length > 0)
{
webSearch["blocked_domains"] = blockedDomains;
}

var requestPayload = new
{
ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(
schema: schema,
schemaName: "PostSuggestions",
schemaDescription: "A list of suggested posts with title, author, summary, publication date, and URL")
model = settings.ModelName,

messages = new[]
{
new
{
role = "user",
content = searchCriterias.GetSearchPrompt()
}
},
response_format = GetResponseFormat(),
research = new
{
web_search = webSearch
},
};

AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
var jsonPayload = JsonSerializer.Serialize(requestPayload, new JsonSerializerOptions
{
Name = "ResearchAgent",
ChatOptions = chatOptions
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});

var prompt = searchCriterias.GetSearchPrompt();
var response = await agent.RunAsync(prompt);

suggestions = response.Deserialize<PostSuggestions>(jsonOptions) ?? new PostSuggestions();

await SaveToFile("research_response", response.ToString() ?? string.Empty);
// await SaveToFile("research_request", jsonPayload);

var endpoint = settings.BaseUrl.TrimEnd('/') + "/chat/completions";
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
request.Headers.Add("Authorization", $"Bearer {settings.ApiKey}");
request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");

response = await _client.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();

await SaveToFile("research_response", responseContent);

var rekaResponse = JsonSerializer.Deserialize<RekaResponse>(responseContent);

if (response.IsSuccessStatusCode)
{
suggestions = JsonSerializer.Deserialize<PostSuggestions>(rekaResponse!.Choices![0].Message!.Content!)!;
}
else
{
throw new Exception($"Request failed with status code: {response.StatusCode}. Response: {responseContent}");
}
}
catch (Exception ex)
{
Expand All @@ -73,6 +110,43 @@ public async Task<PostSuggestions> SearchSuggestionsAsync(SearchCriterias search
return suggestions;
}

private object GetResponseFormat()
{
return new
{
type = "json_schema",
json_schema = new
{
name = "post_suggestions",
schema = new
{
type = "object",
properties = new
{
suggestions = new
{
type = "array",
items = new
{
type = "object",
properties = new
{
title = new { type = "string" },
author = new { type = "string" },
summary = new { type = "string", maxLength = 100 },
publication_date = new { type = "string", format = "date" },
url = new { type = "string" }
},
required = new[] { "title", "summary", "url" }
}
}
},
required = new[] { "post_suggestions" }
}
}
};
}

private async Task SaveToFile(string prefix, string responseContent)
{
string datetime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm");
Expand Down
10 changes: 7 additions & 3 deletions src/NoteBookmark.BlazorApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.AddAzureTableClient("nb-tables");

Check warning on line 12 in src/NoteBookmark.BlazorApp/Program.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'AspireTablesExtensions.AddAzureTableClient(IHostApplicationBuilder, string, Action<AzureDataTablesSettings>?, Action<IAzureClientBuilder<TableServiceClient, TableClientOptions>>?)' is obsolete: 'Use AddAzureTableServiceClient instead. This method will be removed in a future version.'

// Add HTTP client for API calls
builder.Services.AddHttpClient<PostNoteClient>(client =>
Expand Down Expand Up @@ -40,11 +40,15 @@
return new SummaryService(logger, provider);
});

builder.Services.AddHttpClient(nameof(ResearchService));

builder.Services.AddTransient<ResearchService>(sp =>
{
var logger = sp.GetRequiredService<ILogger<ResearchService>>();
var settingsProvider = sp.GetRequiredService<AISettingsProvider>();

var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient(nameof(ResearchService));

// Settings provider that fetches directly from database (server-side, unmasked)
Func<Task<(string ApiKey, string BaseUrl, string ModelName)>> provider = async () =>
{
Expand All @@ -55,8 +59,8 @@
settings.ModelName
);
};
return new ResearchService(logger, provider);

return new ResearchService(client, logger, provider);
});


Expand Down
Loading