49 Commits

Author SHA1 Message Date
LD50
3e433c3cbe Merge pull request #75 from LD-Reborn/72-swagger-and-elmah-have-no-return-to-front-end-button
Added swagger and elmah return-to-front-end button
2026-01-01 17:39:08 +01:00
8cbc77eb1d Added swagger and elmah return-to-front-end button 2026-01-01 17:38:48 +01:00
LD50
977a8f1637 Merge pull request #73 from LD-Reborn/68-returnurl-does-not-work
Fixed ReturnUrl not working
2026-01-01 16:12:51 +01:00
65ed78462d Fixed ReturnUrl not working 2026-01-01 16:02:30 +01:00
LD50
4d2d2c9938 Merge pull request #71 from LD-Reborn/67-improve-fcp-by-defering-js-and-css
Added CriticalCSS, defered CSS and JS, fixed heading order, fixed fro…
2026-01-01 14:58:09 +01:00
b20102785a Added CriticalCSS, defered CSS and JS, fixed heading order, fixed front-end querycache url, added response compression and caching 2026-01-01 14:57:37 +01:00
LD50
3b96d7212b Merge pull request #70 from LD-Reborn/61-add-a-model-allow-denylist
Added missing configuration file changes
2025-12-31 04:07:59 +01:00
254c534b0b Added missing configuration file changes 2025-12-31 04:07:28 +01:00
LD50
eafc764f73 Merge pull request #69 from LD-Reborn/61-add-a-model-allow-denylist
61 add a model allow denylist
2025-12-31 03:58:47 +01:00
7dfe945a48 Added swagger authorization check 2025-12-31 03:58:18 +01:00
aa95308f61 Added allowlist and denylist, fixed patchy configuration with proper options models, fixed api middleware authorization issues 2025-12-31 03:47:40 +01:00
8d56883e7e Fixed multithreading mutation issue 2025-12-31 03:43:44 +01:00
bc293bf7ec Added proper server config model, added proper apikey authorization with swagger integration, added allowlist and denylist to config 2025-12-30 22:18:26 +01:00
LD50
b5db4bc1e4 Merge pull request #64 from LD-Reborn/62-add-an-embedding-cache-size-label-to-front-end
Added home page dashboard, added embedding cache size estimation and …
2025-12-30 02:55:23 +01:00
0f599a49d0 Added home page dashboard, added embedding cache size estimation and front-end label, added individual health check routes 2025-12-30 02:54:30 +01:00
LD50
4fe6b4a112 Merge pull request #63 from LD-Reborn/59-implement-missing-endpoints-in-client
59 implement missing endpoints in client
2025-12-29 19:51:35 +01:00
16efe447a2 Reorganized client methods to better match swagger sequence 2025-12-29 19:51:16 +01:00
6a7bdf585c Added missing endpoints to client 2025-12-29 19:44:55 +01:00
31c784f0ab Renamed SearchCache mentions to QueryCache for better clarity 2025-12-29 15:41:12 +01:00
625019f9f4 Added swagger decoration, reorganized controller elements for better clarity, renamed entity index endpoint for better clarity 2025-12-29 13:56:44 +01:00
c3dfe1a964 Fixed ridiculously low EmbeddingCacheMaxCount preset 2025-12-29 01:10:31 +01:00
LD50
d647bedb33 Merge pull request #60 from LD-Reborn/44-fix-controller-endpoint-naming-and-http-methods
Fixed endpoint naming and http methods
2025-12-28 17:36:17 +01:00
fe6bbfe9e5 Fixed endpoint naming and http methods 2025-12-28 17:36:01 +01:00
LD50
6f7afca195 Merge pull request #58 from LD-Reborn/56-bug-exception-when-update-indexing-entity
Fixed datapoint stale reference causing issues when updating datapoin…
2025-12-28 00:44:12 +01:00
3fa71a8d8b Fixed datapoint stale reference causing issues when updating datapoint text and probmethod or similaritymethod, fixe probmethod and similaritymethod not being applied in-memory 2025-12-28 00:43:55 +01:00
LD50
8921121078 Merge pull request #57 from LD-Reborn/54-properly-implement-embeddings-cache-size-limit-global
Implemented cache reconciliation
2025-12-28 00:22:15 +01:00
baf76685b7 Implemented cache reconciliation 2025-12-28 00:19:18 +01:00
LD50
4030e4a824 Merge pull request #55 from LD-Reborn/54-properly-implement-embeddings-cache-size-limit-global
Moved embeddingCache from Dictionary to LRUCache
2025-12-27 18:40:45 +01:00
7b4a3bd2c8 Moved embeddingCache from Dictionary to LRUCache 2025-12-27 18:40:03 +01:00
LD50
5eabb0d924 Merge pull request #53 from LD-Reborn/33-move-query-from-entity-to-searchdomain
33 move query from entity to searchdomain
2025-12-27 17:26:29 +01:00
40424053da Merge branch '33-move-query-from-entity-to-searchdomain' of https://github.com/LD-Reborn/embeddingsearch into 33-move-query-from-entity-to-searchdomain 2025-12-27 17:26:13 +01:00
f3a4665153 Moved query action from EntityController to SearchdomainController 2025-12-27 17:26:06 +01:00
a358eaea86 Moved query action from EntityController to SearchdomainController 2025-12-27 17:25:12 +01:00
665a392b5a Fixed redundant Searchdomain retrieval error messages 2025-12-25 15:25:23 +01:00
26d0561c3b Fixed wrong return model returned in EntityController methods 2025-12-25 14:55:58 +01:00
cc93a76546 Fixed DRY violations regarding result models 2025-12-25 14:55:30 +01:00
LD50
7298593341 Merge pull request #51 from LD-Reborn/35-implement-enums-for-probmethods-in-the-shared-models
Fixed embeddingCache not yet global
2025-12-25 14:19:41 +01:00
25723cb7a4 Fixed embeddingCache not yet global 2025-12-25 14:19:25 +01:00
LD50
84d83206cb Merge pull request #50 from LD-Reborn/35-implement-enums-for-probmethods-in-the-shared-models
Removed unused GenerateEmbeddings method
2025-12-25 14:18:41 +01:00
b6e01a3f66 Removed unused GenerateEmbeddings method 2025-12-25 14:13:28 +01:00
LD50
e4cfcb1030 Merge pull request #49 from LD-Reborn/35-implement-enums-for-probmethods-in-the-shared-models
Added enums to JSONEntity and JSONDatapoint
2025-12-25 13:20:44 +01:00
6d1cffe2db Added enums to JSONEntity and JSONDatapoint 2025-12-25 13:20:24 +01:00
LD50
dd0019b1c1 Merge pull request #48 from LD-Reborn/40-add-attributes-to-query-result
40 add attributes to query result
2025-12-25 12:39:18 +01:00
5877ebaff2 Added attributes to query results 2025-12-25 12:39:01 +01:00
040d4f916a Fixed views showing in swagger 2025-12-25 12:21:50 +01:00
LD50
57beddd70f Merge pull request #47 from LD-Reborn/42-create-a-front-end---localization
Added localization
2025-12-23 22:40:42 +01:00
8416d7f404 Added localization 2025-12-23 22:40:04 +01:00
16f08aa8a7 Removed privacy page 2025-12-23 21:37:10 +01:00
LD50
cce42d8ec3 Merge pull request #46 from LD-Reborn/41-create-a-front-end---toasts
41 create a front end   toasts
2025-12-23 14:55:39 +01:00
56 changed files with 3814 additions and 2412 deletions

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ src/Server/logs
src/Shared/bin src/Shared/bin
src/Shared/obj src/Shared/obj
src/Server/wwwroot/logs/* src/Server/wwwroot/logs/*
src/Server/CriticalCSS/node_modules
src/Server/CriticalCSS/package*.json

View File

@@ -8,6 +8,8 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using System.Reflection.Metadata.Ecma335; using System.Reflection.Metadata.Ecma335;
using Shared.Models; using Shared.Models;
using System.Net;
using Microsoft.Extensions.Options;
namespace Client; namespace Client;
@@ -24,19 +26,65 @@ public class Client
this.searchdomain = searchdomain; this.searchdomain = searchdomain;
} }
public Client(IConfiguration configuration) public Client(IOptions<ServerOptions> configuration)
{ {
string? baseUri = configuration.GetSection("Embeddingsearch").GetValue<string>("BaseUri"); string baseUri = configuration.Value.BaseUri;
string? apiKey = configuration.GetSection("Embeddingsearch").GetValue<string>("ApiKey"); string? apiKey = configuration.Value.ApiKey;
string? searchdomain = configuration.GetSection("Embeddingsearch").GetValue<string>("Searchdomain"); string? searchdomain = configuration.Value.Searchdomain;
this.baseUri = baseUri ?? ""; this.baseUri = baseUri;
this.apiKey = apiKey ?? ""; this.apiKey = apiKey ?? "";
this.searchdomain = searchdomain ?? ""; this.searchdomain = searchdomain ?? "";
} }
public async Task<EntityListResults> EntityListAsync(bool returnEmbeddings = false)
{
return await EntityListAsync(searchdomain, returnEmbeddings);
}
public async Task<EntityListResults> EntityListAsync(string searchdomain, bool returnEmbeddings = false)
{
var url = $"{baseUri}/Entities?searchdomain={HttpUtility.UrlEncode(searchdomain)}&returnEmbeddings={HttpUtility.UrlEncode(returnEmbeddings.ToString())}";
return await FetchUrlAndProcessJson<EntityListResults>(HttpMethod.Get, url);
}
public async Task<EntityIndexResult> EntityIndexAsync(List<JSONEntity> jsonEntity)
{
return await EntityIndexAsync(JsonSerializer.Serialize(jsonEntity));
}
public async Task<EntityIndexResult> EntityIndexAsync(string jsonEntity)
{
var content = new StringContent(jsonEntity, Encoding.UTF8, "application/json");
return await FetchUrlAndProcessJson<EntityIndexResult>(HttpMethod.Put, GetUrl($"{baseUri}", "Entities", []), content);
}
public async Task<EntityDeleteResults> EntityDeleteAsync(string entityName)
{
return await EntityDeleteAsync(searchdomain, entityName);
}
public async Task<EntityDeleteResults> EntityDeleteAsync(string searchdomain, string entityName)
{
var url = $"{baseUri}/Entity?apiKey={HttpUtility.UrlEncode(apiKey)}&searchdomain={HttpUtility.UrlEncode(searchdomain)}&entity={HttpUtility.UrlEncode(entityName)}";
return await FetchUrlAndProcessJson<EntityDeleteResults>(HttpMethod.Delete, url);
}
public async Task<SearchdomainListResults> SearchdomainListAsync() public async Task<SearchdomainListResults> SearchdomainListAsync()
{ {
return await GetUrlAndProcessJson<SearchdomainListResults>(GetUrl($"{baseUri}/Searchdomain", "List", apiKey, [])); return await FetchUrlAndProcessJson<SearchdomainListResults>(HttpMethod.Get, GetUrl($"{baseUri}", "Searchdomains", []));
}
public async Task<SearchdomainCreateResults> SearchdomainCreateAsync()
{
return await SearchdomainCreateAsync(searchdomain);
}
public async Task<SearchdomainCreateResults> SearchdomainCreateAsync(string searchdomain, SearchdomainSettings searchdomainSettings = new())
{
return await FetchUrlAndProcessJson<SearchdomainCreateResults>(HttpMethod.Post, GetUrl($"{baseUri}", "Searchdomain", new Dictionary<string, string>()
{
{"searchdomain", searchdomain}
}), new StringContent(JsonSerializer.Serialize(searchdomainSettings), Encoding.UTF8, "application/json"));
} }
public async Task<SearchdomainDeleteResults> SearchdomainDeleteAsync() public async Task<SearchdomainDeleteResults> SearchdomainDeleteAsync()
@@ -46,20 +94,7 @@ public class Client
public async Task<SearchdomainDeleteResults> SearchdomainDeleteAsync(string searchdomain) public async Task<SearchdomainDeleteResults> SearchdomainDeleteAsync(string searchdomain)
{ {
return await GetUrlAndProcessJson<SearchdomainDeleteResults>(GetUrl($"{baseUri}/Searchdomain", "Delete", apiKey, new Dictionary<string, string>() return await FetchUrlAndProcessJson<SearchdomainDeleteResults>(HttpMethod.Delete, GetUrl($"{baseUri}", "Searchdomain", new Dictionary<string, string>()
{
{"searchdomain", searchdomain}
}));
}
public async Task<SearchdomainCreateResults> SearchdomainCreateAsync()
{
return await SearchdomainCreateAsync(searchdomain);
}
public async Task<SearchdomainCreateResults> SearchdomainCreateAsync(string searchdomain)
{
return await GetUrlAndProcessJson<SearchdomainCreateResults>(GetUrl($"{baseUri}/Searchdomain", "Create", apiKey, new Dictionary<string, string>()
{ {
{"searchdomain", searchdomain} {"searchdomain", searchdomain}
})); }));
@@ -72,87 +107,147 @@ public class Client
return updateResults; return updateResults;
} }
public async Task<SearchdomainUpdateResults> SearchdomainUpdateAsync(string searchdomain, string newName, SearchdomainSettings settings = new())
{
return await SearchdomainUpdateAsync(searchdomain, newName, JsonSerializer.Serialize(settings));
}
public async Task<SearchdomainUpdateResults> SearchdomainUpdateAsync(string searchdomain, string newName, string settings = "{}") public async Task<SearchdomainUpdateResults> SearchdomainUpdateAsync(string searchdomain, string newName, string settings = "{}")
{ {
return await GetUrlAndProcessJson<SearchdomainUpdateResults>(GetUrl($"{baseUri}/Searchdomain", "Update", apiKey, new Dictionary<string, string>() return await FetchUrlAndProcessJson<SearchdomainUpdateResults>(HttpMethod.Put, GetUrl($"{baseUri}", "Searchdomain", new Dictionary<string, string>()
{ {
{"searchdomain", searchdomain}, {"searchdomain", searchdomain},
{"newName", newName}, {"newName", newName}
{"settings", settings} }), new StringContent(settings, Encoding.UTF8, "application/json"));
}));
} }
public async Task<EntityQueryResults> EntityQueryAsync(string query) public async Task<SearchdomainSearchesResults> SearchdomainGetQueriesAsync(string searchdomain)
{ {
return await EntityQueryAsync(searchdomain, query); Dictionary<string, string> parameters = new()
{
{"searchdomain", searchdomain}
};
return await FetchUrlAndProcessJson<SearchdomainSearchesResults>(HttpMethod.Get, GetUrl($"{baseUri}/Searchdomain", "Queries", parameters));
} }
public async Task<EntityQueryResults> EntityQueryAsync(string searchdomain, string query) public async Task<EntityQueryResults> SearchdomainQueryAsync(string query)
{ {
return await GetUrlAndProcessJson<EntityQueryResults>(GetUrl($"{baseUri}/Entity", "Query", apiKey, new Dictionary<string, string>() return await SearchdomainQueryAsync(searchdomain, query);
}
public async Task<EntityQueryResults> SearchdomainQueryAsync(string searchdomain, string query, int? topN = null, bool returnAttributes = false)
{
Dictionary<string, string> parameters = new()
{ {
{"searchdomain", searchdomain}, {"searchdomain", searchdomain},
{"query", query} {"query", query}
})); };
if (topN is not null) parameters.Add("topN", ((int)topN).ToString());
if (returnAttributes) parameters.Add("returnAttributes", returnAttributes.ToString());
return await FetchUrlAndProcessJson<EntityQueryResults>(HttpMethod.Post, GetUrl($"{baseUri}/Searchdomain", "Query", parameters), null);
} }
public async Task<EntityIndexResult> EntityIndexAsync(List<JSONEntity> jsonEntity) public async Task<SearchdomainDeleteSearchResult> SearchdomainDeleteQueryAsync(string searchdomain, string query)
{ {
return await EntityIndexAsync(JsonSerializer.Serialize(jsonEntity)); Dictionary<string, string> parameters = new()
{
{"searchdomain", searchdomain},
{"query", query}
};
return await FetchUrlAndProcessJson<SearchdomainDeleteSearchResult>(HttpMethod.Delete, GetUrl($"{baseUri}/Searchdomain", "Query", parameters));
} }
public async Task<EntityIndexResult> EntityIndexAsync(string jsonEntity) public async Task<SearchdomainUpdateSearchResult> SearchdomainUpdateQueryAsync(string searchdomain, string query, List<ResultItem> results)
{ {
var content = new StringContent(jsonEntity, Encoding.UTF8, "application/json"); Dictionary<string, string> parameters = new()
return await PostUrlAndProcessJson<EntityIndexResult>(GetUrl($"{baseUri}/Entity", "Index", apiKey, []), content);//new FormUrlEncodedContent(values)); {
{"searchdomain", searchdomain},
{"query", query}
};
return await FetchUrlAndProcessJson<SearchdomainUpdateSearchResult>(
HttpMethod.Patch,
GetUrl($"{baseUri}/Searchdomain", "Query", parameters),
new StringContent(JsonSerializer.Serialize(results), Encoding.UTF8, "application/json"));
} }
public async Task<EntityListResults> EntityListAsync(bool returnEmbeddings = false) public async Task<SearchdomainSettingsResults> SearchdomainGetSettingsAsync(string searchdomain)
{ {
return await EntityListAsync(searchdomain, returnEmbeddings); Dictionary<string, string> parameters = new()
{
{"searchdomain", searchdomain}
};
return await FetchUrlAndProcessJson<SearchdomainSettingsResults>(HttpMethod.Get, GetUrl($"{baseUri}/Searchdomain", "Settings", parameters));
} }
public async Task<EntityListResults> EntityListAsync(string searchdomain, bool returnEmbeddings = false) public async Task<SearchdomainUpdateResults> SearchdomainUpdateSettingsAsync(string searchdomain, SearchdomainSettings searchdomainSettings)
{ {
var url = $"{baseUri}/Entity/List?apiKey={HttpUtility.UrlEncode(apiKey)}&searchdomain={HttpUtility.UrlEncode(searchdomain)}&returnEmbeddings={HttpUtility.UrlEncode(returnEmbeddings.ToString())}"; Dictionary<string, string> parameters = new()
return await GetUrlAndProcessJson<EntityListResults>(url); {
{"searchdomain", searchdomain}
};
StringContent content = new(JsonSerializer.Serialize(searchdomainSettings), Encoding.UTF8, "application/json");
return await FetchUrlAndProcessJson<SearchdomainUpdateResults>(HttpMethod.Put, GetUrl($"{baseUri}/Searchdomain", "Settings", parameters), content);
} }
public async Task<EntityDeleteResults> EntityDeleteAsync(string entityName) public async Task<SearchdomainSearchCacheSizeResults> SearchdomainGetQueryCacheSizeAsync(string searchdomain)
{ {
return await EntityDeleteAsync(searchdomain, entityName); Dictionary<string, string> parameters = new()
{
{"searchdomain", searchdomain}
};
return await FetchUrlAndProcessJson<SearchdomainSearchCacheSizeResults>(HttpMethod.Get, GetUrl($"{baseUri}/Searchdomain/QueryCache", "Size", parameters));
} }
public async Task<EntityDeleteResults> EntityDeleteAsync(string searchdomain, string entityName) public async Task<SearchdomainInvalidateCacheResults> SearchdomainClearQueryCache(string searchdomain)
{ {
var url = $"{baseUri}/Entity/Delete?apiKey={HttpUtility.UrlEncode(apiKey)}&searchdomain={HttpUtility.UrlEncode(searchdomain)}&entity={HttpUtility.UrlEncode(entityName)}"; Dictionary<string, string> parameters = new()
return await GetUrlAndProcessJson<EntityDeleteResults>(url); {
{"searchdomain", searchdomain}
};
return await FetchUrlAndProcessJson<SearchdomainInvalidateCacheResults>(HttpMethod.Post, GetUrl($"{baseUri}/Searchdomain/QueryCache", "Clear", parameters), null);
} }
private static async Task<T> GetUrlAndProcessJson<T>(string url) public async Task<SearchdomainGetDatabaseSizeResult> SearchdomainGetDatabaseSizeAsync(string searchdomain)
{ {
Dictionary<string, string> parameters = new()
{
{"searchdomain", searchdomain}
};
return await FetchUrlAndProcessJson<SearchdomainGetDatabaseSizeResult>(HttpMethod.Get, GetUrl($"{baseUri}/Searchdomain/Database", "Size", parameters));
}
public async Task<ServerGetModelsResult> ServerGetModelsAsync()
{
return await FetchUrlAndProcessJson<ServerGetModelsResult>(HttpMethod.Get, GetUrl($"{baseUri}/Server", "Models", []));
}
public async Task<ServerGetEmbeddingCacheSizeResult> ServerGetEmbeddingCacheSizeAsync()
{
return await FetchUrlAndProcessJson<ServerGetEmbeddingCacheSizeResult>(HttpMethod.Get, GetUrl($"{baseUri}/Server/EmbeddingCache", "Size", []));
}
private async Task<T> FetchUrlAndProcessJson<T>(HttpMethod httpMethod, string url, HttpContent? content = null)
{
HttpRequestMessage requestMessage = new(httpMethod, url)
{
Content = content,
};
requestMessage.Headers.Add("X-API-KEY", apiKey);
using var client = new HttpClient(); using var client = new HttpClient();
var response = await client.GetAsync(url); var response = await client.SendAsync(requestMessage);
string responseContent = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<T>(responseContent)
?? throw new Exception($"Failed to deserialize JSON to type {typeof(T).Name}");
return result;
}
private static async Task<T> PostUrlAndProcessJson<T>(string url, HttpContent content)
{
using var client = new HttpClient();
var response = await client.PostAsync(url, content);
string responseContent = await response.Content.ReadAsStringAsync(); string responseContent = await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == HttpStatusCode.Unauthorized) throw new UnauthorizedAccessException(responseContent); // TODO implement distinct exceptions
if (response.StatusCode == HttpStatusCode.InternalServerError) throw new Exception($"Request was unsuccessful due to an internal server error: {responseContent}"); // TODO implement proper InternalServerErrorException
var result = JsonSerializer.Deserialize<T>(responseContent) var result = JsonSerializer.Deserialize<T>(responseContent)
?? throw new Exception($"Failed to deserialize JSON to type {typeof(T).Name}"); ?? throw new Exception($"Failed to deserialize JSON to type {typeof(T).Name}");
return result; return result;
} }
public static string GetUrl(string baseUri, string endpoint, string apiKey, Dictionary<string, string> parameters) public static string GetUrl(string baseUri, string endpoint, Dictionary<string, string> parameters)
{ {
var uriBuilder = new UriBuilder($"{baseUri}/{endpoint}"); var uriBuilder = new UriBuilder($"{baseUri}/{endpoint}");
var query = HttpUtility.ParseQueryString(uriBuilder.Query); var query = HttpUtility.ParseQueryString(uriBuilder.Query);
if (apiKey.Length > 0) query["apiKey"] = apiKey;
foreach (var param in parameters) foreach (var param in parameters)
{ {
query[param.Key] = param.Value; query[param.Key] = param.Value;

View File

@@ -15,6 +15,7 @@
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.3" />
<PackageReference Include="Python" Version="3.13.3" /> <PackageReference Include="Python" Version="3.13.3" />
<PackageReference Include="Pythonnet" Version="3.0.5" /> <PackageReference Include="Pythonnet" Version="3.0.5" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,9 @@
using Shared.Models;
namespace Indexer.Models;
public class IndexerOptions : ApiKeyOptions
{
public required WorkerConfig[] Workers { get; set; }
public required ServerOptions Server { get; set;}
public required string PythonRuntime { get; set; } = "libpython3.13.so";
}

View File

@@ -15,11 +15,11 @@ public class ScriptToolSet
public Client.Client Client; public Client.Client Client;
public LoggerWrapper Logger; public LoggerWrapper Logger;
public ICallbackInfos? CallbackInfos; public ICallbackInfos? CallbackInfos;
public IConfiguration Configuration; public IndexerOptions Configuration;
public CancellationToken CancellationToken; public CancellationToken CancellationToken;
public string Name; public string Name;
public ScriptToolSet(string filePath, Client.Client client, ILogger<WorkerManager> logger, IConfiguration configuration, CancellationToken cancellationToken, string name) public ScriptToolSet(string filePath, Client.Client client, ILogger<WorkerManager> logger, IndexerOptions configuration, CancellationToken cancellationToken, string name)
{ {
Configuration = configuration; Configuration = configuration;
Name = name; Name = name;

View File

@@ -6,6 +6,8 @@ using ElmahCore.Mvc;
using ElmahCore.Mvc.Logger; using ElmahCore.Mvc.Logger;
using Serilog; using Serilog;
using Quartz; using Quartz;
using System.Configuration;
using Shared.Models;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -21,6 +23,12 @@ Log.Logger = new LoggerConfiguration()
builder.Logging.AddSerilog(); builder.Logging.AddSerilog();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IConfigurationRoot>(builder.Configuration); builder.Services.AddSingleton<IConfigurationRoot>(builder.Configuration);
IConfigurationSection configurationSection = builder.Configuration.GetSection("Indexer");
IndexerOptions configuration = configurationSection.Get<IndexerOptions>() ?? throw new ConfigurationErrorsException("Unable to start server due to an invalid configration");
builder.Services.Configure<IndexerOptions>(configurationSection);
builder.Services.Configure<ServerOptions>(configurationSection.GetSection("Server"));
builder.Services.Configure<ApiKeyOptions>(configurationSection);
builder.Services.AddSingleton<Client.Client>(); builder.Services.AddSingleton<Client.Client>();
builder.Services.AddSingleton<WorkerManager>(); builder.Services.AddSingleton<WorkerManager>();
builder.Services.AddHostedService<IndexerService>(); builder.Services.AddHostedService<IndexerService>();

View File

@@ -15,11 +15,8 @@ public class PythonScriptable : IScriptContainer
public ILogger _logger { get; set; } public ILogger _logger { get; set; }
public PythonScriptable(ScriptToolSet toolSet, ILogger logger) public PythonScriptable(ScriptToolSet toolSet, ILogger logger)
{ {
string? runtime = toolSet.Configuration.GetValue<string>("EmbeddingsearchIndexer:PythonRuntime"); string runtime = toolSet.Configuration.PythonRuntime;
if (runtime is not null) Runtime.PythonDLL ??= runtime;
{
Runtime.PythonDLL ??= runtime;
}
_logger = logger; _logger = logger;
SourceLoaded = false; SourceLoaded = false;
if (!PythonEngine.IsInitialized) if (!PythonEngine.IsInitialized)

View File

@@ -1,21 +1,22 @@
using Indexer.Exceptions; using Indexer.Exceptions;
using Indexer.Models; using Indexer.Models;
using Indexer.ScriptContainers; using Indexer.ScriptContainers;
using Microsoft.Extensions.Options;
public class WorkerManager public class WorkerManager
{ {
public Dictionary<string, Worker> Workers; public Dictionary<string, Worker> Workers;
public List<Type> types; public List<Type> types;
private readonly ILogger<WorkerManager> _logger; private readonly ILogger<WorkerManager> _logger;
private readonly IConfiguration _configuration; private readonly IndexerOptions _configuration;
private readonly Client.Client client; private readonly Client.Client client;
public WorkerManager(ILogger<WorkerManager> logger, IConfiguration configuration, Client.Client client) public WorkerManager(ILogger<WorkerManager> logger, IOptions<IndexerOptions> configuration, Client.Client client)
{ {
Workers = []; Workers = [];
types = [typeof(PythonScriptable), typeof(CSharpScriptable)]; types = [typeof(PythonScriptable), typeof(CSharpScriptable)];
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration.Value;
this.client = client; this.client = client;
} }
@@ -23,27 +24,12 @@ public class WorkerManager
{ {
_logger.LogInformation("Initializing workers"); _logger.LogInformation("Initializing workers");
// Load and configure all workers // Load and configure all workers
var sectionMain = _configuration.GetSection("EmbeddingsearchIndexer");
if (!sectionMain.Exists())
{
_logger.LogCritical("Unable to load section \"EmbeddingsearchIndexer\"");
throw new IndexerConfigurationException("Unable to load section \"EmbeddingsearchIndexer\"");
}
WorkerCollectionConfig? sectionWorker = (WorkerCollectionConfig?)sectionMain.Get(typeof(WorkerCollectionConfig)); //GetValue<WorkerCollectionConfig>("Worker"); foreach (WorkerConfig workerConfig in _configuration.Workers)
if (sectionWorker is not null)
{ {
foreach (WorkerConfig workerConfig in sectionWorker.Worker) CancellationTokenSource cancellationTokenSource = new();
{ ScriptToolSet toolSet = new(workerConfig.Script, client, _logger, _configuration, cancellationTokenSource.Token, workerConfig.Name);
CancellationTokenSource cancellationTokenSource = new(); InitializeWorker(toolSet, workerConfig, cancellationTokenSource);
ScriptToolSet toolSet = new(workerConfig.Script, client, _logger, _configuration, cancellationTokenSource.Token, workerConfig.Name);
InitializeWorker(toolSet, workerConfig, cancellationTokenSource);
}
}
else
{
_logger.LogCritical("Unable to load section \"Worker\"");
throw new IndexerConfigurationException("Unable to load section \"Worker\"");
} }
_logger.LogInformation("Initialized workers"); _logger.LogInformation("Initialized workers");
} }

View File

@@ -5,46 +5,23 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"Embeddingsearch": { "Indexer": {
"BaseUri": "http://localhost:5146" "Workers": [
},
"EmbeddingsearchIndexer": {
"Worker":
[
{ {
"Name": "pythonExample", "Name": "pythonExample",
"Script": "Scripts/example.py", "Script": "Scripts/example.py",
"Calls": [
{
"Name": "intervalCall",
"Type": "interval",
"Interval": 30000
}
]
},
{
"Name": "csharpExample",
"Script": "Scripts/example.csx",
"Calls": [ "Calls": [
{ {
"Name": "runonceCall", "Name": "runonceCall",
"Type": "runonce" "Type": "runonce"
},
{
"Name": "scheduleCall",
"Type": "schedule",
"Schedule": "0 0/5 * * * ?"
},
{
"Name": "fileupdateCall",
"Type": "fileupdate",
"Path": "./Scripts/example_content",
"Events": ["Created", "Changed", "Deleted", "Renamed"],
"Filters": ["*.md", "*.txt"],
"IncludeSubdirectories": true
} }
] ]
} }
] ],
"ApiKeys": ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"],
"Server": {
"BaseUri": "http://localhost:5146",
"ApiKey": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
}
} }
} }

View File

@@ -24,7 +24,7 @@
], ],
"LogFolder": "./logs" "LogFolder": "./logs"
}, },
"PythonRuntime": "libpython3.12.so" "PythonRuntime": "libpython3.13.so"
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@@ -1,24 +1,25 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Server.Exceptions; using Server.Exceptions;
using Server.Models;
namespace Server; namespace Server;
public class AIProvider public class AIProvider
{ {
private readonly ILogger<AIProvider> _logger; private readonly ILogger<AIProvider> _logger;
private readonly IConfiguration _configuration; private readonly EmbeddingSearchOptions _configuration;
public AIProvidersConfiguration aIProvidersConfiguration; public Dictionary<string, AiProvider> aIProvidersConfiguration;
public AIProvider(ILogger<AIProvider> logger, IConfiguration configuration) public AIProvider(ILogger<AIProvider> logger, IOptions<EmbeddingSearchOptions> configuration)
{ {
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration.Value;
AIProvidersConfiguration? retrievedAiProvidersConfiguration = _configuration Dictionary<string, AiProvider>? retrievedAiProvidersConfiguration = _configuration.AiProviders;
.GetSection("Embeddingsearch")
.Get<AIProvidersConfiguration>();
if (retrievedAiProvidersConfiguration is null) if (retrievedAiProvidersConfiguration is null)
{ {
_logger.LogCritical("Unable to build AIProvidersConfiguration. Please check your configuration."); _logger.LogCritical("Unable to build AIProvidersConfiguration. Please check your configuration.");
@@ -35,8 +36,8 @@ public class AIProvider
Uri uri = new(modelUri); Uri uri = new(modelUri);
string provider = uri.Scheme; string provider = uri.Scheme;
string model = uri.AbsolutePath; string model = uri.AbsolutePath;
AIProviderConfiguration? aIProvider = aIProvidersConfiguration.AiProviders AiProvider? aIProvider = aIProvidersConfiguration
.FirstOrDefault(x => String.Equals(x.Key.ToLower(), provider.ToLower())) .FirstOrDefault(x => string.Equals(x.Key.ToLower(), provider.ToLower()))
.Value; .Value;
if (aIProvider is null) if (aIProvider is null)
{ {
@@ -119,12 +120,12 @@ public class AIProvider
public string[] GetModels() public string[] GetModels()
{ {
var aIProviders = aIProvidersConfiguration.AiProviders; var aIProviders = aIProvidersConfiguration;
List<string> results = []; List<string> results = [];
foreach (KeyValuePair<string, AIProviderConfiguration> aIProviderKV in aIProviders) foreach (KeyValuePair<string, AiProvider> aIProviderKV in aIProviders)
{ {
string aIProviderName = aIProviderKV.Key; string aIProviderName = aIProviderKV.Key;
AIProviderConfiguration aIProvider = aIProviderKV.Value; AiProvider aIProvider = aIProviderKV.Value;
using var httpClient = new HttpClient(); using var httpClient = new HttpClient();
@@ -178,7 +179,12 @@ public class AIProvider
foreach (string? result in aIProviderResult) foreach (string? result in aIProviderResult)
{ {
if (result is null) continue; if (result is null) continue;
results.Add(aIProviderName + ":" + result); bool isInAllowList = ElementMatchesAnyRegexInList(result, aIProvider.Allowlist);
bool isInDenyList = ElementMatchesAnyRegexInList(result, aIProvider.Denylist);
if (isInAllowList && !isInDenyList)
{
results.Add(aIProviderName + ":" + result);
}
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -189,6 +195,11 @@ public class AIProvider
} }
return [.. results]; return [.. results];
} }
private static bool ElementMatchesAnyRegexInList(string element, string[] list)
{
return list?.Any(pattern => pattern != null && Regex.IsMatch(element, pattern)) ?? false;
}
} }
public class AIProvidersConfiguration public class AIProvidersConfiguration

View File

@@ -6,14 +6,15 @@ using Server.Models;
namespace Server.Controllers; namespace Server.Controllers;
[ApiExplorerSettings(IgnoreApi = true)]
[Route("[Controller]")] [Route("[Controller]")]
public class AccountController : Controller public class AccountController : Controller
{ {
private readonly SimpleAuthOptions _options; private readonly SimpleAuthOptions _options;
public AccountController(IOptions<SimpleAuthOptions> options) public AccountController(IOptions<EmbeddingSearchOptions> options)
{ {
_options = options.Value; _options = options.Value.SimpleAuth;
} }
[HttpGet("Login")] [HttpGet("Login")]

View File

@@ -24,91 +24,27 @@ public class EntityController : ControllerBase
_databaseHelper = databaseHelper; _databaseHelper = databaseHelper;
} }
[HttpGet("Query")] /// <summary>
public ActionResult<EntityQueryResults> Query(string searchdomain, string query, int? topN) /// List the entities in a searchdomain
{ /// </summary>
Searchdomain searchdomain_; /// <remarks>
try /// With returnModels = false expect: "Datapoints": [..., "Embeddings": null]<br/>
{ /// With returnModels = true expect: "Datapoints": [..., "Embeddings": [{"Model": "...", "Embeddings": []}, ...]]<br/>
searchdomain_ = _domainManager.GetSearchdomain(searchdomain); /// With returnEmbeddings = true expect: "Datapoints": [..., "Embeddings": [{"Model": "...", "Embeddings": [0.007384672,0.01309805,0.0012528514,...]}, ...]]
} catch (SearchdomainNotFoundException) /// </remarks>
{ /// <param name="searchdomain">Name of the searchdomain</param>
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - it likely does not exist yet", [searchdomain]); /// <param name="returnModels">Include the models in the response</param>
return Ok(new EntityQueryResults() {Results = [], Success = false, Message = "Searchdomain not found" }); /// <param name="returnEmbeddings">Include the embeddings in the response (requires returnModels)</param>
} catch (Exception ex) [HttpGet("/Entities")]
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return Ok(new EntityQueryResults() {Results = [], Success = false, Message = "Unable to retrieve the searchdomain - it likely exists, but some other error happened." });
}
List<(float, string)> results = searchdomain_.Search(query, topN);
List<EntityQueryResult> queryResults = [.. results.Select(r => new EntityQueryResult
{
Name = r.Item2,
Value = r.Item1
})];
return Ok(new EntityQueryResults(){Results = queryResults, Success = true });
}
[HttpPost("Index")]
public ActionResult<EntityIndexResult> Index([FromBody] List<JSONEntity>? jsonEntities)
{
try
{
List<Entity>? entities = _searchdomainHelper.EntitiesFromJSON(
_domainManager,
_logger,
JsonSerializer.Serialize(jsonEntities));
if (entities is not null && jsonEntities is not null)
{
List<string> invalidatedSearchdomains = [];
foreach (var jsonEntity in jsonEntities)
{
string jsonEntityName = jsonEntity.Name;
string jsonEntitySearchdomainName = jsonEntity.Searchdomain;
if (entities.Select(x => x.name == jsonEntityName).Any()
&& !invalidatedSearchdomains.Contains(jsonEntitySearchdomainName))
{
invalidatedSearchdomains.Add(jsonEntitySearchdomainName);
_domainManager.InvalidateSearchdomainCache(jsonEntitySearchdomainName);
}
}
return Ok(new EntityIndexResult() { Success = true });
}
else
{
_logger.LogError("Unable to deserialize an entity");
return Ok(new EntityIndexResult() { Success = false, Message = "Unable to deserialize an entity"});
}
} catch (Exception ex)
{
if (ex.InnerException is not null) ex = ex.InnerException;
_logger.LogError("Unable to index the provided entities. {ex.Message} - {ex.StackTrace}", [ex.Message, ex.StackTrace]);
return Ok(new EntityIndexResult() { Success = false, Message = ex.Message });
}
}
[HttpGet("List")]
public ActionResult<EntityListResults> List(string searchdomain, bool returnModels = false, bool returnEmbeddings = false) public ActionResult<EntityListResults> List(string searchdomain, bool returnModels = false, bool returnEmbeddings = false)
{ {
if (returnEmbeddings && !returnModels) if (returnEmbeddings && !returnModels)
{ {
_logger.LogError("Invalid request for {searchdomain} - embeddings return requested but without models - not possible!", [searchdomain]); _logger.LogError("Invalid request for {searchdomain} - embeddings return requested but without models - not possible!", [searchdomain]);
return Ok(new EntityQueryResults() {Results = [], Success = false, Message = "Invalid request" }); return BadRequest(new EntityListResults() {Results = [], Success = false, Message = "Invalid request" });
}
Searchdomain searchdomain_;
try
{
searchdomain_ = _domainManager.GetSearchdomain(searchdomain);
} catch (SearchdomainNotFoundException)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - it likely does not exist yet", [searchdomain]);
return Ok(new EntityQueryResults() {Results = [], Success = false, Message = "Searchdomain not found" });
} catch (Exception ex)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return Ok(new EntityQueryResults() {Results = [], Success = false, Message = "Unable to retrieve the searchdomain - it likely exists, but some other error happened." });
} }
(Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
EntityListResults entityListResults = new() {Results = [], Success = true}; EntityListResults entityListResults = new() {Results = [], Success = true};
foreach (Entity entity in searchdomain_.entityCache) foreach (Entity entity in searchdomain_.entityCache)
{ {
@@ -146,29 +82,69 @@ public class EntityController : ControllerBase
return Ok(entityListResults); return Ok(entityListResults);
} }
[HttpGet("Delete")] /// <summary>
public ActionResult<EntityDeleteResults> Delete(string searchdomain, string entityName) /// Index entities
/// </summary>
/// <remarks>
/// Behavior: Creates new entities, but overwrites existing entities that have the same name
/// </remarks>
/// <param name="jsonEntities">Entities to index</param>
[HttpPut("/Entities")]
public ActionResult<EntityIndexResult> Index([FromBody] List<JSONEntity>? jsonEntities)
{ {
Searchdomain searchdomain_;
try try
{ {
searchdomain_ = _domainManager.GetSearchdomain(searchdomain); List<Entity>? entities = _searchdomainHelper.EntitiesFromJSON(
} catch (SearchdomainNotFoundException) _domainManager,
{ _logger,
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - it likely does not exist yet", [searchdomain]); JsonSerializer.Serialize(jsonEntities));
return Ok(new EntityQueryResults() {Results = [], Success = false, Message = "Searchdomain not found" }); if (entities is not null && jsonEntities is not null)
{
List<string> invalidatedSearchdomains = [];
foreach (var jsonEntity in jsonEntities)
{
string jsonEntityName = jsonEntity.Name;
string jsonEntitySearchdomainName = jsonEntity.Searchdomain;
if (entities.Select(x => x.name == jsonEntityName).Any()
&& !invalidatedSearchdomains.Contains(jsonEntitySearchdomainName))
{
invalidatedSearchdomains.Add(jsonEntitySearchdomainName);
}
}
return Ok(new EntityIndexResult() { Success = true });
}
else
{
_logger.LogError("Unable to deserialize an entity");
return Ok(new EntityIndexResult() { Success = false, Message = "Unable to deserialize an entity"});
}
} catch (Exception ex) } catch (Exception ex)
{ {
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]); if (ex.InnerException is not null) ex = ex.InnerException;
return Ok(new EntityQueryResults() {Results = [], Success = false, Message = "Unable to retrieve the searchdomain - it likely exists, but some other error happened." }); _logger.LogError("Unable to index the provided entities. {ex.Message} - {ex.StackTrace}", [ex.Message, ex.StackTrace]);
return Ok(new EntityIndexResult() { Success = false, Message = ex.Message });
} }
}
/// <summary>
/// Deletes an entity
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
/// <param name="entityName">Name of the entity</param>
[HttpDelete]
public ActionResult<EntityDeleteResults> Delete(string searchdomain, string entityName)
{
(Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
Entity? entity_ = SearchdomainHelper.CacheGetEntity(searchdomain_.entityCache, entityName); Entity? entity_ = SearchdomainHelper.CacheGetEntity(searchdomain_.entityCache, entityName);
if (entity_ is null) if (entity_ is null)
{ {
_logger.LogError("Unable to delete the entity {entityName} in {searchdomain} - it was not found under the specified name", [entityName, searchdomain]); _logger.LogError("Unable to delete the entity {entityName} in {searchdomain} - it was not found under the specified name", [entityName, searchdomain]);
return Ok(new EntityDeleteResults() {Success = false, Message = "Entity not found"}); return Ok(new EntityDeleteResults() {Success = false, Message = "Entity not found"});
} }
searchdomain_.ReconciliateOrInvalidateCacheForDeletedEntity(entity_);
_databaseHelper.RemoveEntity([], _domainManager.helper, entityName, searchdomain); _databaseHelper.RemoveEntity([], _domainManager.helper, entityName, searchdomain);
searchdomain_.entityCache.RemoveAll(entity => entity.name == entityName); searchdomain_.entityCache.RemoveAll(entity => entity.name == entityName);
return Ok(new EntityDeleteResults() {Success = true}); return Ok(new EntityDeleteResults() {Success = true});

View File

@@ -7,8 +7,8 @@ using Server.Exceptions;
using Server.Models; using Server.Models;
namespace Server.Controllers; namespace Server.Controllers;
[ApiController] [ApiExplorerSettings(IgnoreApi = true)]
[Route("/")] [Route("[Controller]")]
public class HomeController : Controller public class HomeController : Controller
{ {
private readonly ILogger<EntityController> _logger; private readonly ILogger<EntityController> _logger;
@@ -20,9 +20,22 @@ public class HomeController : Controller
_domainManager = domainManager; _domainManager = domainManager;
} }
[Authorize]
[HttpGet("/")] [HttpGet("/")]
public IActionResult Root()
{
return Redirect("/Home/Index");
}
[Authorize]
[HttpGet("Index")]
public IActionResult Index() public IActionResult Index()
{
return View();
}
[Authorize]
[HttpGet("Searchdomains")]
public IActionResult Searchdomains()
{ {
HomeIndexViewModel viewModel = new() HomeIndexViewModel viewModel = new()
{ {

View File

@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json; using System.Text.Json;
using ElmahCore; using ElmahCore;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
@@ -23,7 +24,10 @@ public class SearchdomainController : ControllerBase
_domainManager = domainManager; _domainManager = domainManager;
} }
[HttpGet("List")] /// <summary>
/// Lists all searchdomains
/// </summary>
[HttpGet("/Searchdomains")]
public ActionResult<SearchdomainListResults> List() public ActionResult<SearchdomainListResults> List()
{ {
List<string> results; List<string> results;
@@ -40,8 +44,13 @@ public class SearchdomainController : ControllerBase
return Ok(searchdomainListResults); return Ok(searchdomainListResults);
} }
[HttpGet("Create")] /// <summary>
public ActionResult<SearchdomainCreateResults> Create(string searchdomain, string settings = "{}") /// Creates a new searchdomain
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
/// <param name="settings">Optional initial settings</param>
[HttpPost]
public ActionResult<SearchdomainCreateResults> Create([Required]string searchdomain, [FromBody]SearchdomainSettings settings = new())
{ {
try try
{ {
@@ -54,8 +63,12 @@ public class SearchdomainController : ControllerBase
} }
} }
[HttpGet("Delete")] /// <summary>
public ActionResult<SearchdomainDeleteResults> Delete(string searchdomain) /// Deletes a searchdomain
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
[HttpDelete]
public ActionResult<SearchdomainDeleteResults> Delete([Required]string searchdomain)
{ {
bool success; bool success;
int deletedEntries; int deletedEntries;
@@ -84,12 +97,27 @@ public class SearchdomainController : ControllerBase
return Ok(new SearchdomainDeleteResults(){Success = success, DeletedEntities = deletedEntries, Message = message}); return Ok(new SearchdomainDeleteResults(){Success = success, DeletedEntities = deletedEntries, Message = message});
} }
[HttpGet("Update")] /// <summary>
public ActionResult<SearchdomainUpdateResults> Update(string searchdomain, string newName, string settings = "{}") /// Updates name and settings of a searchdomain
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
/// <param name="newName">Updated name of the searchdomain</param>
/// <param name="settings">Updated settings of searchdomain</param>
[HttpPut]
public ActionResult<SearchdomainUpdateResults> Update([Required]string searchdomain, string newName, [FromBody]SearchdomainSettings? settings)
{ {
try (Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
if (settings is null)
{
Dictionary<string, dynamic> parameters = new()
{
{"name", newName},
{"id", searchdomain_.id}
};
searchdomain_.helper.ExecuteSQLNonQuery("UPDATE searchdomain set name = @name WHERE id = @id", parameters);
} else
{ {
Searchdomain searchdomain_ = _domainManager.GetSearchdomain(searchdomain);
Dictionary<string, dynamic> parameters = new() Dictionary<string, dynamic> parameters = new()
{ {
{"name", newName}, {"name", newName},
@@ -97,84 +125,56 @@ public class SearchdomainController : ControllerBase
{"id", searchdomain_.id} {"id", searchdomain_.id}
}; };
searchdomain_.helper.ExecuteSQLNonQuery("UPDATE searchdomain set name = @name, settings = @settings WHERE id = @id", parameters); searchdomain_.helper.ExecuteSQLNonQuery("UPDATE searchdomain set name = @name, settings = @settings WHERE id = @id", parameters);
} catch (SearchdomainNotFoundException)
{
_logger.LogError("Unable to update searchdomain {searchdomain} - not found", [searchdomain]);
return Ok(new SearchdomainUpdateResults() { Success = false, Message = $"Unable to update searchdomain {searchdomain} - not found" });
} catch (Exception ex)
{
_logger.LogError("Unable to update searchdomain {searchdomain} - Exception: {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return Ok(new SearchdomainUpdateResults() { Success = false, Message = $"Unable to update searchdomain {searchdomain}" });
} }
return Ok(new SearchdomainUpdateResults(){Success = true}); return Ok(new SearchdomainUpdateResults(){Success = true});
} }
[HttpPost("UpdateSettings")] /// <summary>
public ActionResult<SearchdomainUpdateResults> UpdateSettings(string searchdomain, [FromBody] SearchdomainSettings request) /// Gets the query cache of a searchdomain
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
[HttpGet("Queries")]
public ActionResult<SearchdomainSearchesResults> GetQueries([Required]string searchdomain)
{ {
try (Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
{ if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
Searchdomain searchdomain_ = _domainManager.GetSearchdomain(searchdomain);
Dictionary<string, dynamic> parameters = new()
{
{"settings", JsonSerializer.Serialize(request)},
{"id", searchdomain_.id}
};
searchdomain_.helper.ExecuteSQLNonQuery("UPDATE searchdomain set settings = @settings WHERE id = @id", parameters);
searchdomain_.settings = request;
} catch (SearchdomainNotFoundException)
{
_logger.LogError("Unable to update settings for searchdomain {searchdomain} - not found", [searchdomain]);
return Ok(new SearchdomainUpdateResults() { Success = false, Message = $"Unable to update settings for searchdomain {searchdomain} - not found" });
} catch (Exception ex)
{
_logger.LogError("Unable to update settings for searchdomain {searchdomain} - Exception: {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return Ok(new SearchdomainUpdateResults() { Success = false, Message = $"Unable to update settings for searchdomain {searchdomain}" });
}
return Ok(new SearchdomainUpdateResults(){Success = true});
}
[HttpGet("GetSearches")]
public ActionResult<SearchdomainSearchesResults> GetSearches(string searchdomain)
{
Searchdomain searchdomain_;
try
{
searchdomain_ = _domainManager.GetSearchdomain(searchdomain);
}
catch (SearchdomainNotFoundException)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - it likely does not exist yet", [searchdomain]);
return Ok(new SearchdomainSearchesResults() { Searches = [], Success = false, Message = "Searchdomain not found" });
}
catch (Exception ex)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return Ok(new SearchdomainSearchesResults() { Searches = [], Success = false, Message = ex.Message });
}
Dictionary<string, DateTimedSearchResult> searchCache = searchdomain_.searchCache; Dictionary<string, DateTimedSearchResult> searchCache = searchdomain_.searchCache;
return Ok(new SearchdomainSearchesResults() { Searches = searchCache, Success = true }); return Ok(new SearchdomainSearchesResults() { Searches = searchCache, Success = true });
} }
[HttpDelete("Searches")] /// <summary>
public ActionResult<SearchdomainDeleteSearchResult> DeleteSearch(string searchdomain, string query) /// Executes a query in the searchdomain
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
/// <param name="query">Query to execute</param>
/// <param name="topN">Return only the top N results</param>
/// <param name="returnAttributes">Return the attributes of the object</param>
[HttpPost("Query")]
public ActionResult<EntityQueryResults> Query([Required]string searchdomain, [Required]string query, int? topN, bool returnAttributes = false)
{ {
Searchdomain searchdomain_; (Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
try if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
List<(float, string)> results = searchdomain_.Search(query, topN);
List<EntityQueryResult> queryResults = [.. results.Select(r => new EntityQueryResult
{ {
searchdomain_ = _domainManager.GetSearchdomain(searchdomain); Name = r.Item2,
} Value = r.Item1,
catch (SearchdomainNotFoundException) Attributes = returnAttributes ? (searchdomain_.entityCache.FirstOrDefault(x => x.name == r.Item2)?.attributes ?? null) : null
{ })];
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - it likely does not exist yet", [searchdomain]); return Ok(new EntityQueryResults(){Results = queryResults, Success = true });
return Ok(new SearchdomainDeleteSearchResult() { Success = false, Message = "Searchdomain not found" }); }
}
catch (Exception ex) /// <summary>
{ /// Deletes a query from the query cache
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]); /// </summary>
return Ok(new SearchdomainDeleteSearchResult() { Success = false, Message = ex.Message }); /// <param name="searchdomain">Name of the searchdomain</param>
} /// <param name="query">Query to delete</param>
[HttpDelete("Query")]
public ActionResult<SearchdomainDeleteSearchResult> DeleteQuery([Required]string searchdomain, [Required]string query)
{
(Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
Dictionary<string, DateTimedSearchResult> searchCache = searchdomain_.searchCache; Dictionary<string, DateTimedSearchResult> searchCache = searchdomain_.searchCache;
bool containsKey = searchCache.ContainsKey(query); bool containsKey = searchCache.ContainsKey(query);
if (containsKey) if (containsKey)
@@ -185,24 +185,17 @@ public class SearchdomainController : ControllerBase
return Ok(new SearchdomainDeleteSearchResult() {Success = false, Message = "Query not found in search cache"}); return Ok(new SearchdomainDeleteSearchResult() {Success = false, Message = "Query not found in search cache"});
} }
[HttpPatch("Searches")] /// <summary>
public ActionResult<SearchdomainUpdateSearchResult> UpdateSearch(string searchdomain, string query, [FromBody]List<ResultItem> results) /// Updates a query from the query cache
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
/// <param name="query">Query to update</param>
/// <param name="results">List of results to apply to the query</param>
[HttpPatch("Query")]
public ActionResult<SearchdomainUpdateSearchResult> UpdateQuery([Required]string searchdomain, [Required]string query, [Required][FromBody]List<ResultItem> results)
{ {
Searchdomain searchdomain_; (Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
try if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
{
searchdomain_ = _domainManager.GetSearchdomain(searchdomain);
}
catch (SearchdomainNotFoundException)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - it likely does not exist yet", [searchdomain]);
return Ok(new SearchdomainUpdateSearchResult() { Success = false, Message = "Searchdomain not found" });
}
catch (Exception ex)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return Ok(new SearchdomainUpdateSearchResult() { Success = false, Message = ex.Message });
}
Dictionary<string, DateTimedSearchResult> searchCache = searchdomain_.searchCache; Dictionary<string, DateTimedSearchResult> searchCache = searchdomain_.searchCache;
bool containsKey = searchCache.ContainsKey(query); bool containsKey = searchCache.ContainsKey(query);
if (containsKey) if (containsKey)
@@ -215,46 +208,47 @@ public class SearchdomainController : ControllerBase
return Ok(new SearchdomainUpdateSearchResult() {Success = false, Message = "Query not found in search cache"}); return Ok(new SearchdomainUpdateSearchResult() {Success = false, Message = "Query not found in search cache"});
} }
[HttpGet("GetSettings")] /// <summary>
public ActionResult<SearchdomainSettingsResults> GetSettings(string searchdomain) /// Get the settings of a searchdomain
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
[HttpGet("Settings")]
public ActionResult<SearchdomainSettingsResults> GetSettings([Required]string searchdomain)
{ {
Searchdomain searchdomain_; (Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
try if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
{
searchdomain_ = _domainManager.GetSearchdomain(searchdomain);
}
catch (SearchdomainNotFoundException)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - it likely does not exist yet", [searchdomain]);
return Ok(new SearchdomainSettingsResults() { Settings = null, Success = false, Message = "Searchdomain not found" });
}
catch (Exception ex)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return Ok(new SearchdomainSettingsResults() { Settings = null, Success = false, Message = ex.Message });
}
SearchdomainSettings settings = searchdomain_.settings; SearchdomainSettings settings = searchdomain_.settings;
return Ok(new SearchdomainSettingsResults() { Settings = settings, Success = true }); return Ok(new SearchdomainSettingsResults() { Settings = settings, Success = true });
} }
[HttpGet("GetSearchCacheSize")] /// <summary>
public ActionResult<SearchdomainSearchCacheSizeResults> GetSearchCacheSize(string searchdomain) /// Update the settings of a searchdomain
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
[HttpPut("Settings")]
public ActionResult<SearchdomainUpdateResults> UpdateSettings([Required]string searchdomain, [Required][FromBody] SearchdomainSettings request)
{ {
Searchdomain searchdomain_; (Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
try if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
Dictionary<string, dynamic> parameters = new()
{ {
searchdomain_ = _domainManager.GetSearchdomain(searchdomain); {"settings", JsonSerializer.Serialize(request)},
} {"id", searchdomain_.id}
catch (SearchdomainNotFoundException) };
{ searchdomain_.helper.ExecuteSQLNonQuery("UPDATE searchdomain set settings = @settings WHERE id = @id", parameters);
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - it likely does not exist yet", [searchdomain]); searchdomain_.settings = request;
return Ok(new SearchdomainSearchCacheSizeResults() { SearchCacheSizeBytes = null, Success = false, Message = "Searchdomain not found" }); return Ok(new SearchdomainUpdateResults(){Success = true});
} }
catch (Exception ex)
{ /// <summary>
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]); /// Get the query cache size of a searchdomain
return Ok(new SearchdomainSearchCacheSizeResults() { SearchCacheSizeBytes = null, Success = false, Message = ex.Message }); /// </summary>
} /// <param name="searchdomain">Name of the searchdomain</param>
[HttpGet("QueryCache/Size")]
public ActionResult<SearchdomainSearchCacheSizeResults> GetSearchCacheSize([Required]string searchdomain)
{
(Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
Dictionary<string, DateTimedSearchResult> searchCache = searchdomain_.searchCache; Dictionary<string, DateTimedSearchResult> searchCache = searchdomain_.searchCache;
long sizeInBytes = 0; long sizeInBytes = 0;
foreach (var entry in searchCache) foreach (var entry in searchCache)
@@ -263,46 +257,31 @@ public class SearchdomainController : ControllerBase
sizeInBytes += entry.Key.Length * sizeof(char); // string characters sizeInBytes += entry.Key.Length * sizeof(char); // string characters
sizeInBytes += entry.Value.EstimateSize(); sizeInBytes += entry.Value.EstimateSize();
} }
return Ok(new SearchdomainSearchCacheSizeResults() { SearchCacheSizeBytes = sizeInBytes, Success = true }); return Ok(new SearchdomainSearchCacheSizeResults() { QueryCacheSizeBytes = sizeInBytes, Success = true });
} }
[HttpGet("ClearSearchCache")] /// <summary>
public ActionResult<SearchdomainInvalidateCacheResults> InvalidateSearchCache(string searchdomain) /// Clear the query cache of a searchdomain
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
[HttpPost("QueryCache/Clear")]
public ActionResult<SearchdomainInvalidateCacheResults> InvalidateSearchCache([Required]string searchdomain)
{ {
try (Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
{ if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
Searchdomain searchdomain_ = _domainManager.GetSearchdomain(searchdomain); searchdomain_.InvalidateSearchCache();
searchdomain_.InvalidateSearchCache();
} catch (SearchdomainNotFoundException)
{
_logger.LogError("Unable to invalidate search cache for searchdomain {searchdomain} - not found", [searchdomain]);
return Ok(new SearchdomainInvalidateCacheResults() { Success = false, Message = $"Unable to invalidate search cache for searchdomain {searchdomain} - not found" });
} catch (Exception ex)
{
_logger.LogError("Unable to invalidate search cache for searchdomain {searchdomain} - Exception: {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return Ok(new SearchdomainInvalidateCacheResults() { Success = false, Message = $"Unable to invalidate search cache for searchdomain {searchdomain}" });
}
return Ok(new SearchdomainInvalidateCacheResults(){Success = true}); return Ok(new SearchdomainInvalidateCacheResults(){Success = true});
} }
[HttpGet("GetDatabaseSize")] /// <summary>
public ActionResult<SearchdomainGetDatabaseSizeResult> GetDatabaseSize(string searchdomain) /// Get the disk size of a searchdomain in bytes
/// </summary>
/// <param name="searchdomain">Name of the searchdomain</param>
[HttpGet("Database/Size")]
public ActionResult<SearchdomainGetDatabaseSizeResult> GetDatabaseSize([Required]string searchdomain)
{ {
Searchdomain searchdomain_; (Searchdomain? searchdomain_, int? httpStatusCode, string? message) = SearchdomainHelper.TryGetSearchdomain(_domainManager, searchdomain, _logger);
try if (searchdomain_ is null || httpStatusCode is not null) return StatusCode(httpStatusCode ?? 500, new SearchdomainUpdateResults(){Success = false, Message = message});
{
searchdomain_ = _domainManager.GetSearchdomain(searchdomain);
}
catch (SearchdomainNotFoundException)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - it likely does not exist yet", [searchdomain]);
return Ok(new SearchdomainGetDatabaseSizeResult() { SearchdomainDatabaseSizeBytes = null, Success = false, Message = "Searchdomain not found" });
}
catch (Exception ex)
{
_logger.LogError("Unable to retrieve the searchdomain {searchdomain} - {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return Ok(new SearchdomainGetDatabaseSizeResult() { SearchdomainDatabaseSizeBytes = null, Success = false, Message = ex.Message });
}
long sizeInBytes = DatabaseHelper.GetSearchdomainDatabaseSize(searchdomain_.helper, searchdomain); long sizeInBytes = DatabaseHelper.GetSearchdomainDatabaseSize(searchdomain_.helper, searchdomain);
return Ok(new SearchdomainGetDatabaseSizeResult() { SearchdomainDatabaseSizeBytes = sizeInBytes, Success = true }); return Ok(new SearchdomainGetDatabaseSizeResult() { SearchdomainDatabaseSizeBytes = sizeInBytes, Success = true });
} }

View File

@@ -1,6 +1,8 @@
namespace Server.Controllers; namespace Server.Controllers;
using System.Reflection;
using System.Text.Json; using System.Text.Json;
using AdaptiveExpressions;
using ElmahCore; using ElmahCore;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Server.Exceptions; using Server.Exceptions;
@@ -14,15 +16,23 @@ public class ServerController : ControllerBase
private readonly ILogger<ServerController> _logger; private readonly ILogger<ServerController> _logger;
private readonly IConfiguration _config; private readonly IConfiguration _config;
private AIProvider _aIProvider; private AIProvider _aIProvider;
private readonly SearchdomainManager _searchdomainManager;
public ServerController(ILogger<ServerController> logger, IConfiguration config, AIProvider aIProvider) public ServerController(ILogger<ServerController> logger, IConfiguration config, AIProvider aIProvider, SearchdomainManager searchdomainManager)
{ {
_logger = logger; _logger = logger;
_config = config; _config = config;
_aIProvider = aIProvider; _aIProvider = aIProvider;
_searchdomainManager = searchdomainManager;
} }
[HttpGet("GetModels")] /// <summary>
/// Lists the models available to the server
/// </summary>
/// <remarks>
/// Returns ALL models available to the server - not only the embedding models.
/// </remarks>
[HttpGet("Models")]
public ActionResult<ServerGetModelsResult> GetModels() public ActionResult<ServerGetModelsResult> GetModels()
{ {
try try
@@ -35,4 +45,51 @@ public class ServerController : ControllerBase
return new ServerGetModelsResult() { Success = false, Message = ex.Message}; return new ServerGetModelsResult() { Success = false, Message = ex.Message};
} }
} }
/// <summary>
/// Gets the total memory size of the embedding cache
/// </summary>
[HttpGet("EmbeddingCache/Size")]
public ActionResult<ServerGetEmbeddingCacheSizeResult> GetEmbeddingCacheSize()
{
long size = 0;
long elementCount = 0;
long embeddingsCount = 0;
LRUCache<string, Dictionary<string, float[]>> embeddingCache = _searchdomainManager.embeddingCache;
var cacheListField = embeddingCache.GetType()
.GetField("_cacheList", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("_cacheList field not found"); // TODO Remove this unsafe reflection atrocity
LinkedList<string> cacheListOriginal = (LinkedList<string>)cacheListField.GetValue(embeddingCache)!;
LinkedList<string> cacheList = new(cacheListOriginal);
foreach (string key in cacheList)
{
if (!embeddingCache.TryGet(key, out var entry))
continue;
// estimate size
size += EstimateEntrySize(key, entry);
elementCount++;
embeddingsCount += entry.Keys.Count;
}
return new ServerGetEmbeddingCacheSizeResult() { Success = true, SizeInBytes = size, MaxElementCount = _searchdomainManager.EmbeddingCacheMaxCount, ElementCount = elementCount, EmbeddingsCount = embeddingsCount};
}
private static long EstimateEntrySize(string key, Dictionary<string, float[]> value)
{
int stringOverhead = MemorySizes.Align(MemorySizes.ObjectHeader + sizeof(int));
int arrayOverhead = MemorySizes.ArrayHeader;
int dictionaryOverhead = MemorySizes.ObjectHeader;
long size = 0;
size += stringOverhead + key.Length * sizeof(char);
size += dictionaryOverhead;
foreach (var kv in value)
{
size += stringOverhead + kv.Key.Length * sizeof(char);
size += arrayOverhead + kv.Value.Length * sizeof(float);
}
return size;
}
} }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,129 @@
import { generate } from 'critical';
import fs from 'fs';
import path from 'path';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
// Login
await page.goto('http://localhost:5146/Account/Login');
await page.type('#username', 'admin');
await page.type('#password', 'UnsafePractice.67');
await page.click('button[type=submit]');
await page.waitForNavigation();
// Extract cookies
const cookies = await page.cookies();
await browser.close();
async function generateCriticalCSSForViews() {
const viewsDir = '../Views';
// Helper function to get all .cshtml files recursively
function getAllCshtmlFiles(dir) {
let results = [];
const list = fs.readdirSync(dir);
list.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
console.log("DEBUG@2");
console.log(filePath);
if (stat && stat.isDirectory()) {
// Recursively get files from subdirectories
results = results.concat(getAllCshtmlFiles(filePath));
} else if (file.endsWith('.cshtml') && filePath.search("/_") == -1) {
results.push(filePath);
}
});
return results;
}
// Helper function to convert file path to URL path
function filePathToUrlPath(filePath) {
// Remove 'Views/' prefix
let relativePath = filePath.replace(/^Views[\/\\]/, '');
// Remove .cshtml extension
relativePath = relativePath.replace(/\.cshtml$/, '');
// Convert to URL format (replace \ with / and capitalize first letter)
const urlPath = relativePath
.split(/[\/\\]/)
.map((segment, index) =>
index === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1)
)
.join('/');
// Handle the case where we have a single file (like Index.cshtml)
if (relativePath.includes('/')) {
// Convert to URL path format: Views/Home/Index.cshtml -> /Home/Index
return '/' + relativePath.replace(/\\/g, '/').replace(/\.cshtml$/, '');
} else {
// For files directly in Views folder (like Views/Index.cshtml)
return '/' + relativePath.replace(/\.cshtml$/, '');
}
}
// Get all .cshtml files
const cshtmlFiles = getAllCshtmlFiles(viewsDir);
const criticalCssDir = '.';
// if (!fs.existsSync(criticalCssDir)) {
// fs.mkdirSync(criticalCssDir, { recursive: true });
// }
// Process each file
for (const file of cshtmlFiles) {
try {
const urlPath = filePathToUrlPath(file).replace("../", "").replace("/Views", "");
// Generate critical CSS
await generate({
src: `http://localhost:5146${urlPath}`,
inline: false,
width: 1920,
height: 1080,
penthouse: {
customHeaders: {
cookie: cookies.map(c => `${c.name}=${c.value}`).join('; ')
},
forceExclude: ['.btn'], // Otherwise buttons end up colorless and .btn overrides other classes like .btn-warning, etc. - so it has to be force-excluded here and re-added later
forceInclude: [
'[data-bs-theme=dark]',
'.navbar',
'.col-md-4',
'.visually-hidden', // visually hidden headings
'.bi-info-circle-fill', '.text-info', // info icon
'.container', '.col-md-6', '.row', '.g-4', '.row>*',
'p', '.fs-3', '.py-4', // title
'.mb-4',
'.card', '.card-body', '.p-2', // card
'h2', '.card-title', '.fs-5', // card - title
'.d-flex', '.justify-content-between', '.mt-2', // card - content
'.progress', '.mt-3', // card - progress bar
'.list-group', '.list-group-flush', '.list-group-item', '.list-group-flush>.list-group-item', '.list-group-flush>.list-group-item:last-child', '.badge', '.bg-warning', '.bg-success', '.h-100', // card - health check list
'.btn', '.btn-sm', '.btn-primary', '.btn-warning', '.btn-danger', // Searchdomains buttons
'.col-md-8', '.sidebar',
'.mb-0', '.mb-2', '.align-items-center',
'h3', '.col-md-3', '.col-md-2', '.text-nowrap', '.overflow-auto'
]
},
target: {
css: path.join(criticalCssDir, urlPath.replace(/\//g, '.').replace(/^\./, '').replace("...", "") + '.css')
}
});
console.log(`Critical CSS generated for: ${urlPath}`);
} catch (err) {
console.error(`Error processing ${file}:`, err);
}
}
console.log('All critical CSS files generated!');
}
// Run the function
generateCriticalCSSForViews().catch(console.error);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
# How to use CriticalCSS
1. Install it here
```bash
npm i -D critical
npm install puppeteer
```
2. Run the css generator:
```bash
node CriticalCSSGenerator.js
```

View File

@@ -1,3 +1,4 @@
using AdaptiveExpressions;
using OllamaSharp; using OllamaSharp;
using OllamaSharp.Models; using OllamaSharp.Models;
@@ -27,74 +28,31 @@ public class Datapoint
public static Dictionary<string, float[]> GenerateEmbeddings(string content, List<string> models, AIProvider aIProvider) public static Dictionary<string, float[]> GenerateEmbeddings(string content, List<string> models, AIProvider aIProvider)
{ {
return GenerateEmbeddings(content, models, aIProvider, []); return GenerateEmbeddings(content, models, aIProvider, new());
} }
public static Dictionary<string, float[]> GenerateEmbeddings(List<string> contents, string model, OllamaApiClient ollama, Dictionary<string, Dictionary<string, float[]>> embeddingCache) public static Dictionary<string, float[]> GenerateEmbeddings(string content, List<string> models, AIProvider aIProvider, LRUCache<string, Dictionary<string, float[]>> embeddingCache)
{
Dictionary<string, float[]> retVal = [];
List<string> remainingContents = new List<string>(contents);
for (int i = contents.Count - 1; i >= 0; i--) // Compare against cache and remove accordingly
{
string content = contents[i];
if (embeddingCache.ContainsKey(model) && embeddingCache[model].ContainsKey(content))
{
retVal[content] = embeddingCache[model][content];
remainingContents.RemoveAt(i);
}
}
if (remainingContents.Count == 0)
{
return retVal;
}
EmbedRequest request = new()
{
Model = model,
Input = remainingContents
};
EmbedResponse response = ollama.EmbedAsync(request).Result;
for (int i = 0; i < response.Embeddings.Count; i++)
{
string content = remainingContents.ElementAt(i);
float[] embeddings = response.Embeddings.ElementAt(i);
retVal[content] = embeddings;
if (!embeddingCache.ContainsKey(model))
{
embeddingCache[model] = [];
}
if (!embeddingCache[model].ContainsKey(content))
{
embeddingCache[model][content] = embeddings;
}
}
return retVal;
}
public static Dictionary<string, float[]> GenerateEmbeddings(string content, List<string> models, AIProvider aIProvider, Dictionary<string, Dictionary<string, float[]>> embeddingCache)
{ {
Dictionary<string, float[]> retVal = []; Dictionary<string, float[]> retVal = [];
foreach (string model in models) foreach (string model in models)
{ {
if (embeddingCache.ContainsKey(model) && embeddingCache[model].ContainsKey(content)) bool embeddingCacheHasModel = embeddingCache.TryGet(model, out var embeddingCacheForModel);
if (embeddingCacheHasModel && embeddingCacheForModel.ContainsKey(content))
{ {
retVal[model] = embeddingCache[model][content]; retVal[model] = embeddingCacheForModel[content];
continue; continue;
} }
var response = aIProvider.GenerateEmbeddings(model, [content]); var response = aIProvider.GenerateEmbeddings(model, [content]);
if (response is not null) if (response is not null)
{ {
retVal[model] = response; retVal[model] = response;
if (!embeddingCache.ContainsKey(model)) if (!embeddingCacheHasModel)
{ {
embeddingCache[model] = []; embeddingCacheForModel = [];
} }
if (!embeddingCache[model].ContainsKey(content)) if (!embeddingCacheForModel.ContainsKey(content))
{ {
embeddingCache[model][content] = response; embeddingCacheForModel[content] = response;
} }
} }
} }

View File

@@ -1,7 +1,9 @@
using Shared.Models;
namespace Server.Exceptions; namespace Server.Exceptions;
public class ProbMethodNotFoundException(string probMethod) : Exception($"Unknown probMethod name {probMethod}") { } public class ProbMethodNotFoundException(ProbMethodEnum probMethod) : Exception($"Unknown probMethod name {probMethod}") { }
public class SimilarityMethodNotFoundException(string similarityMethod) : Exception($"Unknown similarityMethod name \"{similarityMethod}\"") { } public class SimilarityMethodNotFoundException(SimilarityMethodEnum similarityMethod) : Exception($"Unknown similarityMethod name \"{similarityMethod}\"") { }
public class JSONPathSelectionException(string path, string testedContent) : Exception($"Unable to select tokens using JSONPath {path} for string: {testedContent}.") { } public class JSONPathSelectionException(string path, string testedContent) : Exception($"Unable to select tokens using JSONPath {path} for string: {testedContent}.") { }

View File

@@ -38,12 +38,12 @@ public class DatabaseHelper(ILogger<DatabaseHelper> logger)
return helper.ExecuteSQLCommandGetInsertedID("INSERT INTO searchdomain (name, settings) VALUES (@name, @settings)", parameters); return helper.ExecuteSQLCommandGetInsertedID("INSERT INTO searchdomain (name, settings) VALUES (@name, @settings)", parameters);
} }
public static int DatabaseInsertEntity(SQLHelper helper, string name, string probmethod, int id_searchdomain) public static int DatabaseInsertEntity(SQLHelper helper, string name, ProbMethodEnum probmethod, int id_searchdomain)
{ {
Dictionary<string, dynamic> parameters = new() Dictionary<string, dynamic> parameters = new()
{ {
{ "name", name }, { "name", name },
{ "probmethod", probmethod }, { "probmethod", probmethod.ToString() },
{ "id_searchdomain", id_searchdomain } { "id_searchdomain", id_searchdomain }
}; };
return helper.ExecuteSQLCommandGetInsertedID("INSERT INTO entity (name, probmethod, id_searchdomain) VALUES (@name, @probmethod, @id_searchdomain)", parameters); return helper.ExecuteSQLCommandGetInsertedID("INSERT INTO entity (name, probmethod, id_searchdomain) VALUES (@name, @probmethod, @id_searchdomain)", parameters);
@@ -60,13 +60,13 @@ public class DatabaseHelper(ILogger<DatabaseHelper> logger)
return helper.ExecuteSQLCommandGetInsertedID("INSERT INTO attribute (attribute, value, id_entity) VALUES (@attribute, @value, @id_entity)", parameters); return helper.ExecuteSQLCommandGetInsertedID("INSERT INTO attribute (attribute, value, id_entity) VALUES (@attribute, @value, @id_entity)", parameters);
} }
public static int DatabaseInsertDatapoint(SQLHelper helper, string name, string probmethod_embedding, string similarityMethod, string hash, int id_entity) public static int DatabaseInsertDatapoint(SQLHelper helper, string name, ProbMethodEnum probmethod_embedding, SimilarityMethodEnum similarityMethod, string hash, int id_entity)
{ {
Dictionary<string, dynamic> parameters = new() Dictionary<string, dynamic> parameters = new()
{ {
{ "name", name }, { "name", name },
{ "probmethod_embedding", probmethod_embedding }, { "probmethod_embedding", probmethod_embedding.ToString() },
{ "similaritymethod", similarityMethod }, { "similaritymethod", similarityMethod.ToString() },
{ "hash", hash }, { "hash", hash },
{ "id_entity", id_entity } { "id_entity", id_entity }
}; };

View File

@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using AdaptiveExpressions;
using Server.Exceptions; using Server.Exceptions;
using Shared.Models; using Shared.Models;
@@ -46,7 +47,7 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
public List<Entity>? EntitiesFromJSON(SearchdomainManager searchdomainManager, ILogger logger, string json) public List<Entity>? EntitiesFromJSON(SearchdomainManager searchdomainManager, ILogger logger, string json)
{ {
Dictionary<string, Dictionary<string, float[]>> embeddingCache = searchdomainManager.embeddingCache; LRUCache<string, Dictionary<string, float[]>> embeddingCache = searchdomainManager.embeddingCache;
AIProvider aIProvider = searchdomainManager.aIProvider; AIProvider aIProvider = searchdomainManager.aIProvider;
SQLHelper helper = searchdomainManager.helper; SQLHelper helper = searchdomainManager.helper;
@@ -91,8 +92,9 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
Searchdomain searchdomain = searchdomainManager.GetSearchdomain(jsonEntity.Searchdomain); Searchdomain searchdomain = searchdomainManager.GetSearchdomain(jsonEntity.Searchdomain);
List<Entity> entityCache = searchdomain.entityCache; List<Entity> entityCache = searchdomain.entityCache;
AIProvider aIProvider = searchdomain.aIProvider; AIProvider aIProvider = searchdomain.aIProvider;
Dictionary<string, Dictionary<string, float[]>> embeddingCache = searchdomain.embeddingCache; LRUCache<string, Dictionary<string, float[]>> embeddingCache = searchdomain.embeddingCache;
Entity? preexistingEntity = entityCache.FirstOrDefault(entity => entity.name == jsonEntity.Name); Entity? preexistingEntity = entityCache.FirstOrDefault(entity => entity.name == jsonEntity.Name);
bool invalidateSearchCache = false;
if (preexistingEntity is not null) if (preexistingEntity is not null)
{ {
@@ -147,8 +149,9 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
} }
// Datapoint // Datapoint
foreach (Datapoint datapoint in preexistingEntity.datapoints.ToList()) foreach (Datapoint datapoint_ in preexistingEntity.datapoints.ToList())
{ {
Datapoint datapoint = datapoint_; // To enable replacing the datapoint reference as foreach iterators cannot be overwritten
bool newEntityHasDatapoint = jsonEntity.Datapoints.Any(x => x.Name == datapoint.name); bool newEntityHasDatapoint = jsonEntity.Datapoints.Any(x => x.Name == datapoint.name);
if (!newEntityHasDatapoint) if (!newEntityHasDatapoint)
{ {
@@ -161,6 +164,7 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
helper.ExecuteSQLNonQuery("DELETE e FROM embedding e JOIN datapoint d ON e.id_datapoint=d.id WHERE d.name=@datapointName AND d.id_entity=@entityId", parameters); helper.ExecuteSQLNonQuery("DELETE e FROM embedding e JOIN datapoint d ON e.id_datapoint=d.id WHERE d.name=@datapointName AND d.id_entity=@entityId", parameters);
helper.ExecuteSQLNonQuery("DELETE FROM datapoint WHERE id_entity=@entityId AND name=@datapointName", parameters); helper.ExecuteSQLNonQuery("DELETE FROM datapoint WHERE id_entity=@entityId AND name=@datapointName", parameters);
preexistingEntity.datapoints.Remove(datapoint); preexistingEntity.datapoints.Remove(datapoint);
invalidateSearchCache = true;
} else } else
{ {
JSONDatapoint? newEntityDatapoint = jsonEntity.Datapoints.FirstOrDefault(x => x.Name == datapoint.name); JSONDatapoint? newEntityDatapoint = jsonEntity.Datapoints.FirstOrDefault(x => x.Name == datapoint.name);
@@ -177,22 +181,24 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
preexistingEntity.datapoints.Remove(datapoint); preexistingEntity.datapoints.Remove(datapoint);
Datapoint newDatapoint = DatabaseInsertDatapointWithEmbeddings(helper, searchdomain, newEntityDatapoint, (int)preexistingEntityID); Datapoint newDatapoint = DatabaseInsertDatapointWithEmbeddings(helper, searchdomain, newEntityDatapoint, (int)preexistingEntityID);
preexistingEntity.datapoints.Add(newDatapoint); preexistingEntity.datapoints.Add(newDatapoint);
datapoint = newDatapoint;
invalidateSearchCache = true;
} }
if (newEntityDatapoint is not null && (newEntityDatapoint.Probmethod_embedding != datapoint.probMethod.name || newEntityDatapoint.SimilarityMethod != datapoint.similarityMethod.name)) if (newEntityDatapoint is not null && (newEntityDatapoint.Probmethod_embedding != datapoint.probMethod.probMethodEnum || newEntityDatapoint.SimilarityMethod != datapoint.similarityMethod.similarityMethodEnum))
{ {
// Datapoint - Updated (probmethod or similaritymethod) // Datapoint - Updated (probmethod or similaritymethod)
Dictionary<string, dynamic> parameters = new() Dictionary<string, dynamic> parameters = new()
{ {
{ "probmethod", newEntityDatapoint.Probmethod_embedding }, { "probmethod", newEntityDatapoint.Probmethod_embedding.ToString() },
{ "similaritymethod", newEntityDatapoint.SimilarityMethod }, { "similaritymethod", newEntityDatapoint.SimilarityMethod.ToString() },
{ "datapointName", datapoint.name }, { "datapointName", datapoint.name },
{ "entityId", preexistingEntityID} { "entityId", preexistingEntityID}
}; };
helper.ExecuteSQLNonQuery("UPDATE datapoint SET probmethod_embedding=@probmethod, similaritymethod=@similaritymethod WHERE id_entity=@entityId AND name=@datapointName", parameters); helper.ExecuteSQLNonQuery("UPDATE datapoint SET probmethod_embedding=@probmethod, similaritymethod=@similaritymethod WHERE id_entity=@entityId AND name=@datapointName", parameters);
Datapoint preexistingDatapoint = preexistingEntity.datapoints.First(x => x == datapoint); // The for loop is a copy. This retrieves the original such that it can be updated. Datapoint preexistingDatapoint = preexistingEntity.datapoints.First(x => x == datapoint); // The for loop is a copy. This retrieves the original such that it can be updated.
preexistingDatapoint.probMethod = datapoint.probMethod; preexistingDatapoint.probMethod = new(newEntityDatapoint.Probmethod_embedding, _logger);
preexistingDatapoint.similarityMethod = datapoint.similarityMethod; preexistingDatapoint.similarityMethod = new(newEntityDatapoint.SimilarityMethod, _logger);
invalidateSearchCache = true;
} }
} }
} }
@@ -204,10 +210,15 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
// Datapoint - New // Datapoint - New
Datapoint datapoint = DatabaseInsertDatapointWithEmbeddings(helper, searchdomain, jsonDatapoint, (int)preexistingEntityID); Datapoint datapoint = DatabaseInsertDatapointWithEmbeddings(helper, searchdomain, jsonDatapoint, (int)preexistingEntityID);
preexistingEntity.datapoints.Add(datapoint); preexistingEntity.datapoints.Add(datapoint);
invalidateSearchCache = true;
} }
} }
if (invalidateSearchCache)
{
searchdomain.ReconciliateOrInvalidateCacheForNewOrUpdatedEntity(preexistingEntity);
}
searchdomain.UpdateModelsInUse();
return preexistingEntity; return preexistingEntity;
} }
else else
@@ -227,11 +238,13 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
} }
var probMethod = Probmethods.GetMethod(jsonEntity.Probmethod) ?? throw new ProbMethodNotFoundException(jsonEntity.Probmethod); var probMethod = Probmethods.GetMethod(jsonEntity.Probmethod) ?? throw new ProbMethodNotFoundException(jsonEntity.Probmethod);
Entity entity = new(jsonEntity.Attributes, probMethod, jsonEntity.Probmethod, datapoints, jsonEntity.Name) Entity entity = new(jsonEntity.Attributes, probMethod, jsonEntity.Probmethod.ToString(), datapoints, jsonEntity.Name)
{ {
id = id_entity id = id_entity
}; };
entityCache.Add(entity); entityCache.Add(entity);
searchdomain.ReconciliateOrInvalidateCacheForNewOrUpdatedEntity(entity);
searchdomain.UpdateModelsInUse();
return entity; return entity;
} }
} }
@@ -261,7 +274,7 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
throw new Exception("jsonDatapoint.Text must not be null at this point"); throw new Exception("jsonDatapoint.Text must not be null at this point");
} }
using SQLHelper helper = searchdomain.helper.DuplicateConnection(); using SQLHelper helper = searchdomain.helper.DuplicateConnection();
Dictionary<string, Dictionary<string, float[]>> embeddingCache = searchdomain.embeddingCache; LRUCache<string, Dictionary<string, float[]>> embeddingCache = searchdomain.embeddingCache;
hash ??= Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(jsonDatapoint.Text))); hash ??= Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(jsonDatapoint.Text)));
DatabaseHelper.DatabaseInsertDatapoint(helper, jsonDatapoint.Name, jsonDatapoint.Probmethod_embedding, jsonDatapoint.SimilarityMethod, hash, entityId); DatabaseHelper.DatabaseInsertDatapoint(helper, jsonDatapoint.Name, jsonDatapoint.Probmethod_embedding, jsonDatapoint.SimilarityMethod, hash, entityId);
Dictionary<string, float[]> embeddings = Datapoint.GenerateEmbeddings(jsonDatapoint.Text, [.. jsonDatapoint.Model], searchdomain.aIProvider, embeddingCache); Dictionary<string, float[]> embeddings = Datapoint.GenerateEmbeddings(jsonDatapoint.Text, [.. jsonDatapoint.Model], searchdomain.aIProvider, embeddingCache);
@@ -269,4 +282,21 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
var similarityMethod = new SimilarityMethod(jsonDatapoint.SimilarityMethod, logger) ?? throw new SimilarityMethodNotFoundException(jsonDatapoint.SimilarityMethod); var similarityMethod = new SimilarityMethod(jsonDatapoint.SimilarityMethod, logger) ?? throw new SimilarityMethodNotFoundException(jsonDatapoint.SimilarityMethod);
return new Datapoint(jsonDatapoint.Name, probMethod_embedding, similarityMethod, hash, [.. embeddings.Select(kv => (kv.Key, kv.Value))]); return new Datapoint(jsonDatapoint.Name, probMethod_embedding, similarityMethod, hash, [.. embeddings.Select(kv => (kv.Key, kv.Value))]);
} }
public static (Searchdomain?, int?, string?) TryGetSearchdomain(SearchdomainManager searchdomainManager, string searchdomain, ILogger logger)
{
try
{
Searchdomain searchdomain_ = searchdomainManager.GetSearchdomain(searchdomain);
return (searchdomain_, null, null);
} catch (SearchdomainNotFoundException)
{
logger.LogError("Unable to update searchdomain {searchdomain} - not found", [searchdomain]);
return (null, 500, $"Unable to update searchdomain {searchdomain} - not found");
} catch (Exception ex)
{
logger.LogError("Unable to update searchdomain {searchdomain} - Exception: {ex.Message} - {ex.StackTrace}", [searchdomain, ex.Message, ex.StackTrace]);
return (null, 404, $"Unable to update searchdomain {searchdomain}");
}
}
} }

View File

@@ -1,13 +0,0 @@
namespace Server.Models;
public class SimpleAuthOptions
{
public List<SimpleUser> Users { get; set; } = new();
}
public class SimpleUser
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string[] Roles { get; set; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,36 @@
using System.Configuration;
using ElmahCore;
using Shared.Models;
namespace Server.Models;
public class EmbeddingSearchOptions : ApiKeyOptions
{
public required ConnectionStringsSection ConnectionStrings { get; set; }
public ElmahOptions? Elmah { get; set; }
public required long EmbeddingCacheMaxCount { get; set; }
public required Dictionary<string, AiProvider> AiProviders { get; set; }
public required SimpleAuthOptions SimpleAuth { get; set; }
public required bool UseHttpsRedirection { get; set; }
}
public class AiProvider
{
public required string Handler { get; set; }
public required string BaseURL { get; set; }
public string? ApiKey { get; set; }
public required string[] Allowlist { get; set; }
public required string[] Denylist { get; set; }
}
public class SimpleAuthOptions
{
public List<SimpleUser> Users { get; set; } = [];
}
public class SimpleUser
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string[] Roles { get; set; } = [];
}

View File

@@ -1,37 +1,29 @@
using System.Text.Json; using System.Text.Json;
using Server.Exceptions; using Server.Exceptions;
using Shared.Models;
namespace Server; namespace Server;
public class ProbMethod public class ProbMethod
{ {
public Probmethods.probMethodDelegate method; public Probmethods.probMethodDelegate method;
public ProbMethodEnum probMethodEnum;
public string name; public string name;
public ProbMethod(string name, ILogger logger) public ProbMethod(ProbMethodEnum probMethodEnum, ILogger logger)
{ {
this.name = name; this.probMethodEnum = probMethodEnum;
this.name = probMethodEnum.ToString();
Probmethods.probMethodDelegate? probMethod = Probmethods.GetMethod(name); Probmethods.probMethodDelegate? probMethod = Probmethods.GetMethod(name);
if (probMethod is null) if (probMethod is null)
{ {
logger.LogError("Unable to retrieve probMethod {name}", [name]); logger.LogError("Unable to retrieve probMethod {name}", [name]);
throw new ProbMethodNotFoundException(name); throw new ProbMethodNotFoundException(probMethodEnum);
} }
method = probMethod; method = probMethod;
} }
} }
public enum ProbMethodEnum
{
Mean,
HarmonicMean,
QuadraticMean,
GeometricMean,
EVEWAvg,
HVEWAvg,
LVEWAvg,
DictionaryWeightedAverage
}
public static class Probmethods public static class Probmethods
{ {
@@ -54,6 +46,11 @@ public static class Probmethods
}; };
} }
public static probMethodDelegate? GetMethod(ProbMethodEnum probMethodEnum)
{
return GetMethod(probMethodEnum.ToString());
}
public static probMethodDelegate? GetMethod(string name) public static probMethodDelegate? GetMethod(string name)
{ {
string methodName = name; string methodName = name;

View File

@@ -8,12 +8,32 @@ using Server.HealthChecks;
using Server.Helper; using Server.Helper;
using Server.Models; using Server.Models;
using Server.Services; using Server.Services;
using System.Text.Json.Serialization;
using System.Reflection;
using System.Configuration;
using Microsoft.OpenApi.Models;
using Shared.Models;
using Microsoft.AspNetCore.ResponseCompression;
using System.Net;
using System.Text;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add Controllers with views & string conversion for enums
builder.Services.AddControllersWithViews()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(
new JsonStringEnumConverter()
);
});
builder.Services.AddControllersWithViews(); // Add Configuration
IConfigurationSection configurationSection = builder.Configuration.GetSection("Embeddingsearch");
EmbeddingSearchOptions configuration = configurationSection.Get<EmbeddingSearchOptions>() ?? throw new ConfigurationErrorsException("Unable to start server due to an invalid configration");
builder.Services.Configure<EmbeddingSearchOptions>(configurationSection);
builder.Services.Configure<ApiKeyOptions>(configurationSection);
// Add Localization // Add Localization
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
@@ -30,7 +50,37 @@ builder.Services.AddScoped<LocalizationService>();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen(c =>
{
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(xmlPath);
if (configuration.ApiKeys is not null)
{
c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme
{
Description = "ApiKey must appear in header",
Type = SecuritySchemeType.ApiKey,
Name = "X-API-KEY",
In = ParameterLocation.Header,
Scheme = "ApiKeyScheme"
});
var key = new OpenApiSecurityScheme()
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "ApiKey"
},
In = ParameterLocation.Header
};
var requirement = new OpenApiSecurityRequirement
{
{ key, []}
};
c.AddSecurityRequirement(requirement);
}
});
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration) .ReadFrom.Configuration(builder.Configuration)
.CreateLogger(); .CreateLogger();
@@ -40,12 +90,17 @@ builder.Services.AddSingleton<SearchdomainHelper>();
builder.Services.AddSingleton<SearchdomainManager>(); builder.Services.AddSingleton<SearchdomainManager>();
builder.Services.AddSingleton<AIProvider>(); builder.Services.AddSingleton<AIProvider>();
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("DatabaseHealthCheck") .AddCheck<DatabaseHealthCheck>("DatabaseHealthCheck", tags: ["Database"])
.AddCheck<AIProviderHealthCheck>("AIProviderHealthCheck"); .AddCheck<AIProviderHealthCheck>("AIProviderHealthCheck", tags: ["AIProvider"]);
builder.Services.AddElmah<XmlFileErrorLog>(Options => builder.Services.AddElmah<XmlFileErrorLog>(Options =>
{ {
Options.LogPath = builder.Configuration.GetValue<string>("Embeddingsearch:Elmah:LogFolder") ?? "~/logs"; Options.OnPermissionCheck = context =>
context.User.Claims.Any(claim =>
claim.Value.Equals("Admin", StringComparison.OrdinalIgnoreCase)
|| claim.Value.Equals("Elmah", StringComparison.OrdinalIgnoreCase)
);
Options.LogPath = configuration.Elmah?.LogPath ?? "~/logs";
}); });
builder.Services builder.Services
@@ -63,29 +118,109 @@ builder.Services.AddAuthorization(options =>
policy => policy.RequireRole("Admin")); policy => policy.RequireRole("Admin"));
}); });
IConfigurationSection simpleAuthSection = builder.Configuration.GetSection("Embeddingsearch:SimpleAuth"); builder.Services.AddResponseCompression(options =>
if (simpleAuthSection.Exists()) builder.Services.Configure<SimpleAuthOptions>(simpleAuthSection); {
options.EnableForHttps = true;
options.Providers.Add<GzipCompressionProvider>();
options.Providers.Add<BrotliCompressionProvider>();
options.MimeTypes =
[
"text/plain",
"text/css",
"application/javascript",
"text/javascript",
"text/html",
"application/xml",
"text/xml",
"application/json",
"image/svg+xml"
];
});
var app = builder.Build(); var app = builder.Build();
List<string>? allowedIps = builder.Configuration.GetSection("Embeddingsearch:Elmah:AllowedHosts") app.UseAuthentication();
.Get<List<string>>(); app.UseAuthorization();
// Configure Elmah
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/elmah"))
{
context.Response.OnStarting(() =>
{
context.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'self' 'unsafe-inline' 'unsafe-eval'"
);
return Task.CompletedTask;
});
}
await next();
});
app.Use(async (context, next) =>
{
if (!context.Request.Path.StartsWithSegments("/elmah"))
{
await next();
return;
}
var originalBody = context.Response.Body;
using var memStream = new MemoryStream();
context.Response.Body = memStream;
await next();
memStream.Position = 0;
var html = await new StreamReader(memStream).ReadToEndAsync();
if (context.Response.ContentType?.Contains("text/html") == true)
{
html = html.Replace(
"</head>",
"""
<link rel="stylesheet" href="/elmah-ui/custom.css" />
<script src="/elmah-ui/custom.js"></script>
</head>
"""
);
}
var bytes = Encoding.UTF8.GetBytes(html);
context.Response.ContentLength = bytes.Length;
await originalBody.WriteAsync(bytes);
context.Response.Body = originalBody;
});
app.UseElmah();
app.MapHealthChecks("/healthz");
app.MapHealthChecks("/healthz/Database", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = c => c.Name.Contains("Database")
});
app.MapHealthChecks("/healthz/AIProvider", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = c => c.Name.Contains("AIProvider")
});
bool IsDevelopment = app.Environment.IsDevelopment();
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
bool requestIsElmah = context.Request.Path.StartsWithSegments("/elmah"); if (context.Request.Path.StartsWithSegments("/swagger"))
bool requestIsSwagger = context.Request.Path.StartsWithSegments("/swagger");
if (requestIsElmah || requestIsSwagger)
{ {
var remoteIp = context.Connection.RemoteIpAddress?.ToString(); if (!context.User.Identity?.IsAuthenticated ?? true)
bool blockRequest = allowedIps is null
|| remoteIp is null
|| !allowedIps.Contains(remoteIp);
if (blockRequest)
{ {
context.Response.StatusCode = 403; context.Response.Redirect($"/Account/Login?ReturnUrl={WebUtility.UrlEncode("/swagger")}");
await context.Response.WriteAsync("Forbidden"); return;
}
if (!context.User.IsInRole("Admin"))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return; return;
} }
} }
@@ -93,26 +228,34 @@ app.Use(async (context, next) =>
await next(); await next();
}); });
app.UseElmah(); app.UseSwagger();
app.UseSwaggerUI(options =>
app.MapHealthChecks("/healthz");
bool IsDevelopment = app.Environment.IsDevelopment();
bool useSwagger = app.Configuration.GetValue<bool>("UseSwagger");
bool? UseMiddleware = app.Configuration.GetValue<bool?>("UseMiddleware");
// Configure the HTTP request pipeline.
if (IsDevelopment || useSwagger)
{ {
app.UseSwagger(); options.EnablePersistAuthorization();
app.UseSwaggerUI(); options.InjectStylesheet("/swagger-ui/custom.css");
//app.UseElmahExceptionPage(); // Messes with JSON response for API calls. Leaving this here so I don't accidentally put this in again later on. options.InjectJavascript("/swagger-ui/custom.js");
} });
if (UseMiddleware == true && !IsDevelopment) //app.UseElmahExceptionPage(); // Messes with JSON response for API calls. Leaving this here so I don't accidentally put this in again later on.
if (configuration.ApiKeys is not null)
{ {
app.UseMiddleware<Shared.ApiKeyMiddleware>(); app.UseWhen(context =>
{
RouteData routeData = context.GetRouteData();
string controllerName = routeData.Values["controller"]?.ToString() ?? "StaticFile";
if (controllerName == "Account" || controllerName == "Home" || controllerName == "StaticFile")
{
return false;
}
return true;
}, appBuilder =>
{
appBuilder.UseMiddleware<Shared.ApiKeyMiddleware>();
});
} }
app.UseResponseCompression();
// Add localization // Add localization
var supportedCultures = new[] { "de", "de-DE", "en-US" }; var supportedCultures = new[] { "de", "de-DE", "en-US" };
var localizationOptions = new RequestLocalizationOptions() var localizationOptions = new RequestLocalizationOptions()
@@ -121,10 +264,23 @@ var localizationOptions = new RequestLocalizationOptions()
.AddSupportedUICultures(supportedCultures); .AddSupportedUICultures(supportedCultures);
app.UseRequestLocalization(localizationOptions); app.UseRequestLocalization(localizationOptions);
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.UseStaticFiles(); app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
string requestPath = ctx.Context.Request.Path.ToString();
string[] cachedSuffixes = [".css", ".js", ".png", ".ico", ".woff2"];
if (cachedSuffixes.Any(suffix => requestPath.EndsWith(suffix)))
{
ctx.Context.Response.GetTypedHeaders().CacheControl =
new Microsoft.Net.Http.Headers.CacheControlHeaderValue()
{
Public = true,
MaxAge = TimeSpan.FromDays(365)
};
}
}
});
app.Run(); app.Run();

View File

@@ -24,4 +24,223 @@
<data name="IrreversibleActionWarning" xml:space="preserve"> <data name="IrreversibleActionWarning" xml:space="preserve">
<value>Diese Aktion kann nicht rückgängig gemacht werden.</value> <value>Diese Aktion kann nicht rückgängig gemacht werden.</value>
</data> </data>
<data name="Searchdomain selection" xml:space="preserve">
<value>Searchdomain Auswahl</value>
</data>
<data name="Create" xml:space="preserve">
<value>Erstellen</value>
</data>
<data name="Searchdomain information and settings" xml:space="preserve">
<value>Searchdomain Informationen und Einstellungen</value>
</data>
<data name="Actions" xml:space="preserve">
<value>Aktionen</value>
</data>
<data name="Rename" xml:space="preserve">
<value>Umbenennen</value>
</data>
<data name="Delete" xml:space="preserve">
<value>Löschen</value>
</data>
<data name="Settings" xml:space="preserve">
<value>Einstellungen</value>
</data>
<data name="Cache reconciliation" xml:space="preserve">
<value>Cache Abgleich</value>
</data>
<data name="Update" xml:space="preserve">
<value>Anpassen</value>
</data>
<data name="Search cache" xml:space="preserve">
<value>Such-Cache</value>
</data>
<data name="Search cache utilization" xml:space="preserve">
<value>Such-Cache Speicherauslastung</value>
</data>
<data name="Clear" xml:space="preserve">
<value>Leeren</value>
</data>
<data name="Database size" xml:space="preserve">
<value>Größe in der Datenbank</value>
</data>
<data name="Add new entity" xml:space="preserve">
<value>Neue Entity erstellen</value>
</data>
<data name="Entity Details" xml:space="preserve">
<value>Entity Details</value>
</data>
<data name="Attributes" xml:space="preserve">
<value>Attribute</value>
</data>
<data name="Key" xml:space="preserve">
<value>Schlüssel</value>
</data>
<data name="Value" xml:space="preserve">
<value>Wert</value>
</data>
<data name="Datapoints" xml:space="preserve">
<value>Datapoints</value>
</data>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="ProbMethod" xml:space="preserve">
<value>ProbMethod</value>
</data>
<data name="SimilarityMethod" xml:space="preserve">
<value>SimilarityMethod</value>
</data>
<data name="Close" xml:space="preserve">
<value>Schließen</value>
</data>
<data name="Query Details" xml:space="preserve">
<value>Suchanfrage Details</value>
</data>
<data name="Access times" xml:space="preserve">
<value>Zugriffszeiten</value>
</data>
<data name="Results" xml:space="preserve">
<value>Ergebnisse</value>
</data>
<data name="Score" xml:space="preserve">
<value>Bewertung</value>
</data>
<data name="Query Update" xml:space="preserve">
<value>Suchanfrage anpassen</value>
</data>
<data name="Rename searchdomain" xml:space="preserve">
<value>Searchdomain umbenennen</value>
</data>
<data name="Delete searchdomain" xml:space="preserve">
<value>Searchdomain löschen</value>
</data>
<data name="Create searchdomain" xml:space="preserve">
<value>Searchdomain anlegen</value>
</data>
<data name="Searchdomain name" xml:space="preserve">
<value>Searchdomain Name</value>
</data>
<data name="Enable cache reconciliation" xml:space="preserve">
<value>Cache Abgleich verwenden</value>
</data>
<data name="Create entity" xml:space="preserve">
<value>Entity erstellen</value>
</data>
<data name="Entity name" xml:space="preserve">
<value>Entity Name</value>
</data>
<data name="Probmethod" xml:space="preserve">
<value>Probmethod</value>
</data>
<data name="Add attribute" xml:space="preserve">
<value>Attribut hinzufügen</value>
</data>
<data name="Probmethod_embedding" xml:space="preserve">
<value>Probmethod_embedding</value>
</data>
<data name="Similarity method" xml:space="preserve">
<value>Similarity method</value>
</data>
<data name="Model" xml:space="preserve">
<value>Modell</value>
</data>
<data name="Add datapoint" xml:space="preserve">
<value>Datapoint hinzufügen</value>
</data>
<data name="Delete entity" xml:space="preserve">
<value>Entity löschen</value>
</data>
<data name="Update entity" xml:space="preserve">
<value>Entity anpassen</value>
</data>
<data name="Action" xml:space="preserve">
<value>Aktion</value>
</data>
<data name="Delete query" xml:space="preserve">
<value>Suchanfrage löschen</value>
</data>
<data name="Creating entity" xml:space="preserve">
<value>Erstelle Entity</value>
</data>
<data name="Entity was created successfully" xml:space="preserve">
<value>Entity wurde erfolgreich erstellt</value>
</data>
<data name="Failed to create entity" xml:space="preserve">
<value>Entity konnte nicht erstellt werden</value>
</data>
<data name="Searchdomain was created successfully" xml:space="preserve">
<value>Searchdomain wurde erfolgreich erstellt</value>
</data>
<data name="Failed to create searchdomain" xml:space="preserve">
<value>Searchdomain konnte nicht erstellt werden</value>
</data>
<data name="Searchdomain cache was cleared successfully" xml:space="preserve">
<value>Searchdomain Cache wurde erfolgreich geleert</value>
</data>
<data name="Failed to clear searchdomain cache" xml:space="preserve">
<value>Searchdomain Cache konnte nicht geleert werden</value>
</data>
<data name="Entity was deleted successfully" xml:space="preserve">
<value>Entity wurde erfolgreich gelöscht</value>
</data>
<data name="Failed to delete entity" xml:space="preserve">
<value>Entity konnte nicht gelöscht werden</value>
</data>
<data name="Updating entity" xml:space="preserve">
<value>Entity wird angepasst</value>
</data>
<data name="Entity was updated successfully" xml:space="preserve">
<value>Entity wurde erfolgreich angepasst</value>
</data>
<data name="Failed to update entity" xml:space="preserve">
<value>Entity konnte nicht angepasst werden</value>
</data>
<data name="Search query was deleted successfully" xml:space="preserve">
<value>Suchanfrage wurde erfolgreich gelöscht</value>
</data>
<data name="Failed to delete search query" xml:space="preserve">
<value>Suchanfrage konnte nicht gelöscht werden</value>
</data>
<data name="Searchdomain was created successfully" xml:space="preserve">
<value>Searchdomain wurde erfolgreich erstellt</value>
</data>
<data name="Updating search query failed" xml:space="preserve">
<value>Suchanfrage konnte nicht angepasst werden</value>
</data>
<data name="Searchdomain was deleted successfully" xml:space="preserve">
<value>Searchdomain wurde erfolgreich gelöscht</value>
</data>
<data name="Failed to delete searchdomain" xml:space="preserve">
<value>Konnte Searchdomain nicht löschen</value>
</data>
<data name="Searchdomain was renamed successfully" xml:space="preserve">
<value>Searchdomain wurde erfolgreich umbenannt</value>
</data>
<data name="Failed to rename searchdomain" xml:space="preserve">
<value>Searchdomain konnte nicht umbenannt werden</value>
</data>
<data name="Searchdomain settings were updated successfully" xml:space="preserve">
<value>Searchdomain Einstellungen wurden erfolgreich angepasst</value>
</data>
<data name="Updating searchdomain settings failed" xml:space="preserve">
<value>Searchdomain Einstellungen konnten nicht angepasst werden</value>
</data>
<data name="Unable to fetch searchdomain config" xml:space="preserve">
<value>Searchdomain Einstellungen konnten nicht abgerufen werden</value>
</data>
<data name="Unable to fetch searchdomain cache utilization" xml:space="preserve">
<value>Searchdomain Cache-Auslastung konnte nicht abgerufen werden</value>
</data>
<data name="Details" xml:space="preserve">
<value>Details</value>
</data>
<data name="Remove attribute" xml:space="preserve">
<value>Attribut entfernen</value>
</data>
<data name="Remove" xml:space="preserve">
<value>Entfernen</value>
</data>
<data name="Close alert" xml:space="preserve">
<value>Benachrichtigung schließen</value>
</data>
</root> </root>

View File

@@ -24,4 +24,223 @@
<data name="IrreversibleActionWarning" xml:space="preserve"> <data name="IrreversibleActionWarning" xml:space="preserve">
<value>This action cannot be undone.</value> <value>This action cannot be undone.</value>
</data> </data>
<data name="Searchdomain selection" xml:space="preserve">
<value>Searchdomain selection</value>
</data>
<data name="Create" xml:space="preserve">
<value>Create</value>
</data>
<data name="Searchdomain information and settings" xml:space="preserve">
<value>Searchdomain information and settings</value>
</data>
<data name="Actions" xml:space="preserve">
<value>Actions</value>
</data>
<data name="Rename" xml:space="preserve">
<value>Rename</value>
</data>
<data name="Delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="Settings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="Cache reconciliation" xml:space="preserve">
<value>Cache reconciliation</value>
</data>
<data name="Update" xml:space="preserve">
<value>Update</value>
</data>
<data name="Search cache" xml:space="preserve">
<value>Search cache</value>
</data>
<data name="Search cache utilization" xml:space="preserve">
<value>Search cache utilization</value>
</data>
<data name="Clear" xml:space="preserve">
<value>Clear</value>
</data>
<data name="Database size" xml:space="preserve">
<value>Database size</value>
</data>
<data name="Add new entity" xml:space="preserve">
<value>Add new entity</value>
</data>
<data name="Entity Details" xml:space="preserve">
<value>Entity Details</value>
</data>
<data name="Attributes" xml:space="preserve">
<value>Attributes</value>
</data>
<data name="Key" xml:space="preserve">
<value>Key</value>
</data>
<data name="Value" xml:space="preserve">
<value>Value</value>
</data>
<data name="Datapoints" xml:space="preserve">
<value>Datapoints</value>
</data>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="ProbMethod" xml:space="preserve">
<value>ProbMethod</value>
</data>
<data name="SimilarityMethod" xml:space="preserve">
<value>SimilarityMethod</value>
</data>
<data name="Close" xml:space="preserve">
<value>Close</value>
</data>
<data name="Query Details" xml:space="preserve">
<value>Query Details</value>
</data>
<data name="Access times" xml:space="preserve">
<value>Access times</value>
</data>
<data name="Results" xml:space="preserve">
<value>Results</value>
</data>
<data name="Score" xml:space="preserve">
<value>Score</value>
</data>
<data name="Query Update" xml:space="preserve">
<value>Query Update</value>
</data>
<data name="Rename searchdomain" xml:space="preserve">
<value>Rename searchdomain</value>
</data>
<data name="Delete searchdomain" xml:space="preserve">
<value>Delete searchdomain</value>
</data>
<data name="Create searchdomain" xml:space="preserve">
<value>Create searchdomain</value>
</data>
<data name="Searchdomain name" xml:space="preserve">
<value>Searchdomain name</value>
</data>
<data name="Enable cache reconciliation" xml:space="preserve">
<value>Enable cache reconciliation</value>
</data>
<data name="Create entity" xml:space="preserve">
<value>Create entity</value>
</data>
<data name="Entity name" xml:space="preserve">
<value>Entity name</value>
</data>
<data name="Probmethod" xml:space="preserve">
<value>Probmethod</value>
</data>
<data name="Add attribute" xml:space="preserve">
<value>Add attribute</value>
</data>
<data name="Probmethod_embedding" xml:space="preserve">
<value>Probmethod_embedding</value>
</data>
<data name="Similarity method" xml:space="preserve">
<value>Similarity method</value>
</data>
<data name="Model" xml:space="preserve">
<value>Model</value>
</data>
<data name="Add datapoint" xml:space="preserve">
<value>Add datapoint</value>
</data>
<data name="Delete entity" xml:space="preserve">
<value>Delete entity</value>
</data>
<data name="Update entity" xml:space="preserve">
<value>Update entity</value>
</data>
<data name="Action" xml:space="preserve">
<value>Action</value>
</data>
<data name="Delete query" xml:space="preserve">
<value>Delete query</value>
</data>
<data name="Creating entity" xml:space="preserve">
<value>Creating entity</value>
</data>
<data name="Entity was created successfully" xml:space="preserve">
<value>Entity was created successfully</value>
</data>
<data name="Failed to create entity" xml:space="preserve">
<value>Failed to create entity</value>
</data>
<data name="Searchdomain was created successfully" xml:space="preserve">
<value>Searchdomain was created successfully</value>
</data>
<data name="Failed to create searchdomain" xml:space="preserve">
<value>Failed to create searchdomain</value>
</data>
<data name="Searchdomain cache was cleared successfully" xml:space="preserve">
<value>Searchdomain cache was cleared successfully</value>
</data>
<data name="Failed to clear searchdomain cache" xml:space="preserve">
<value>Failed to clear searchdomain cache</value>
</data>
<data name="Entity was deleted successfully" xml:space="preserve">
<value>Entity was deleted successfully</value>
</data>
<data name="Failed to delete entity" xml:space="preserve">
<value>Failed to delete entity</value>
</data>
<data name="Updating entity" xml:space="preserve">
<value>Updating entity</value>
</data>
<data name="Entity was updated successfully" xml:space="preserve">
<value>Entity was updated successfully</value>
</data>
<data name="Failed to update entity" xml:space="preserve">
<value>Failed to update entity</value>
</data>
<data name="Search query was deleted successfully" xml:space="preserve">
<value>Search query was deleted successfully</value>
</data>
<data name="Failed to delete search query" xml:space="preserve">
<value>Failed to delete search query</value>
</data>
<data name="Searchdomain was created successfully" xml:space="preserve">
<value>Searchdomain was created successfully</value>
</data>
<data name="Updating search query failed" xml:space="preserve">
<value>Updating search query failed</value>
</data>
<data name="Searchdomain was deleted successfully" xml:space="preserve">
<value>Searchdomain was deleted successfully</value>
</data>
<data name="Failed to delete searchdomain" xml:space="preserve">
<value>Failed to delete searchdomain</value>
</data>
<data name="Searchdomain was renamed successfully" xml:space="preserve">
<value>Searchdomain was renamed successfully</value>
</data>
<data name="Failed to rename searchdomain" xml:space="preserve">
<value>Failed to rename searchdomain</value>
</data>
<data name="Searchdomain settings were updated successfully" xml:space="preserve">
<value>Searchdomain settings were updated successfully</value>
</data>
<data name="Updating searchdomain settings failed" xml:space="preserve">
<value>Updating searchdomain settings failed</value>
</data>
<data name="Unable to fetch searchdomain config" xml:space="preserve">
<value>Unable to fetch searchdomain config</value>
</data>
<data name="Unable to fetch searchdomain cache utilization" xml:space="preserve">
<value>"Unable to fetch searchdomain cache utilization</value>
</data>
<data name="Details" xml:space="preserve">
<value>Details</value>
</data>
<data name="Remove attribute" xml:space="preserve">
<value>Remove attribute</value>
</data>
<data name="Remove" xml:space="preserve">
<value>Remove</value>
</data>
<data name="Close alert" xml:space="preserve">
<value>Close alert</value>
</data>
</root> </root>

View File

@@ -5,6 +5,7 @@ using ElmahCore.Mvc.Logger;
using MySql.Data.MySqlClient; using MySql.Data.MySqlClient;
using Server.Helper; using Server.Helper;
using Shared.Models; using Shared.Models;
using AdaptiveExpressions;
namespace Server; namespace Server;
@@ -19,13 +20,12 @@ public class Searchdomain
public Dictionary<string, DateTimedSearchResult> searchCache; // Key: query, Value: Search results for that query (with timestamp) public Dictionary<string, DateTimedSearchResult> searchCache; // Key: query, Value: Search results for that query (with timestamp)
public List<Entity> entityCache; public List<Entity> entityCache;
public List<string> modelsInUse; public List<string> modelsInUse;
public Dictionary<string, Dictionary<string, float[]>> embeddingCache; public LRUCache<string, Dictionary<string, float[]>> embeddingCache;
public int embeddingCacheMaxSize = 10000000;
private readonly MySqlConnection connection; private readonly MySqlConnection connection;
public SQLHelper helper; public SQLHelper helper;
private readonly ILogger _logger; private readonly ILogger _logger;
public Searchdomain(string searchdomain, string connectionString, AIProvider aIProvider, Dictionary<string, Dictionary<string, float[]>> embeddingCache, ILogger logger, string provider = "sqlserver", bool runEmpty = false) public Searchdomain(string searchdomain, string connectionString, AIProvider aIProvider, LRUCache<string, Dictionary<string, float[]>> embeddingCache, ILogger logger, string provider = "sqlserver", bool runEmpty = false)
{ {
_connectionString = connectionString; _connectionString = connectionString;
_provider = provider.ToLower(); _provider = provider.ToLower();
@@ -96,8 +96,16 @@ public class Searchdomain
string probmethodString = datapointReader.GetString(3); string probmethodString = datapointReader.GetString(3);
string similarityMethodString = datapointReader.GetString(4); string similarityMethodString = datapointReader.GetString(4);
string hash = datapointReader.GetString(5); string hash = datapointReader.GetString(5);
ProbMethod probmethod = new(probmethodString, _logger); ProbMethodEnum probmethodEnum = (ProbMethodEnum)Enum.Parse(
SimilarityMethod similarityMethod = new(similarityMethodString, _logger); typeof(ProbMethodEnum),
probmethodString
);
SimilarityMethodEnum similairtyMethodEnum = (SimilarityMethodEnum)Enum.Parse(
typeof(SimilarityMethodEnum),
similarityMethodString
);
ProbMethod probmethod = new(probmethodEnum, _logger);
SimilarityMethod similarityMethod = new(similairtyMethodEnum, _logger);
if (embedding_unassigned.TryGetValue(id, out Dictionary<string, float[]>? embeddings) && probmethod is not null) if (embedding_unassigned.TryGetValue(id, out Dictionary<string, float[]>? embeddings) && probmethod is not null)
{ {
embedding_unassigned.Remove(id); embedding_unassigned.Remove(id);
@@ -151,7 +159,6 @@ public class Searchdomain
} }
entityReader.Close(); entityReader.Close();
modelsInUse = GetModels(entityCache); modelsInUse = GetModels(entityCache);
embeddingCache = []; // TODO remove this and implement proper remediation to improve performance
} }
public List<(float, string)> Search(string query, int? topN = null) public List<(float, string)> Search(string query, int? topN = null)
@@ -162,33 +169,13 @@ public class Searchdomain
return [.. cachedResult.Results.Select(r => (r.Score, r.Name))]; return [.. cachedResult.Results.Select(r => (r.Score, r.Name))];
} }
if (!embeddingCache.TryGetValue(query, out Dictionary<string, float[]>? queryEmbeddings)) Dictionary<string, float[]> queryEmbeddings = GetQueryEmbeddings(query);
{
queryEmbeddings = Datapoint.GenerateEmbeddings(query, modelsInUse, aIProvider);
if (embeddingCache.Count < embeddingCacheMaxSize) // TODO add better way of managing cache limit hits
{ // Idea: Add access count to each entry. On limit hit, sort the entries by access count and remove the bottom 10% of entries
embeddingCache.Add(query, queryEmbeddings);
}
} // TODO implement proper cache remediation for embeddingCache here
List<(float, string)> result = []; List<(float, string)> result = [];
foreach (Entity entity in entityCache) foreach (Entity entity in entityCache)
{ {
List<(string, float)> datapointProbs = []; result.Add((EvaluateEntityAgainstQueryEmbeddings(entity, queryEmbeddings), entity.name));
foreach (Datapoint datapoint in entity.datapoints)
{
SimilarityMethod similarityMethod = datapoint.similarityMethod;
List<(string, float)> list = [];
foreach ((string, float[]) embedding in datapoint.embeddings)
{
string key = embedding.Item1;
float value = similarityMethod.method(queryEmbeddings[embedding.Item1], embedding.Item2);
list.Add((key, value));
}
datapointProbs.Add((datapoint.name, datapoint.probMethod.method(list)));
}
result.Add((entity.probMethod(datapointProbs), entity.name));
} }
IEnumerable<(float, string)> sortedResults = result.OrderByDescending(s => s.Item1); IEnumerable<(float, string)> sortedResults = result.OrderByDescending(s => s.Item1);
if (topN is not null) if (topN is not null)
@@ -204,19 +191,70 @@ public class Searchdomain
return results; return results;
} }
public Dictionary<string, float[]> GetQueryEmbeddings(string query)
{
bool hasQuery = embeddingCache.TryGet(query, out Dictionary<string, float[]> queryEmbeddings);
bool allModelsInQuery = queryEmbeddings is not null && modelsInUse.All(model => queryEmbeddings.ContainsKey(model));
if (!(hasQuery && allModelsInQuery) || queryEmbeddings is null)
{
queryEmbeddings = Datapoint.GenerateEmbeddings(query, modelsInUse, aIProvider, embeddingCache);
if (!embeddingCache.TryGet(query, out var embeddingCacheForCurrentQuery))
{
embeddingCache.Set(query, queryEmbeddings);
}
else // embeddingCache already has an entry for this query, so the missing model-embedding pairs have to be filled in
{
foreach (KeyValuePair<string, float[]> kvp in queryEmbeddings) // kvp.Key = model, kvp.Value = embedding
{
if (!embeddingCache.TryGet(kvp.Key, out var _))
{
embeddingCacheForCurrentQuery[kvp.Key] = kvp.Value;
}
}
}
}
return queryEmbeddings;
}
public void UpdateModelsInUse()
{
modelsInUse = GetModels([.. entityCache]);
}
private static float EvaluateEntityAgainstQueryEmbeddings(Entity entity, Dictionary<string, float[]> queryEmbeddings)
{
List<(string, float)> datapointProbs = [];
foreach (Datapoint datapoint in entity.datapoints)
{
SimilarityMethod similarityMethod = datapoint.similarityMethod;
List<(string, float)> list = [];
foreach ((string, float[]) embedding in datapoint.embeddings)
{
string key = embedding.Item1;
float value = similarityMethod.method(queryEmbeddings[embedding.Item1], embedding.Item2);
list.Add((key, value));
}
datapointProbs.Add((datapoint.name, datapoint.probMethod.method(list)));
}
return entity.probMethod(datapointProbs);
}
public static List<string> GetModels(List<Entity> entities) public static List<string> GetModels(List<Entity> entities)
{ {
List<string> result = []; List<string> result = [];
foreach (Entity entity in entities) lock (entities)
{ {
foreach (Datapoint datapoint in entity.datapoints) foreach (Entity entity in entities)
{ {
foreach ((string, float[]) tuple in datapoint.embeddings) foreach (Datapoint datapoint in entity.datapoints)
{ {
string model = tuple.Item1; foreach ((string, float[]) tuple in datapoint.embeddings)
if (!result.Contains(model))
{ {
result.Add(model); string model = tuple.Item1;
if (!result.Contains(model))
{
result.Add(model);
}
} }
} }
} }
@@ -250,6 +288,53 @@ public class Searchdomain
return JsonSerializer.Deserialize<SearchdomainSettings>(settingsString); return JsonSerializer.Deserialize<SearchdomainSettings>(settingsString);
} }
public void ReconciliateOrInvalidateCacheForNewOrUpdatedEntity(Entity entity)
{
if (settings.CacheReconciliation)
{
foreach (KeyValuePair<string, DateTimedSearchResult> element in searchCache)
{
string query = element.Key;
DateTimedSearchResult searchResult = element.Value;
Dictionary<string, float[]> queryEmbeddings = GetQueryEmbeddings(query);
float evaluationResult = EvaluateEntityAgainstQueryEmbeddings(entity, queryEmbeddings);
searchResult.Results.RemoveAll(x => x.Name == entity.name); // If entity already exists in that results list: remove it.
ResultItem newItem = new(evaluationResult, entity.name);
int index = searchResult.Results.BinarySearch(
newItem,
Comparer<ResultItem>.Create((a, b) => b.Score.CompareTo(a.Score)) // Invert searching order
);
if (index < 0) // If not found, BinarySearch gives the bitwise complement
index = ~index;
searchResult.Results.Insert(index, newItem);
}
}
else
{
InvalidateSearchCache();
}
}
public void ReconciliateOrInvalidateCacheForDeletedEntity(Entity entity)
{
if (settings.CacheReconciliation)
{
foreach (KeyValuePair<string, DateTimedSearchResult> element in searchCache)
{
string query = element.Key;
DateTimedSearchResult searchResult = element.Value;
searchResult.Results.RemoveAll(x => x.Name == entity.name);
}
}
else
{
InvalidateSearchCache();
}
}
public void InvalidateSearchCache() public void InvalidateSearchCache()
{ {
searchCache = []; searchCache = [];

View File

@@ -3,6 +3,9 @@ using System.Data.Common;
using Server.Migrations; using Server.Migrations;
using Server.Helper; using Server.Helper;
using Server.Exceptions; using Server.Exceptions;
using AdaptiveExpressions;
using Shared.Models;
using System.Text.Json;
namespace Server; namespace Server;
@@ -16,7 +19,8 @@ public class SearchdomainManager
private readonly string connectionString; private readonly string connectionString;
private MySqlConnection connection; private MySqlConnection connection;
public SQLHelper helper; public SQLHelper helper;
public Dictionary<string, Dictionary<string, float[]>> embeddingCache; public LRUCache<string, Dictionary<string, float[]>> embeddingCache;
public int EmbeddingCacheMaxCount;
public SearchdomainManager(ILogger<SearchdomainManager> logger, IConfiguration config, AIProvider aIProvider, DatabaseHelper databaseHelper) public SearchdomainManager(ILogger<SearchdomainManager> logger, IConfiguration config, AIProvider aIProvider, DatabaseHelper databaseHelper)
{ {
@@ -24,7 +28,8 @@ public class SearchdomainManager
_config = config; _config = config;
this.aIProvider = aIProvider; this.aIProvider = aIProvider;
_databaseHelper = databaseHelper; _databaseHelper = databaseHelper;
embeddingCache = []; EmbeddingCacheMaxCount = config.GetValue<int?>("Embeddingsearch:EmbeddingCacheMaxCount") ?? 1000000;
embeddingCache = new(EmbeddingCacheMaxCount);
connectionString = _config.GetSection("Embeddingsearch").GetConnectionString("SQL") ?? ""; connectionString = _config.GetSection("Embeddingsearch").GetConnectionString("SQL") ?? "";
connection = new MySqlConnection(connectionString); connection = new MySqlConnection(connectionString);
connection.Open(); connection.Open();
@@ -66,7 +71,7 @@ public class SearchdomainManager
{ {
var searchdomain = GetSearchdomain(searchdomainName); var searchdomain = GetSearchdomain(searchdomainName);
searchdomain.UpdateEntityCache(); searchdomain.UpdateEntityCache();
searchdomain.InvalidateSearchCache(); // TODO implement cache remediation (Suggestion: searchdomain-wide setting for cache remediation / invalidation - ) searchdomain.InvalidateSearchCache();
} }
public List<string> ListSearchdomains() public List<string> ListSearchdomains()
@@ -84,6 +89,10 @@ public class SearchdomainManager
} }
} }
public int CreateSearchdomain(string searchdomain, SearchdomainSettings settings)
{
return CreateSearchdomain(searchdomain, JsonSerializer.Serialize(settings));
}
public int CreateSearchdomain(string searchdomain, string settings = "{}") public int CreateSearchdomain(string searchdomain, string settings = "{}")
{ {
if (searchdomains.TryGetValue(searchdomain, out Searchdomain? value)) if (searchdomains.TryGetValue(searchdomain, out Searchdomain? value))

View File

@@ -6,7 +6,13 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AdaptiveExpressions" Version="4.23.0" />
<PackageReference Include="ElmahCore" Version="2.1.2" /> <PackageReference Include="ElmahCore" Version="2.1.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />

View File

@@ -1,16 +1,18 @@
using System.Numerics.Tensors; using System.Numerics.Tensors;
using System.Text.Json; using Shared.Models;
namespace Server; namespace Server;
public class SimilarityMethod public class SimilarityMethod
{ {
public SimilarityMethods.similarityMethodDelegate method; public SimilarityMethods.similarityMethodDelegate method;
public SimilarityMethodEnum similarityMethodEnum;
public string name; public string name;
public SimilarityMethod(string name, ILogger logger) public SimilarityMethod(SimilarityMethodEnum similarityMethodEnum, ILogger logger)
{ {
this.name = name; this.similarityMethodEnum = similarityMethodEnum;
this.name = similarityMethodEnum.ToString();
SimilarityMethods.similarityMethodDelegate? probMethod = SimilarityMethods.GetMethod(name); SimilarityMethods.similarityMethodDelegate? probMethod = SimilarityMethods.GetMethod(name);
if (probMethod is null) if (probMethod is null)
{ {
@@ -21,14 +23,6 @@ public class SimilarityMethod
} }
} }
public enum SimilarityMethodEnum
{
Cosine,
Euclidian,
Manhattan,
Pearson
}
public static class SimilarityMethods public static class SimilarityMethods
{ {
public delegate float similarityMethodProtoDelegate(float[] vector1, float[] vector2); public delegate float similarityMethodProtoDelegate(float[] vector1, float[] vector2);

View File

@@ -1,3 +1,4 @@
@using Microsoft.Extensions.Primitives
@using Server.Services @using Server.Services
@inject LocalizationService T @inject LocalizationService T
@{ @{
@@ -9,6 +10,10 @@
<h1>Login</h1> <h1>Login</h1>
<form asp-action="Login" method="post" class="mt-4" style="max-width: 400px; margin: auto;"> <form asp-action="Login" method="post" class="mt-4" style="max-width: 400px; margin: auto;">
<div class="form-group mb-3"> <div class="form-group mb-3">
@if (Context.Request.Query.TryGetValue("ReturnUrl", out StringValues returnUrl))
{
<input type="hidden" name="ReturnUrl" value="@(returnUrl)" />
}
<label for="username" class="form-label">@T["Username"]</label> <label for="username" class="form-label">@T["Username"]</label>
<input autofocus type="text" class="form-control" id="username" name="username" autocomplete="username" required> <input autofocus type="text" class="form-control" id="username" name="username" autocomplete="username" required>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,42 @@
@using Server.Services @using System.Globalization
@using Server.Services
@using System.Net
@inject LocalizationService T @inject LocalizationService T
@{
var currentUrl = WebUtility.HtmlEncode(Context.Request.Path);
}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="description" content="Embeddingsearch server" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - embeddingsearch</title> <title>@ViewData["Title"] - embeddingsearch</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> @if (!Context.Request.Query.ContainsKey("renderRaw"))
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> {
<link rel="preload" href="~/lib/bootstrap/dist/css/bootstrap.min.css" as="style"/>
<link rel="stylesheet" fetchpriority="high"
href="~/lib/bootstrap/dist/css/bootstrap.min.css"
media="print"
onload="this.media='all'">
}
<style>
@Html.Raw(File.ReadAllText(System.IO.Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "css", "site.css")))
</style>
@if (!Context.Request.Query.ContainsKey("noCriticalCSS"))
{
<style>
@if (Context.Request.Path.Value is not null)
{
string path = System.IO.Path.Combine("CriticalCSS", Context.Request.Path.Value.Trim('/').Replace("/", ".") + ".css");
Console.WriteLine(path);
if (File.Exists(path))
{
@Html.Raw(File.ReadAllText(path));
}
}
</style>
}
<script> <script>
window.appTranslations = { window.appTranslations = {
closeAlert: '@T["Close alert"]' closeAlert: '@T["Close alert"]'
@@ -29,16 +57,31 @@
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">@T["Home"]</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Account" asp-action="Logout">Logout</a> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Searchdomains">@T["Searchdomains"]</a>
</li>
@if (User.IsInRole("Admin") || User.IsInRole("Swagger"))
{
<li class="nav-item">
<a class="nav-link text-dark" href="/swagger/index.html?ReturnUrl=@(currentUrl)">@T["Swagger"]</a>
</li>
}
@if (User.IsInRole("Admin"))
{
<li class="nav-item">
<a class="nav-link text-dark" href="/elmah?ReturnUrl=@(currentUrl)">@T["Elmah"]</a>
</li>
}
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Account" asp-action="Logout">@T["Logout"]</a>
</li> </li>
} }
else else
{ {
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Account" asp-action="Login">Login</a> <a class="nav-link text-dark" asp-area="" asp-controller="Account" asp-action="Login">@T["Login"]</a>
</li> </li>
} }
</ul> </ul>
@@ -54,12 +97,12 @@
<footer class="border-top footer text-muted"> <footer class="border-top footer text-muted">
<div class="container"> <div class="container">
&copy; 2025 - embeddingsearch - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> &copy; 2025 - embeddingsearch
</div> </div>
</footer> </footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="~/lib/jquery/dist/jquery.min.js" defer></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="~/js/site.js" asp-append-version="true"></script> <script src="~/js/site.js" asp-append-version="true" defer></script>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
</body> </body>
</html> </html>

View File

@@ -18,23 +18,33 @@
"SQL": "server=localhost;database=embeddingsearch;uid=embeddingsearch;pwd=somepassword!;" "SQL": "server=localhost;database=embeddingsearch;uid=embeddingsearch;pwd=somepassword!;"
}, },
"Elmah": { "Elmah": {
"AllowedHosts": [ "LogPath": "~/logs"
"127.0.0.1",
"::1",
"172.17.0.1"
]
}, },
"EmbeddingCacheMaxCount": 10000000,
"AiProviders": { "AiProviders": {
"ollama": { "ollama": {
"handler": "ollama", "handler": "ollama",
"baseURL": "http://localhost:11434" "baseURL": "http://localhost:11434",
"Allowlist": [".*"],
"Denylist": ["qwen3-coder:latest", "qwen3:0.6b", "deepseek-v3.1:671b-cloud", "qwen3-vl", "deepseek-ocr"]
}, },
"localAI": { "localAI": {
"handler": "openai", "handler": "openai",
"baseURL": "http://localhost:8080", "baseURL": "http://localhost:8080",
"ApiKey": "Some API key here" "ApiKey": "Some API key here",
"Allowlist": [".*"],
"Denylist": ["cross-encoder", "kitten-tts", "jina-reranker-v1-tiny-en", "whisper-small", "qwen3-vl-2b-instruct"]
} }
}, },
"SimpleAuth": {
"Users": [
{
"Username": "admin",
"Password": "UnsafePractice.67",
"Roles": ["Admin"]
}
]
},
"ApiKeys": ["Some UUID here", "Another UUID here"], "ApiKeys": ["Some UUID here", "Another UUID here"],
"UseHttpsRedirection": true "UseHttpsRedirection": true
} }

View File

@@ -16,14 +16,5 @@
"Application": "Embeddingsearch.Server" "Application": "Embeddingsearch.Server"
} }
}, },
"EmbeddingsearchIndexer": {
"Elmah": {
"AllowedHosts": [
"127.0.0.1",
"::1"
],
"LogFolder": "./logs"
}
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@@ -49,3 +49,29 @@ body {
.modal-title { .modal-title {
font-size: 1.25rem; font-size: 1.25rem;
} }
/* Bootstrap icons */
@font-face {
font-display: block;
font-family: "bootstrap-icons";
src: url("/fonts/bootstrap-icons.woff2") format("woff2"),
url("/fonts/bootstrap-icons.woff") format("woff");
}
.bi::before,
[class^="bi-"]::before,
[class*=" bi-"]::before {
display: inline-block;
font-family: bootstrap-icons !important;
font-style: normal;
font-weight: normal !important;
font-variant: normal;
text-transform: none;
line-height: 1;
vertical-align: -.125em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.bi-info-circle-fill::before { content: "\f430"; }

View File

@@ -0,0 +1,54 @@
.elmah-return-btn {
position: fixed;
top: 6px;
right: 24px;
z-index: 9999;
display: flex;
align-items: center;
height: 44px;
min-width: 44px;
padding: 0 14px;
background: #85ea2d;
color: black;
border-radius: 999px;
font-weight: 600;
text-decoration: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
overflow: hidden;
white-space: nowrap;
justify-content: center;
text-decoration: none !important;
transition:
top 0.25s ease,
background-color 0.2s ease;
}
/* hidden label */
.elmah-return-btn::before {
content: "Return to Front-end";
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 0;
opacity: 0;
transition:
max-width 0.3s ease,
opacity 0.2s ease;
}
/* expand on hover */
.elmah-return-btn:hover::before {
max-width: 220px;
padding: 0.5rem;
opacity: 1;
}
/* hover colors */
.elmah-return-btn:hover {
background: #0b5ed7;
color: white;
}

View File

@@ -0,0 +1,10 @@
document.addEventListener('DOMContentLoaded', async () => {
const url = new URL(window.location.href);
const btn = document.createElement("a");
btn.href = url.searchParams.get('ReturnUrl') ?? "/";
btn.innerText = "⎋";
btn.setAttribute("aria-label", "Return to Front-End");
btn.className = "elmah-return-btn";
document.body.appendChild(btn);
});

Binary file not shown.

View File

@@ -0,0 +1,58 @@
.swagger-return-btn {
position: fixed;
top: 6px;
left: 24px;
z-index: 9999;
display: flex;
align-items: center;
height: 44px;
min-width: 44px;
padding: 0 14px;
background: #85ea2d;
color: black;
border-radius: 999px;
font-weight: 600;
text-decoration: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
overflow: hidden;
white-space: nowrap;
justify-content: center;
transition:
top 0.25s ease,
background-color 0.2s ease;
}
/* hidden label */
.swagger-return-btn::after {
content: "Return to Front-end";
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 0;
opacity: 0;
transition:
max-width 0.3s ease,
opacity 0.2s ease;
}
/* expand on hover */
.swagger-return-btn:hover::after {
max-width: 220px;
padding: 0.5rem;
opacity: 1;
}
/* hover colors */
.swagger-return-btn:hover {
background: #0b5ed7;
color: white;
}
/* scrolled state */
.swagger-return-btn.scrolled {
top: 24px;
}

View File

@@ -0,0 +1,24 @@
document.addEventListener('DOMContentLoaded', async () => {
const url = new URL(window.location.href);
const btn = document.createElement("a");
btn.href = url.searchParams.get('ReturnUrl') ?? "/";
btn.innerText = "⎋";
btn.setAttribute("aria-label", "Return to Front-End");
btn.className = "swagger-return-btn";
document.body.appendChild(btn);
const togglePosition = () => {
if (window.scrollY > 0) {
btn.classList.add("scrolled");
} else {
btn.classList.remove("scrolled");
}
};
// Initial state
togglePosition();
// On scroll
window.addEventListener("scroll", togglePosition, { passive: true });
});

View File

@@ -1,38 +1,41 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
using Shared.Models;
namespace Shared; namespace Shared;
public class ApiKeyMiddleware public class ApiKeyMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly IConfiguration _configuration; private readonly ApiKeyOptions _configuration;
public ApiKeyMiddleware(RequestDelegate next, IConfiguration configuration) public ApiKeyMiddleware(RequestDelegate next, IOptions<ApiKeyOptions> configuration)
{ {
_next = next; _next = next;
_configuration = configuration; _configuration = configuration.Value;
} }
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
if (!context.Request.Headers.TryGetValue("X-API-KEY", out StringValues extractedApiKey)) if (!(context.User.Identity?.IsAuthenticated ?? false))
{ {
context.Response.StatusCode = 401; if (!context.Request.Headers.TryGetValue("X-API-KEY", out StringValues extractedApiKey))
await context.Response.WriteAsync("API Key is missing."); {
return; context.Response.StatusCode = 401;
} await context.Response.WriteAsync("API Key is missing.");
return;
}
var validApiKeys = _configuration.GetSection("Embeddingsearch").GetSection("ApiKeys").Get<List<string>>(); string[]? validApiKeys = _configuration.ApiKeys;
#pragma warning disable CS8604 if (validApiKeys == null || !validApiKeys.ToList().Contains(extractedApiKey))
if (validApiKeys == null || !validApiKeys.Contains(extractedApiKey)) // CS8604 extractedApiKey is not null here, but the compiler still thinks that it might be. {
{ context.Response.StatusCode = 403;
context.Response.StatusCode = 403; await context.Response.WriteAsync("Invalid API Key.");
await context.Response.WriteAsync("Invalid API Key."); return;
return; }
} }
#pragma warning restore CS8604
await _next(context); await _next(context);
} }

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Shared.Models;
public class SuccesMessageBaseModel
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Message { get; set; }
}

