Thomas Fransolet 18e4240f0f RAG: pipeline d'ingestion, endpoints du guide IA, journalisation RGPD
Ingestion et indexation
- IIngestionService/IngestionService : chargement des collections filles par
  sous-type, un jeu de morceaux par langue, ChunkIndex continu.
- SectionIndexingInterceptor retenu comme unique déclencheur : les 5
  sous-contrôleurs totalisaient 30 SaveChanges et 0 Enqueue, donc ajouter des
  points d'intérêt à une carte ne réindexait rien.
- HTML retiré avant l'embedding et lignes trop longues recoupées : sans cela un
  article dépassait l'entrée max du modèle et emportait son lot de 50 morceaux.
- Gabarits de LanguageInit filtrés, DistinctBy(Text) avant le Take : ils
  occupaient les cinq premiers résultats d'une recherche en néerlandais.

Endpoints du guide IA
- GET /api/Ai/knowledge/{id} : agrégats sur ContentEmbedding, donc sur ce qui
  est réellement indexé — compter les sections publiées serait plus flatteur et faux.
- GET /api/Ai/insights/{id} : miroir de GuideIaInsights côté manager-app, c'est
  l'écran qui a fixé la forme pour que le job de thèmes la remplisse.

RGPD
- VisitorQuestion journalisée dans AiController.Chat. HasAnswer se déduit des
  sources du retrieval, pas du texte : un repli poli ressemble à une réponse.
  L'écriture n'échoue jamais la réponse au visiteur.
- VisitorQuestionPurgeService, 90 jours, actif sans condition de configuration :
  une durée écrite dans les CGU n'est pas un réglage commercial.

Corrections
- Updateinstance ne recopiait pas les quotas du nouveau plan.
- CheckQuota ne bloquait ni ne comptait à quota 0 — IA gratuite non comptée.
- StoragePath et SizeBytes renseignés à Create, types URL exclus.

dotnet build 0 erreur, dotnet test 130/130.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:48:10 +02:00

