Both phases register their use cases and map their endpoints in the same host files. The lab host test asserted exactly one labelled query, which held only while no other test in the shared fixture wrote to output/; it now checks the seeded query is among the rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
162 lines
6.7 KiB
C#
162 lines
6.7 KiB
C#
using Microsoft.AspNetCore.Authentication;
|
|
using MudBlazor.Services;
|
|
using Wiki.Adapters.FileIndex;
|
|
using Wiki.Adapters.Gitea;
|
|
using Wiki.Adapters.LlamaCpp;
|
|
using Wiki.Adapters.TypeSafe;
|
|
using Wiki.Adapters.Anthropic;
|
|
using Wiki.Application.Retrieval;
|
|
using Wiki.Application.Status;
|
|
using Wiki.Application.Sync;
|
|
using Wiki.Core.Ports;
|
|
using Wiki.Host.Api;
|
|
using Wiki.Host.Auth;
|
|
using Wiki.Host.Components;
|
|
using Wiki.Host.Composition;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
var wiki = builder.Configuration.GetSection("Wiki").Get<WikiOptions>() ?? new WikiOptions();
|
|
builder.Services.AddSingleton(wiki);
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
|
|
// ---- adapters, selected by configuration ------------------------------------------------
|
|
builder.Services.AddGiteaNoteStore(o =>
|
|
{
|
|
o.BaseUrl = wiki.NoteStore.Gitea.BaseUrl;
|
|
o.Repository = wiki.NoteStore.Gitea.Repository;
|
|
o.Branch = wiki.NoteStore.Gitea.Branch;
|
|
o.Token = builder.Configuration["Wiki:NoteStore:Gitea:Token"] ?? string.Empty;
|
|
});
|
|
builder.Services.AddFileIndexAdapters(o => o.Directory = wiki.Index.File.Directory);
|
|
foreach (var (name, model) in wiki.Embeddings.Models)
|
|
{
|
|
switch (model.Adapter)
|
|
{
|
|
case "LlamaCpp":
|
|
builder.Services.AddLlamaCppEmbeddings(name, o =>
|
|
{
|
|
o.Url = model.Url;
|
|
o.Model = model.Model;
|
|
o.ModelId = name;
|
|
o.Dimension = model.Dimension;
|
|
o.BasicAuth = builder.Configuration[$"Wiki:Embeddings:Models:{name}:BasicAuth"];
|
|
});
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"unknown embeddings adapter '{model.Adapter}' for model '{name}'");
|
|
}
|
|
}
|
|
builder.Services.AddSingleton<IReadOnlyList<IEmbeddingModel>>(sp =>
|
|
wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService<IEmbeddingModel>).ToList());
|
|
builder.Services.AddSingleton<IEmbeddingModel>(sp => sp.GetRequiredKeyedService<IEmbeddingModel>(wiki.Embeddings.Active));
|
|
foreach (var (name, judge) in wiki.Judges.Models)
|
|
{
|
|
switch (judge.Adapter)
|
|
{
|
|
case "TypeSafe":
|
|
builder.Services.AddTypeSafeJudge(name, o =>
|
|
{
|
|
o.Url = judge.Url;
|
|
o.Model = judge.Model;
|
|
o.ModelId = name;
|
|
o.ApiKey = builder.Configuration[$"Wiki:Judges:Models:{name}:ApiKey"] ?? string.Empty;
|
|
});
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"unknown judge adapter '{judge.Adapter}' for '{name}'");
|
|
}
|
|
}
|
|
builder.Services.AddSingleton<IReadOnlyList<IJudge>>(sp =>
|
|
wiki.Judges.Models.Keys.Select(sp.GetRequiredKeyedService<IJudge>).ToList());
|
|
builder.Services.AddSingleton<IJudge>(sp => sp.GetRequiredKeyedService<IJudge>(wiki.Judges.Active));
|
|
foreach (var (name, answer) in wiki.AnswerModels.Models)
|
|
{
|
|
switch (answer.Adapter)
|
|
{
|
|
case "Anthropic":
|
|
builder.Services.AddAnthropicAnswerModel(name, o =>
|
|
{
|
|
o.BaseUrl = answer.Url;
|
|
o.Model = answer.Model;
|
|
o.ModelId = name;
|
|
o.MaxTokens = answer.MaxTokens;
|
|
o.ApiKey = builder.Configuration[$"Wiki:AnswerModels:Models:{name}:ApiKey"] ?? string.Empty;
|
|
});
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"unknown answer-model adapter '{answer.Adapter}' for '{name}'");
|
|
}
|
|
}
|
|
builder.Services.AddSingleton<IReadOnlyList<IAnswerModel>>(sp =>
|
|
wiki.AnswerModels.Models.Keys.Select(sp.GetRequiredKeyedService<IAnswerModel>).ToList());
|
|
builder.Services.AddSingleton<IAnswerModel>(sp => sp.GetRequiredKeyedService<IAnswerModel>(wiki.AnswerModels.Active));
|
|
// One adapter may serve several ports; list it once.
|
|
builder.Services.AddSingleton<IReadOnlyList<IAdapterHealth>>(sp =>
|
|
{
|
|
List<IAdapterHealth> all =
|
|
[
|
|
sp.GetRequiredService<INoteStore>(),
|
|
sp.GetRequiredService<IVectorIndex>(),
|
|
sp.GetRequiredService<IDocumentCatalog>(),
|
|
.. sp.GetRequiredService<IReadOnlyList<IEmbeddingModel>>(),
|
|
.. sp.GetRequiredService<IReadOnlyList<IJudge>>(),
|
|
.. sp.GetRequiredService<IReadOnlyList<IAnswerModel>>(),
|
|
];
|
|
return all.Distinct().ToList();
|
|
});
|
|
|
|
// ---- use cases ---------------------------------------------------------------------------
|
|
builder.Services.AddSingleton(new SyncOptions());
|
|
builder.Services.AddSingleton<SyncUseCase>();
|
|
builder.Services.AddSingleton<SyncCoordinator>();
|
|
builder.Services.AddSingleton<RankUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Reading.NoteQueries>();
|
|
builder.Services.AddSingleton<IChunker, Wiki.Core.Parsing.ParagraphChunker>();
|
|
builder.Services.AddSingleton<Wiki.Application.Ask.AskUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Facts.DepositFactUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Evaluation.EvaluateUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Questions.QuestionUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Questions.SaveAnswerUseCase>();
|
|
builder.Services.AddSingleton<StatusQuery>();
|
|
builder.Services.AddSingleton<SyncBackgroundService>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<SyncBackgroundService>());
|
|
|
|
// ---- auth: humans arrive through the Authentik outpost, machines with an API key ---------
|
|
builder.Services.AddAuthentication(ProxyHeaderAuthenticationHandler.SchemeName)
|
|
.AddScheme<AuthenticationSchemeOptions, ProxyHeaderAuthenticationHandler>(ProxyHeaderAuthenticationHandler.SchemeName, null)
|
|
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationHandler.SchemeName, null);
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
options.AddPolicy(Policies.Write, p => p.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName).RequireAuthenticatedUser());
|
|
});
|
|
|
|
// ---- UI ----------------------------------------------------------------------------------
|
|
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
|
builder.Services.AddMudServices();
|
|
|
|
var app = builder.Build();
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
|
}
|
|
// The not-found page is for the UI only: an API 401/404 must stay a JSON status, not be
|
|
// re-executed as a POST against a Blazor page (which fails antiforgery with an empty 400).
|
|
app.UseWhen(
|
|
ctx => HttpMethods.IsGet(ctx.Request.Method) && !ctx.Request.Path.StartsWithSegments("/api"),
|
|
branch => branch.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true));
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAntiforgery();
|
|
|
|
app.MapHealthEndpoints();
|
|
app.MapIndexEndpoints();
|
|
app.MapApiV1();
|
|
|
|
app.MapStaticAssets();
|
|
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
|
|
|
app.Run();
|
|
|
|
public partial class Program;
|