View File

@@ -3,14 +3,10 @@ using System.Text.Json.Serialization;
namespace Shared.Models; namespace Shared.Models;
public class EntityQueryResults public class EntityQueryResults : SuccesMessageBaseModel
{ {
[JsonPropertyName("Results")] [JsonPropertyName("Results")]
public required List<EntityQueryResult> Results { get; set; } public required List<EntityQueryResult> Results { get; set; }
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
} }
public class EntityQueryResult public class EntityQueryResult
@@ -19,20 +15,19 @@ public class EntityQueryResult
public required string Name { get; set; } public required string Name { get; set; }
[JsonPropertyName("Value")] [JsonPropertyName("Value")]
public float Value { get; set; } public float Value { get; set; }
[JsonPropertyName("Attributes")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string>? Attributes { get; set; }
} }
public class EntityIndexResult public class EntityIndexResult : SuccesMessageBaseModel {}
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
}
public class EntityListResults public class EntityListResults
{ {
[JsonPropertyName("Results")] [JsonPropertyName("Results")]
public required List<EntityListResult> Results { get; set; } public required List<EntityListResult> Results { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
[JsonPropertyName("Success")] [JsonPropertyName("Success")]
public required bool Success { get; set; } public required bool Success { get; set; }
} }
@@ -77,11 +72,5 @@ public class EmbeddingResult
public required float[] Embeddings { get; set; } public required float[] Embeddings { get; set; }
} }
public class EntityDeleteResults public class EntityDeleteResults : SuccesMessageBaseModel {}
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
}

