Files
Wiki/src/Wiki.Host/Program.cs
T
claude-botandClaude Opus 5.5 61c424aac6
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
feat: schedules move to cluster CronJobs
The service no longer schedules work itself. POST /api/v1/encyclopedia/run
syncs and runs the encyclopedia; it answers 502 when the run fails or the
text model stopped it, so a CronJob shows a failed run as a failed Job.
The sync loop keeps its startup sync and the webhook trigger; the 30-min
interval is a CronJob calling POST /api/v1/sync.

Removed: EncyclopediaBackgroundService, Encyclopedia RunAt/TimeZone,
Wiki:Sync IntervalSeconds/RetrySeconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:35:24 +01:00

198 lines
9.3 KiB
C#

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":
Action<Wiki.Adapters.Anthropic.AnthropicOptions> anthropic = 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;
};
builder.Services.AddAnthropicAnswerModel(name, anthropic);
builder.Services.AddAnthropicFactDrafter(name, anthropic);
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));
// Fact drafters ride with the answer models: same entries, same keys.
builder.Services.AddSingleton<IReadOnlyList<IFactDrafter>>(sp =>
wiki.AnswerModels.Models.Keys.Select(sp.GetRequiredKeyedService<IFactDrafter>).ToList());
builder.Services.AddSingleton<IFactDrafter>(sp => sp.GetRequiredKeyedService<IFactDrafter>(wiki.AnswerModels.Active));
// ---- encyclopedia: the text model and the run exist only when enabled --------------------
var encyclopedia = wiki.Encyclopedia;
builder.Services.AddSingleton(encyclopedia);
string? encyclopediaConfigWarning = null;
if (encyclopedia.Enabled)
{
var answerEntry = wiki.AnswerModels.Models.GetValueOrDefault(encyclopedia.AnswerModel);
encyclopediaConfigWarning =
answerEntry is null ? $"Encyclopedia.AnswerModel '{encyclopedia.AnswerModel}' has no entry in Wiki:AnswerModels:Models; using the default API URL"
: string.IsNullOrEmpty(builder.Configuration[$"Wiki:AnswerModels:Models:{encyclopedia.AnswerModel}:ApiKey"]) ? $"No API key at Wiki:AnswerModels:Models:{encyclopedia.AnswerModel}:ApiKey; encyclopedia runs will fail until it is set"
: null;
answerEntry ??= new AnswerModelOptions();
builder.Services.AddAnthropicTextModel(o =>
{
o.BaseUrl = answerEntry.Url;
o.Model = encyclopedia.Model;
o.ApiKey = builder.Configuration[$"Wiki:AnswerModels:Models:{encyclopedia.AnswerModel}:ApiKey"] ?? string.Empty;
});
builder.Services.AddSingleton(new Wiki.Application.Encyclopedia.EncyclopediaSettings(
encyclopedia.PlanMaxTokens, encyclopedia.ArticleMaxTokens, encyclopedia.MaxArticlesPerRun,
encyclopedia.MaxSourceCharsPerArticle, encyclopedia.PilotSlugs, encyclopedia.MaxArticles));
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.PlanArticlesUseCase>();
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.IArticleWriter, Wiki.Application.Encyclopedia.WriteArticleUseCase>();
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.IEncyclopediaRun, Wiki.Application.Encyclopedia.EncyclopediaReconciler>();
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.EncyclopediaCoordinator>();
}
// 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>>(),
.. sp.GetRequiredService<IReadOnlyList<IFactDrafter>>(),
.. sp.GetServices<ITextModel>(),
];
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<Wiki.Application.Reading.ArticleQueries>();
builder.Services.AddSingleton(new HouseMediaCache());
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.Facts.RecordStatementUseCase>();
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 sign in with OIDC against Authentik, machines with an API key ----------
builder.Services.AddWikiAuthentication(builder.Configuration);
// ---- UI ----------------------------------------------------------------------------------
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddMudServices();
var app = builder.Build();
if (encyclopediaConfigWarning is not null) app.Logger.LogWarning("{Warning}", encyclopediaConfigWarning);
// Outermost, so the headers also reach a response the exception handler rewrites.
app.UseHouseHeaders();
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).
// House 404s keep their own response: /not-found needs a login, which would turn them into a redirect.
app.UseWhen(
ctx => HttpMethods.IsGet(ctx.Request.Method) && !ctx.Request.Path.StartsWithSegments("/api") && !ctx.Request.Path.StartsWithSegments("/house"),
branch => branch.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true));
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapHealthEndpoints();
app.MapIndexEndpoints();
app.MapApiV1();
app.MapHouseEndpoints();
app.MapStaticAssets().AllowAnonymous();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
public partial class Program;