From 88bf8d650bd28eb8163af5aaee52d030024b4524 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 22 Feb 2026 09:48:03 -0500 Subject: [PATCH] quickfix: refact Research service rollback to raw httpclient rollback to raw httpclient to keep the domains features This change includes: - Addition of Reka.SDK package. - Updates the ResearchService to use Reka's API for search. - Configures the DI in the blazor app to use the http client. - Adds .gitignore entry to ignore the Data folder. The motivation is to leverage Reka's AI models for improved research capabilities within the NoteBookmark application. --- .gitignore | 2 + Directory.Packages.props | 1 + .../ResearchServiceTests.cs | 27 ++-- .../NoteBookmark.AIServices.csproj | 1 + .../ResearchService.cs | 122 ++++++++++++++---- src/NoteBookmark.BlazorApp/Program.cs | 10 +- 6 files changed, 123 insertions(+), 40 deletions(-) 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); });