diff --git a/.gitignore b/.gitignore
index e56eef0..e1d12a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -513,3 +513,5 @@ todos/
.github/agents/
# Squad (local AI team - not committed)
.ai-team/
+
+src/NoteBookmark.BlazorApp/Data/
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0e0bb5f..5f8e9da 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -37,6 +37,7 @@
+
diff --git a/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs b/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs
index c5378d3..513dbe5 100644
--- a/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs
+++ b/src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs
@@ -7,6 +7,7 @@ namespace NoteBookmark.AIServices.Tests;
public class ResearchServiceTests
{
private readonly Mock> _mockLogger;
+ private readonly HttpClient _httpClient = new();
public ResearchServiceTests()
{
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
@@ -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
@@ -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
diff --git a/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj b/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj
index c2962b9..64a8065 100644
--- a/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj
+++ b/src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj
@@ -5,6 +5,7 @@
+
diff --git a/src/NoteBookmark.AIServices/ResearchService.cs b/src/NoteBookmark.AIServices/ResearchService.cs
index 51e107c..69ac93a 100644
--- a/src/NoteBookmark.AIServices/ResearchService.cs
+++ b/src/NoteBookmark.AIServices/ResearchService.cs
@@ -7,6 +7,8 @@
using OpenAI;
using OpenAI.Chat;
using NoteBookmark.Domain;
+using Reka.SDK;
+using System.Text;
namespace NoteBookmark.AIServices;
@@ -14,12 +16,15 @@ public class ResearchService
{
private readonly ILogger _logger;
private readonly Func> _settingsProvider;
+ private readonly HttpClient _client;
public ResearchService(
+ HttpClient client,
ILogger logger,
Func> settingsProvider)
{
_logger = logger;
+ _client = client;
_settingsProvider = settingsProvider;
}
@@ -27,43 +32,75 @@ public async Task SearchSuggestionsAsync(SearchCriterias search
{
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
{
- 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(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(responseContent);
+
+ if (response.IsSuccessStatusCode)
+ {
+ suggestions = JsonSerializer.Deserialize(rekaResponse!.Choices![0].Message!.Content!)!;
+ }
+ else
+ {
+ throw new Exception($"Request failed with status code: {response.StatusCode}. Response: {responseContent}");
+ }
}
catch (Exception ex)
{
@@ -73,6 +110,43 @@ public async Task 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");
diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs
index d169a42..5404ab5 100644
--- a/src/NoteBookmark.BlazorApp/Program.cs
+++ b/src/NoteBookmark.BlazorApp/Program.cs
@@ -40,11 +40,15 @@
return new SummaryService(logger, provider);
});
+builder.Services.AddHttpClient(nameof(ResearchService));
+
builder.Services.AddTransient(sp =>
{
var logger = sp.GetRequiredService>();
var settingsProvider = sp.GetRequiredService();
-
+ var httpClientFactory = sp.GetRequiredService();
+ var client = httpClientFactory.CreateClient(nameof(ResearchService));
+
// Settings provider that fetches directly from database (server-side, unmasked)
Func> provider = async () =>
{
@@ -55,8 +59,8 @@
settings.ModelName
);
};
-
- return new ResearchService(logger, provider);
+
+ return new ResearchService(client, logger, provider);
});