initial release

This commit is contained in:
2026-03-18 09:28:14 +01:00
commit 9d4bec7a17
18 changed files with 914 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
using SmartReader;
namespace OpenQuery.Services;
public class ArticleService
{
public static async Task<Article> FetchArticleAsync(string url)
{
var article = await Reader.ParseArticleAsync(url);
return article;
}
}

View File

@@ -0,0 +1,32 @@
namespace OpenQuery.Services;
public static class ChunkingService
{
private const int MAX_CHUNK_SIZE = 500;
public static List<string> ChunkText(string text)
{
var chunks = new List<string>();
var start = 0;
while (start < text.Length)
{
var length = Math.Min(MAX_CHUNK_SIZE, text.Length - start);
if (start + length < text.Length)
{
var lastSpace = text.LastIndexOfAny([' ', '\n', '\r', '.', '!'], start + length, length);
if (lastSpace > start)
length = lastSpace - start + 1;
}
var chunk = text.Substring(start, length).Trim();
if (!string.IsNullOrEmpty(chunk))
chunks.Add(chunk);
start += length;
}
return chunks;
}
}

View File

@@ -0,0 +1,38 @@
using System.Numerics.Tensors;
namespace OpenQuery.Services;
public class EmbeddingService
{
private readonly OpenRouterClient _client;
private readonly string _embeddingModel;
public EmbeddingService(OpenRouterClient client, string embeddingModel = "openai/text-embedding-3-small")
{
_client = client;
_embeddingModel = embeddingModel;
}
public async Task<float[][]> GetEmbeddingsAsync(List<string> texts)
{
var results = new List<float[]>();
const int batchSize = 300;
for (var i = 0; i < texts.Count; i += batchSize)
{
if (texts.Count > batchSize)
Console.WriteLine(
$"[Generating {Math.Ceiling(i / (double)batchSize)}/{Math.Ceiling(texts.Count / (double)batchSize)} batch of embeddings]");
var batch = texts.Skip(i).Take(batchSize).ToList();
var batchResults = await _client.EmbedAsync(_embeddingModel, batch);
results.AddRange(batchResults);
}
return results.ToArray();
}
public static float CosineSimilarity(float[] vector1, float[] vector2)
{
return TensorPrimitives.CosineSimilarity(vector1, vector2);
}
}

View File

@@ -0,0 +1,123 @@
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenQuery.Models;
namespace OpenQuery.Services;
public class OpenRouterClient
{
private readonly HttpClient _httpClient;
private readonly string _apiKey;
private readonly string _baseUrl = "https://openrouter.ai/api/v1";
public OpenRouterClient(string apiKey)
{
_apiKey = apiKey;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
public async IAsyncEnumerable<StreamChunk> StreamAsync(ChatCompletionRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
request = request with { Stream = true };
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.ChatCompletionRequest);
var content = new StringContent(json, Encoding.UTF8, new MediaTypeHeaderValue("application/json"));
var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/chat/completions")
{
Content = content
};
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
if (string.IsNullOrEmpty(line) || line.StartsWith($":"))
continue;
if (!line.StartsWith("data: ")) continue;
var data = line[6..];
if (data == "[DONE]")
yield break;
var chunk = JsonSerializer.Deserialize<ChatCompletionChunk>(data, AppJsonContext.Default.ChatCompletionChunk);
if (!(chunk?.Choices?.Count > 0)) continue;
var delta = chunk.Choices[0].Delta;
if (!string.IsNullOrEmpty(delta?.Content))
yield return new StreamChunk(delta.Content);
if (delta?.ToolCalls is not { Count: > 0 }) continue;
var toolCall = delta.ToolCalls[0];
yield return new StreamChunk(null, new ClientToolCall(
toolCall.Id,
toolCall.Function.Name,
toolCall.Function.Arguments
));
}
}
public async Task<ChatCompletionResponse> CompleteAsync(ChatCompletionRequest request)
{
request = request with { Stream = false };
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.ChatCompletionRequest);
var content = new StringContent(json, Encoding.UTF8, new MediaTypeHeaderValue("application/json"));
var response = await _httpClient.PostAsync($"{_baseUrl}/chat/completions", content);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ChatCompletionResponse>(responseJson, AppJsonContext.Default.ChatCompletionResponse)!;
}
public async Task<float[][]> EmbedAsync(string model, List<string> inputs)
{
var request = new EmbeddingRequest(model, inputs);
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.EmbeddingRequest);
var content = new StringContent(json, Encoding.UTF8, new MediaTypeHeaderValue("application/json"));
var response = await _httpClient.PostAsync($"{_baseUrl}/embeddings", content);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
var embeddingResponse = JsonSerializer.Deserialize<EmbeddingResponse>(responseJson, AppJsonContext.Default.EmbeddingResponse)!;
return embeddingResponse.Data
.OrderBy(d => d.Index)
.Select(d => d.Embedding)
.ToArray();
}
}
public record StreamChunk(
string? TextDelta = null,
ClientToolCall? Tool = null
);
public record ClientToolCall(
string ToolId,
string ToolName,
string Arguments
);
public record ChatCompletionChunk(
[property: JsonPropertyName("choices")] List<ChunkChoice> Choices
);
public record ChunkChoice(
[property: JsonPropertyName("delta")] ChunkDelta Delta
);
public record ChunkDelta(
[property: JsonPropertyName("content")] string? Content = null,
[property: JsonPropertyName("tool_calls")] List<ToolCall>? ToolCalls = null
);

30
Services/SearxngClient.cs Normal file
View File

@@ -0,0 +1,30 @@
using System.Text.Json;
using OpenQuery.Models;
namespace OpenQuery.Services;
public class SearxngClient
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
public SearxngClient(string baseUrl)
{
_baseUrl = baseUrl.TrimEnd('/');
_httpClient = new HttpClient();
}
public async Task<List<SearxngResult>> SearchAsync(string query, int limit = 10)
{
var encodedQuery = Uri.EscapeDataString(query);
var url = $"{_baseUrl}/search?q={encodedQuery}&format=json";
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var results = JsonSerializer.Deserialize<SearxngRoot>(json, AppJsonContext.Default.SearxngRoot);
return results?.Results?.Take(limit).ToList() ?? [];
}
}