408 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using FirebaseAdmin;
using Google.Apis.Auth.OAuth2;
using Hangfire;
using Hangfire.PostgreSql;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
using Manager.Framework.Models;
using Manager.Helpers;
using Manager.Interfaces;
using Manager.Interfaces.Models;
using Manager.Services;
using ManagerService.Data;
using ManagerService.Extensions;
using ManagerService.Helpers;
using ManagerService.Security;
using ManagerService.Service;
using ManagerService.Service.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Mqtt.Client.AspNetCore.Settings;
using MyCore.Service.Extensions;
using Npgsql;
using NSwag;
using NSwag.Generation.AspNetCore;
using NSwag.Generation.Processors.Security;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using ManagerService.Services;
using Serilog;
namespace ManagerService
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
MapConfiguration();
}
public IConfiguration Configuration { get; }
private void MapConfiguration()
{
MapBrokerHostSettings();
MapClientSettings();
}
private void MapBrokerHostSettings()
{
BrokerHostSettings brokerHostSettings = new BrokerHostSettings();
Configuration.GetSection(nameof(BrokerHostSettings)).Bind(brokerHostSettings);
AppSettingsProvider.BrokerHostSettings = brokerHostSettings;
}
private void MapClientSettings()
{
ClientSettings clientSettings = new ClientSettings();
Configuration.GetSection(nameof(ClientSettings)).Bind(clientSettings);
AppSettingsProvider.ClientSettings = clientSettings;
}
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Swagger
services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
services.AddOpenApiDocument(config =>
{
ConfigureSwagger(config);
});
services.AddCors(o => o.AddPolicy("AllowAll", builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
}));
services.Configure<FormOptions>(o => {
o.ValueLengthLimit = int.MaxValue;
o.MultipartBodyLengthLimit = int.MaxValue;
o.MemoryBufferThreshold = int.MaxValue;
});
// Authentication
var tokensConfiguration = Configuration.GetSection("Tokens");
var tokenSettings = tokensConfiguration.Get<TokensSettings>();
services.Configure<TokensSettings>(tokensConfiguration);
services.Configure<StripeSettings>(Configuration.GetSection("Stripe"));
services.Configure<ResendSettings>(Configuration.GetSection("Resend"));
foreach (var policy in ManagerService.Service.Security.PoliciesConfiguration)
services.AddAuthorization(options =>
{
options.AddPolicy(policy.Name, policyAdmin =>
{
policyAdmin.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey");
foreach (var claim in policy.Claims)
policyAdmin.RequireClaim(ManagerService.Service.Security.ClaimTypes.Permission, claim);
});
});
services.AddAuthorization(options =>
options.AddPolicy(ManagerService.Service.Security.Policies.AppReadAccess, policy =>
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey")
.RequireAuthenticatedUser()));
services
.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(x =>
{
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenSettings.Secret)),
ValidateIssuer = false,
ValidateAudience = false,
RequireExpirationTime = false,
ValidateLifetime = true
};
})
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("ApiKey", _ => { });
#if RELEASE
//services.AddMqttClientHostedService();
#endif
services.AddHttpContextAccessor();
services.AddScoped(typeof(ProfileLogic));
services.AddScoped<TokensService>();
services.AddScoped<LanguageInit>();
// OLD services
services.AddScoped<UserDatabaseService>();
services.AddScoped<SectionDatabaseService>();
services.AddScoped<ConfigurationDatabaseService>();
services.AddScoped<ResourceDatabaseService>();
services.AddScoped<DeviceDatabaseService>();
services.AddScoped<InstanceDatabaseService>();
services.AddScoped<ApiKeyDatabaseService>();
services.AddScoped<IEmailService, ResendEmailService>();
services.AddScoped<StripeService>();
// Assistant IA — choisir un provider (package NuGet : Microsoft.Extensions.AI.OpenAI) :
// OpenAI : new OpenAIClient(new ApiKeyCredential(apiKey)).AsChatClient("gpt-4o-mini")
// Gemini : endpoint OpenAI-compatible (ci-dessous)
// Anthropic : package Anthropic.SDK → new AnthropicClient(apiKey).Messages.AsChatClient("claude-haiku-4-5-20251001")
services.AddSingleton<IChatClient>(_ =>
new OpenAIClient(
new ApiKeyCredential(Configuration["AI:ApiKey"]!),
new OpenAIClientOptions { Endpoint = new Uri("https://generativelanguage.googleapis.com/v1beta/openai/") }
).AsChatClient("gemini-2.5-flash-lite")
.AsBuilder()
.UseFunctionInvocation()
.Build());
services.AddScoped<IAssistantService, AssistantService>();
services.AddScoped<IEmbeddingService, GoogleEmbeddingService>();
services.AddScoped<IVectorStoreService, VectorStoreService>();
services.AddScoped<IIngestionService, IngestionService>();
// Push Notifications
var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"];
if (!string.IsNullOrEmpty(firebaseCredentialsPath) && FirebaseApp.DefaultInstance == null)
{
FirebaseApp.Create(new AppOptions
{
Credential = GoogleCredential.FromFile(firebaseCredentialsPath)
});
}
services.AddSingleton<NotificationService>();
services.AddHttpClient();
services.AddScoped<AgendaSyncService>();
services.AddScoped<WeatherSyncService>();
services.AddScoped<TrialLifecycleService>();
services.AddScoped<VisitEventPurgeService>();
services.AddScoped<VisitorQuestionPurgeService>();
var connectionString = Configuration.GetConnectionString("PostgresConnection");
// Hangfire
services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(c => c.UseNpgsqlConnection(connectionString)));
services.AddHangfireServer(options => options.ServerName = "default");
// Serveur séparé pour l'ingestion : le défaut lance min(nbCPU × 5, 20) workers, et
// autant d'extractions simultanées saturent la RAM d'un conteneur qui sert aussi l'API.
// Un serveur à part plutôt qu'une queue de plus, sinon les jobs d'ingestion occupent
// les workers de la file générale (sync agenda, météo, e-mails).
services.AddHangfireServer(options =>
{
options.ServerName = IngestionService.QueueName;
options.Queues = new[] { IngestionService.QueueName };
options.WorkerCount = 2;
});
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.UseNetTopologySuite();
dataSourceBuilder.UseVector();
dataSourceBuilder.EnableDynamicJson();
var dataSource = dataSourceBuilder.Build();
// Scoped, pas singleton : l'intercepteur accumule les sections touchées entre
// SavingChanges et SavedChanges. Partagé entre requêtes, deux clients se
// déclencheraient mutuellement des ré-indexations.
services.AddScoped<SectionIndexingInterceptor>();
services.AddDbContext<MyInfoMateDbContext>((serviceProvider, options) =>
options.UseNpgsql(dataSource, o => o.UseNetTopologySuite().UseVector())
.AddInterceptors(serviceProvider.GetRequiredService<SectionIndexingInterceptor>())
.EnableSensitiveDataLogging()
.LogTo(Console.WriteLine, LogLevel.Information)
);
services.AddHealthChecks()
.AddNpgSql(connectionString);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
/*app.UseCors(
options => options.WithOrigins("http://localhost:49430").AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().AllowCredentials()
);*/
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseExceptionHandler(HandleError);
app.UseSerilogRequestLogging();
//app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
//app.UseCors("AllowAll");
app.UseCors(
#if DEBUG
options => options
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:9090" || origin == "http://localhost:3000")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
#else
options => options
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "https://manager.mymuseum.be" || origin == "https://manager.myinfomate.be" || origin == "https://visitnamur.myinfomate.be" || origin == "https://fortsaintheribert.myinfomate.be" || origin == "https://app.myinfomate.be" || origin == "https://myinfomate.be")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
#endif
);
#if DEBUG
app.UseHangfireDashboard("/hangfire");
#else
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] { new HangfireDashboardAuthorizationFilter() }
});
#endif
RecurringJob.AddOrUpdate<AgendaSyncService>(
"agenda-sync-daily",
s => s.SyncAllAsync(),
Cron.Daily());
RecurringJob.AddOrUpdate<WeatherSyncService>(
"weather-sync-morning",
s => s.SyncAllAsync(),
"0 6 * * *");
RecurringJob.AddOrUpdate<WeatherSyncService>(
"weather-sync-afternoon",
s => s.SyncAllAsync(),
"0 13 * * *");
RecurringJob.AddOrUpdate<TrialLifecycleService>(
"trial-lifecycle-daily",
s => s.RunAsync(),
Cron.Daily());
// Ne supprime rien tant que Stats:RetentionDays n'est pas défini — voir VisitEventPurgeService.
RecurringJob.AddOrUpdate<VisitEventPurgeService>(
"visit-events-purge",
s => s.PurgeAsync(),
"0 3 * * *");
// Actif sans condition : les 90 jours sont un engagement des CGU §8.4, pas un réglage.
RecurringJob.AddOrUpdate<VisitorQuestionPurgeService>(
"visitor-questions-purge",
s => s.PurgeAsync(),
"30 3 * * *");
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapHealthChecks("/health");
});
app.UseOpenApi();
app.UseSwaggerUi3(configure =>
{
configure.OperationsSorter = "alpha";
configure.TagsSorter = "alpha";
});
}
private void ConfigureSwagger(AspNetCoreOpenApiDocumentGeneratorSettings config)
{
config.GenerateEnumMappingDescription = true;
config.AddSecurity("bearer", Enumerable.Empty<string>(), new OpenApiSecurityScheme
{
Type = OpenApiSecuritySchemeType.OAuth2,
Description = "Manager Authentication",
Flow = OpenApiOAuth2Flow.Password,
Flows = new OpenApiOAuthFlows()
{
Password = new OpenApiOAuthFlow()
{
Scopes = new Dictionary<string, string>
{
{ ManagerService.Service.Security.Scope, "Manager WebAPI" }
},
TokenUrl = "/api/authentication/Token",
AuthorizationUrl = "/authentication/Token",
}
}
});
config.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("bearer"));
config.AddSecurity("apikey", Enumerable.Empty<string>(), new OpenApiSecurityScheme
{
Type = OpenApiSecuritySchemeType.ApiKey,
Name = "X-Api-Key",
In = OpenApiSecurityApiKeyLocation.Header,
Description = "API Key for mobile apps"
});
config.PostProcess = document =>
{
document.Info.Title = "Manager Service";
document.Info.Description = "API Manager Service";
document.Info.Version = "Version Alpha";
};
}
private void HandleError(IApplicationBuilder error)
{
error.Run(async context =>
{
var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
var exception = exceptionHandlerPathFeature?.Error as RequestException;
if (exception != null)
{
var json = exception.GetJson();
context.Response.ContentType = "application/json";
context.Response.StatusCode = exception.StatusCode;
await context.Response.WriteAsync(json);
}
});
}
}
}