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>
198 lines
9.3 KiB
C#
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;
|