View File

@@ -3,7 +3,7 @@ namespace Shared.Models;
public class JSONEntity public class JSONEntity
{ {
public required string Name { get; set; } public required string Name { get; set; }
public required string Probmethod { get; set; } public required ProbMethodEnum Probmethod { get; set; }
public required string Searchdomain { get; set; } public required string Searchdomain { get; set; }
public required Dictionary<string, string> Attributes { get; set; } public required Dictionary<string, string> Attributes { get; set; }
public required JSONDatapoint[] Datapoints { get; set; } public required JSONDatapoint[] Datapoints { get; set; }
@@ -13,7 +13,27 @@ public class JSONDatapoint
{ {
public required string Name { get; set; } public required string Name { get; set; }
public required string? Text { get; set; } public required string? Text { get; set; }
public required string Probmethod_embedding { get; set; } public required ProbMethodEnum Probmethod_embedding { get; set; }
public required string SimilarityMethod { get; set; } public required SimilarityMethodEnum SimilarityMethod { get; set; }
public required string[] Model { get; set; } public required string[] Model { get; set; }
} }
public enum ProbMethodEnum
{
Mean,
HarmonicMean,
QuadraticMean,
GeometricMean,
EVEWAvg,
HVEWAvg,
LVEWAvg,
DictionaryWeightedAverage
}
public enum SimilarityMethodEnum
{
Cosine,
Euclidian,
Manhattan,
Pearson
}

View File

@@ -0,0 +1,13 @@
namespace Shared.Models;
public class ApiKeyOptions
{
public string[]? ApiKeys { get; set; }
}
public class ServerOptions
{
public required string BaseUri { get; set; }
public string? ApiKey { get; set; }
public string? Searchdomain { get; set; }
}

View File

@@ -101,7 +101,7 @@ public struct SearchdomainSettings(bool cacheReconciliation = false)
public bool CacheReconciliation { get; set; } = cacheReconciliation; public bool CacheReconciliation { get; set; } = cacheReconciliation;
} }
internal static class MemorySizes public static class MemorySizes
{ {
public static readonly int PointerSize = IntPtr.Size; public static readonly int PointerSize = IntPtr.Size;
public static readonly int ObjectHeader = PointerSize * 2; public static readonly int ObjectHeader = PointerSize * 2;

View File

@@ -11,109 +11,46 @@ public class SearchdomainListResults
public string? Message { get; set; } public string? Message { get; set; }
} }
public class SearchdomainCreateResults public class SearchdomainCreateResults : SuccesMessageBaseModel
{ {
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
[JsonPropertyName("Id")] [JsonPropertyName("Id")]
public int? Id { get; set; } public int? Id { get; set; }
} }
public class SearchdomainUpdateResults public class SearchdomainUpdateResults : SuccesMessageBaseModel {}
public class SearchdomainDeleteResults : SuccesMessageBaseModel
{ {
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
}
public class SearchdomainDeleteResults
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
[JsonPropertyName("DeletedEntities")] [JsonPropertyName("DeletedEntities")]
public required int DeletedEntities { get; set; } public required int DeletedEntities { get; set; }
} }
public class SearchdomainSearchesResults public class SearchdomainSearchesResults : SuccesMessageBaseModel
{ {
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
[JsonPropertyName("Searches")] [JsonPropertyName("Searches")]
public required Dictionary<string, DateTimedSearchResult> Searches { get; set; } public required Dictionary<string, DateTimedSearchResult> Searches { get; set; }
} }
public class SearchdomainDeleteSearchResult public class SearchdomainDeleteSearchResult : SuccesMessageBaseModel {}
public class SearchdomainUpdateSearchResult : SuccesMessageBaseModel {}
public class SearchdomainSettingsResults : SuccesMessageBaseModel
{ {
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
}
public class SearchdomainUpdateSearchResult
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
}
public class SearchdomainSettingsResults
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
[JsonPropertyName("Settings")] [JsonPropertyName("Settings")]
public required SearchdomainSettings? Settings { get; set; } public required SearchdomainSettings? Settings { get; set; }
} }
public class SearchdomainSearchCacheSizeResults public class SearchdomainSearchCacheSizeResults : SuccesMessageBaseModel
{ {
[JsonPropertyName("Success")] [JsonPropertyName("QueryCacheSizeBytes")]
public required bool Success { get; set; } public required long? QueryCacheSizeBytes { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
[JsonPropertyName("SearchCacheSizeBytes")]
public required long? SearchCacheSizeBytes { get; set; }
} }
public class SearchdomainInvalidateCacheResults public class SearchdomainInvalidateCacheResults : SuccesMessageBaseModel {}
public class SearchdomainGetDatabaseSizeResult : SuccesMessageBaseModel
{ {
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
}
public class SearchdomainGetDatabaseSizeResult
{
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
[JsonPropertyName("SearchdomainDatabaseSizeBytes")] [JsonPropertyName("SearchdomainDatabaseSizeBytes")]
public required long? SearchdomainDatabaseSizeBytes { get; set; } public required long? SearchdomainDatabaseSizeBytes { get; set; }
} }

View File

@@ -2,14 +2,20 @@ using System.Text.Json.Serialization;
namespace Shared.Models; namespace Shared.Models;
public class ServerGetModelsResult public class ServerGetModelsResult : SuccesMessageBaseModel
{ {
[JsonPropertyName("Success")]
public required bool Success { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
[JsonPropertyName("Models")] [JsonPropertyName("Models")]
public string[]? Models { get; set; } public string[]? Models { get; set; }
} }
public class ServerGetEmbeddingCacheSizeResult : SuccesMessageBaseModel
{
[JsonPropertyName("SizeInBytes")]
public required long? SizeInBytes { get; set; }
[JsonPropertyName("MaxElementCount")]
public required long? MaxElementCount { get; set; }
[JsonPropertyName("ElementCount")]
public required long? ElementCount { get; set; }
[JsonPropertyName("EmbeddingsCount")]
public required long? EmbeddingsCount { get; set; }
}