Skip to content

Commit bf21c9d

Browse files
authored
Merge pull request #96 from fboucher/quickfix-domain-feat
quickfix: refact Research service rollback to raw httpclient
2 parents 140b60a + 88bf8d6 commit bf21c9d

File tree

6 files changed

+123
-40
lines changed

6 files changed

+123
-40
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,3 +513,5 @@ todos/
513513
.github/agents/
514514
# Squad (local AI team - not committed)
515515
.ai-team/
516+
517+
src/NoteBookmark.BlazorApp/Data/

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
3838
<PackageVersion Include="Swashbuckle.AspNetCore" Version="9.0.6" />
3939
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
40+
<PackageVersion Include="Reka.SDK" Version="0.1.0" />
4041
<!-- Test packages -->
4142
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
4243
<PackageVersion Include="FluentAssertions" Version="8.8.0" />

src/NoteBookmark.AIServices.Tests/ResearchServiceTests.cs

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ namespace NoteBookmark.AIServices.Tests;
77
public class ResearchServiceTests
88
{
99
private readonly Mock<ILogger<ResearchService>> _mockLogger;
10+
private readonly HttpClient _httpClient = new();
1011

1112
public ResearchServiceTests()
1213
{
@@ -18,7 +19,7 @@ public async Task SearchSuggestionsAsync_WithMissingApiKey_ThrowsInvalidOperatio
1819
{
1920
// Arrange
2021
var settingsProvider = CreateSettingsProvider(apiKey: null);
21-
var service = new ResearchService(_mockLogger.Object, settingsProvider);
22+
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
2223
var searchCriterias = new SearchCriterias("Test prompt") { SearchTopic = "AI" };
2324

2425
// Act
@@ -35,7 +36,7 @@ public async Task SearchSuggestionsAsync_WithMissingApiKey_LogsError()
3536
{
3637
// Arrange
3738
var settingsProvider = CreateSettingsProvider(apiKey: null);
38-
var service = new ResearchService(_mockLogger.Object, settingsProvider);
39+
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
3940
var searchCriterias = new SearchCriterias("Test prompt");
4041

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

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

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

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

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

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

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

148149
// Act
@@ -159,7 +160,7 @@ public async Task SearchSuggestionsAsync_OnException_ReturnsEmptyPostSuggestions
159160
{
160161
// Arrange
161162
var settingsProvider = CreateSettingsProvider(apiKey: "test-key");
162-
var service = new ResearchService(_mockLogger.Object, settingsProvider);
163+
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
163164
var searchCriterias = new SearchCriterias("Test prompt");
164165

165166
// Act
@@ -175,7 +176,7 @@ public async Task SearchSuggestionsAsync_WithValidSearchCriterias_ProcessesPromp
175176
{
176177
// Arrange
177178
var settingsProvider = CreateSettingsProvider(apiKey: "test-key");
178-
var service = new ResearchService(_mockLogger.Object, settingsProvider);
179+
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
179180
var searchCriterias = new SearchCriterias("Find articles about {topic}")
180181
{
181182
SearchTopic = "Machine Learning",
@@ -199,7 +200,7 @@ public async Task SearchSuggestionsAsync_WithEmptyApiKey_ThrowsInvalidOperationE
199200
{
200201
// Arrange
201202
var settingsProvider = CreateSettingsProvider(apiKey: emptyKey);
202-
var service = new ResearchService(_mockLogger.Object, settingsProvider);
203+
var service = new ResearchService(_httpClient, _mockLogger.Object, settingsProvider);
203204
var searchCriterias = new SearchCriterias("Test prompt");
204205

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

222223
// Act

src/NoteBookmark.AIServices/NoteBookmark.AIServices.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<PackageReference Include="Microsoft.Extensions.Logging" />
66
<PackageReference Include="Microsoft.Agents.AI" />
77
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
8+
<PackageReference Include="Reka.SDK" />
89
</ItemGroup>
910

1011
<ItemGroup>

src/NoteBookmark.AIServices/ResearchService.cs

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,63 +7,100 @@
77
using OpenAI;
88
using OpenAI.Chat;
99
using NoteBookmark.Domain;
10+
using Reka.SDK;
11+
using System.Text;
1012

1113
namespace NoteBookmark.AIServices;
1214

1315
public class ResearchService
1416
{
1517
private readonly ILogger<ResearchService> _logger;
1618
private readonly Func<Task<(string ApiKey, string BaseUrl, string ModelName)>> _settingsProvider;
19+
private readonly HttpClient _client;
1720

1821
public ResearchService(
22+
HttpClient client,
1923
ILogger<ResearchService> logger,
2024
Func<Task<(string ApiKey, string BaseUrl, string ModelName)>> settingsProvider)
2125
{
2226
_logger = logger;
27+
_client = client;
2328
_settingsProvider = settingsProvider;
2429
}
2530

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

35+
HttpResponseMessage? response = null;
36+
3037
try
3138
{
3239
var settings = await _settingsProvider();
3340

34-
IChatClient chatClient = new ChatClient(
35-
settings.ModelName,
36-
new ApiKeyCredential(settings.ApiKey),
37-
new OpenAIClientOptions { Endpoint = new Uri(settings.BaseUrl) }
38-
).AsIChatClient();
39-
40-
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
41+
var webSearch = new Dictionary<string, object>
4142
{
42-
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
43+
["max_uses"] = 3
4344
};
44-
45-
JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(PostSuggestions), serializerOptions: jsonOptions);
4645

47-
ChatOptions chatOptions = new()
46+
var allowedDomains = searchCriterias.GetSplittedAllowedDomains();
47+
var blockedDomains = searchCriterias.GetSplittedBlockedDomains();
48+
49+
if (allowedDomains != null && allowedDomains.Length > 0)
50+
{
51+
webSearch["allowed_domains"] = allowedDomains;
52+
}
53+
else if (blockedDomains != null && blockedDomains.Length > 0)
54+
{
55+
webSearch["blocked_domains"] = blockedDomains;
56+
}
57+
58+
var requestPayload = new
4859
{
49-
ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(
50-
schema: schema,
51-
schemaName: "PostSuggestions",
52-
schemaDescription: "A list of suggested posts with title, author, summary, publication date, and URL")
60+
model = settings.ModelName,
61+
62+
messages = new[]
63+
{
64+
new
65+
{
66+
role = "user",
67+
content = searchCriterias.GetSearchPrompt()
68+
}
69+
},
70+
response_format = GetResponseFormat(),
71+
research = new
72+
{
73+
web_search = webSearch
74+
},
5375
};
5476

55-
AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
77+
var jsonPayload = JsonSerializer.Serialize(requestPayload, new JsonSerializerOptions
5678
{
57-
Name = "ResearchAgent",
58-
ChatOptions = chatOptions
79+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
5980
});
6081

61-
var prompt = searchCriterias.GetSearchPrompt();
62-
var response = await agent.RunAsync(prompt);
63-
64-
suggestions = response.Deserialize<PostSuggestions>(jsonOptions) ?? new PostSuggestions();
65-
66-
await SaveToFile("research_response", response.ToString() ?? string.Empty);
82+
// await SaveToFile("research_request", jsonPayload);
83+
84+
var endpoint = settings.BaseUrl.TrimEnd('/') + "/chat/completions";
85+
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
86+
request.Headers.Add("Authorization", $"Bearer {settings.ApiKey}");
87+
request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
88+
89+
response = await _client.SendAsync(request);
90+
var responseContent = await response.Content.ReadAsStringAsync();
91+
92+
await SaveToFile("research_response", responseContent);
93+
94+
var rekaResponse = JsonSerializer.Deserialize<RekaResponse>(responseContent);
95+
96+
if (response.IsSuccessStatusCode)
97+
{
98+
suggestions = JsonSerializer.Deserialize<PostSuggestions>(rekaResponse!.Choices![0].Message!.Content!)!;
99+
}
100+
else
101+
{
102+
throw new Exception($"Request failed with status code: {response.StatusCode}. Response: {responseContent}");
103+
}
67104
}
68105
catch (Exception ex)
69106
{
@@ -73,6 +110,43 @@ public async Task<PostSuggestions> SearchSuggestionsAsync(SearchCriterias search
73110
return suggestions;
74111
}
75112

113+
private object GetResponseFormat()
114+
{
115+
return new
116+
{
117+
type = "json_schema",
118+
json_schema = new
119+
{
120+
name = "post_suggestions",
121+
schema = new
122+
{
123+
type = "object",
124+
properties = new
125+
{
126+
suggestions = new
127+
{
128+
type = "array",
129+
items = new
130+
{
131+
type = "object",
132+
properties = new
133+
{
134+
title = new { type = "string" },
135+
author = new { type = "string" },
136+
summary = new { type = "string", maxLength = 100 },
137+
publication_date = new { type = "string", format = "date" },
138+
url = new { type = "string" }
139+
},
140+
required = new[] { "title", "summary", "url" }
141+
}
142+
}
143+
},
144+
required = new[] { "post_suggestions" }
145+
}
146+
}
147+
};
148+
}
149+
76150
private async Task SaveToFile(string prefix, string responseContent)
77151
{
78152
string datetime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm");

src/NoteBookmark.BlazorApp/Program.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,15 @@
4040
return new SummaryService(logger, provider);
4141
});
4242

43+
builder.Services.AddHttpClient(nameof(ResearchService));
44+
4345
builder.Services.AddTransient<ResearchService>(sp =>
4446
{
4547
var logger = sp.GetRequiredService<ILogger<ResearchService>>();
4648
var settingsProvider = sp.GetRequiredService<AISettingsProvider>();
47-
49+
var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
50+
var client = httpClientFactory.CreateClient(nameof(ResearchService));
51+
4852
// Settings provider that fetches directly from database (server-side, unmasked)
4953
Func<Task<(string ApiKey, string BaseUrl, string ModelName)>> provider = async () =>
5054
{
@@ -55,8 +59,8 @@
5559
settings.ModelName
5660
);
5761
};
58-
59-
return new ResearchService(logger, provider);
62+
63+
return new ResearchService(client, logger, provider);
6064
});
6165

6266

0 commit comments

Comments
 (0)