Files
Wiki/src/Wiki.Host/Program.cs
T
claude-botandClaude Fable 5.1 707c5f50a7
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
Merge branch 'feat/answer-model' into feat/lab
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>
2026-09-24 16:53:49 +02:00

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;