diff --git a/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs b/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs new file mode 100644 index 0000000..4801576 --- /dev/null +++ b/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs @@ -0,0 +1,197 @@ +using Manager.DTOs; +using ManagerService.Controllers; +using ManagerService.Data; +using ManagerService.DTOs; +using ManagerService.Tests.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace ManagerService.Tests.Controllers +{ + /// + /// GetSummary agrège désormais en SQL. Les tests InMemory tiennent la sémantique mais + /// pas la traduction : le provider InMemory évalue tout côté client, donc une requête + /// intraduisible y passerait au vert et exploserait en production. Ceux-ci s'exécutent + /// contre un vrai PostgreSQL. + /// + [Collection(PostgresCollection.Name)] + public class StatsControllerPostgresTests + { + private readonly PostgresFixture _postgres; + + public StatsControllerPostgresTests(PostgresFixture postgres) + { + _postgres = postgres; + } + + private static StatsController BuildController(MyInfoMateDbContext db) + { + var controller = new StatsController(db); + FakeUser.SetUser(controller, FakeUser.Create(Permissions.SuperAdmin, "i1")); + return controller; + } + + private static void Seed(MyInfoMateDbContext db, bool advanced) + { + db.Instances.Add(new Instance + { + Id = "i1", + Name = "Musée de test", + HasStats = true, + HasAdvancedStats = advanced, + StatsHistoryDays = MyInfoMateDbContext.StatsRetentionDays + }); + + var section = TestSection.Article("sect-a", "i1", "A", "c1"); + section.Title = new List { new TranslationDTO { language = "fr", value = "La cave" } }; + db.Sections.Add(section); + + var day = DateTime.UtcNow.AddDays(-2); + var events = new List + { + Ev("e1", VisitEventType.SectionView, "s1", day, sectionId: "sect-a", language: "fr", appType: AppType.Mobile), + Ev("e2", VisitEventType.SectionView, "s2", day.AddHours(1), sectionId: "sect-a", language: "nl", appType: AppType.Tablet), + Ev("e3", VisitEventType.SectionLeave, "s1", day.AddMinutes(5), sectionId: "sect-a", durationSeconds: 30), + // s2 est une session tablette de bout en bout : un appType qui change en + // cours de session n'existe pas, et l'y laisser fausserait la distribution. + Ev("e4", VisitEventType.SectionLeave, "s2", day.AddHours(1).AddMinutes(5), sectionId: "sect-a", durationSeconds: 10, appType: AppType.Tablet), + Ev("e5", VisitEventType.ArticleRead, "s1", day, sectionId: "sect-a"), + Ev("e6", VisitEventType.QrScan, "s1", day, metadata: "{\"valid\":true}"), + Ev("e7", VisitEventType.MapPoiTap, "s1", day, sectionId: "sect-a", metadata: "{\"geoPointId\":7,\"geoPointTitle\":\"Ruche\"}"), + Ev("e8", VisitEventType.AgendaEventTap, "s2", day, metadata: "{\"eventId\":\"ev1\",\"eventTitle\":\"Concert\"}", appType: AppType.Tablet), + Ev("e9", VisitEventType.GameComplete, "s1", day, metadata: "{\"gameType\":\"Puzzle\",\"durationSeconds\":60}"), + Ev("e10", VisitEventType.MenuItemTap, "s2", day, metadata: "{\"targetSectionId\":\"sect-a\",\"menuItemTitle\":\"Accueil\"}", appType: AppType.Tablet), + Ev("e11", VisitEventType.QuizComplete, "s1", day, sectionId: "sect-a", metadata: "{\"score\":4,\"totalQuestions\":5}") + }; + + db.VisitEvents.AddRange(events); + db.SaveChanges(); + } + + private static VisitEvent Ev(string id, VisitEventType type, string sessionId, DateTime timestamp, + string sectionId = null, string metadata = null, string language = null, + AppType appType = AppType.Mobile, int? durationSeconds = null) => + new VisitEvent + { + Id = id, + InstanceId = "i1", + SessionId = sessionId, + EventType = type, + SectionId = sectionId, + Metadata = metadata, + Language = language, + AppType = appType, + DurationSeconds = durationSeconds, + Timestamp = timestamp + }; + + private static StatsSummaryDTO Summarize(MyInfoMateDbContext db) + { + var result = BuildController(db).GetSummary("i1", null, null, null); + + // Le contrôleur enveloppe tout dans un try/catch qui rend 500 : une requête + // intraduisible ne remonterait pas en exception mais en corps de réponse. + // On lit donc le code avant de caster, sinon l'échec serait illisible. + var objectResult = Assert.IsAssignableFrom(result); + Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}"); + + return Assert.IsType(objectResult.Value); + } + + [SkippableFact] + public void GetSummary_TranslatesToSql_OnTheBasicPlan() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("stats_basic"); + Seed(db, advanced: false); + + var summary = Summarize(db); + + Assert.Equal(2, summary.TotalSessions); + Assert.Equal(20, summary.AvgVisitDurationSeconds); // (30 + 10) / 2 sessions + + var section = Assert.Single(summary.TopSections); + Assert.Equal("La cave", section.SectionTitle); + Assert.Equal(2, section.Views); + Assert.Equal(20, section.AvgDurationSeconds); + + var day = Assert.Single(summary.VisitsByDay); + Assert.Equal(2, day.Total); + Assert.Equal(1, day.Mobile); + Assert.Equal(1, day.Tablet); + + Assert.Equal(1, summary.AppTypeDistribution["Mobile"]); + Assert.Equal(1, summary.AppTypeDistribution["Tablet"]); + + // Plan sans stats avancées : rien de tout ça n'a été calculé. + Assert.Empty(summary.LanguageDistribution); + Assert.Empty(summary.TopPois); + Assert.Equal(0, summary.QrScans.TotalScans); + } + + [SkippableFact] + public void GetSummary_TranslatesToSql_OnTheAdvancedPlan() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("stats_advanced"); + Seed(db, advanced: true); + + var summary = Summarize(db); + + Assert.Equal(1, summary.LanguageDistribution["fr"]); + Assert.Equal(1, summary.LanguageDistribution["nl"]); + Assert.Equal("Ruche", Assert.Single(summary.TopPois).Title); + Assert.Equal("Concert", Assert.Single(summary.TopAgendaEvents).EventTitle); + Assert.Equal("Puzzle", Assert.Single(summary.GameStats).GameType); + Assert.Equal("Accueil", Assert.Single(summary.TopMenuItems).MenuItemTitle); + Assert.Equal(1, Assert.Single(summary.TopArticles).Reads); + Assert.Equal(1, summary.QrScans.TotalScans); + + var quiz = Assert.Single(summary.QuizStats); + Assert.Equal("La cave", quiz.SectionTitle); + Assert.Equal(4.0, quiz.AvgScore); + } + + [SkippableFact] + public void GetSummary_TranslatesToSql_WithAnAppTypeFilter() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("stats_filtered"); + Seed(db, advanced: true); + + var result = BuildController(db).GetSummary("i1", null, null, "Tablet"); + var objectResult = Assert.IsAssignableFrom(result); + Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}"); + + var summary = Assert.IsType(objectResult.Value); + Assert.Equal(1, summary.TotalSessions); + Assert.Equal(1, summary.AppTypeDistribution["Tablet"]); + } + + [SkippableFact] + public void GetSummary_OnAnEmptyWindow_TranslatesWithoutFailing() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("stats_empty"); + Seed(db, advanced: true); + + // Fenêtre vide : les agrégats portent sur zéro ligne. C'est là qu'une moyenne + // SQL rend NULL et qu'un cast maladroit lèverait. + var result = BuildController(db).GetSummary("i1", DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, null); + var objectResult = Assert.IsAssignableFrom(result); + Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}"); + + var summary = Assert.IsType(objectResult.Value); + Assert.Equal(0, summary.TotalSessions); + Assert.Equal(0, summary.AvgVisitDurationSeconds); + Assert.Empty(summary.TopSections); + } + } +} diff --git a/ManagerService.Tests/Infrastructure/FakeEmbeddingService.cs b/ManagerService.Tests/Infrastructure/FakeEmbeddingService.cs new file mode 100644 index 0000000..2249347 --- /dev/null +++ b/ManagerService.Tests/Infrastructure/FakeEmbeddingService.cs @@ -0,0 +1,68 @@ +using ManagerService.Data; +using ManagerService.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace ManagerService.Tests.Infrastructure +{ + /// + /// Embeddings déterministes, sans appel réseau. Le texte pilote directement la + /// direction du vecteur, ce qui permet d'écrire « ce morceau est proche de cette + /// question » comme un fait vérifiable plutôt que comme une supposition. + /// + /// + /// Convention : un texte préfixé axis:N produit le vecteur unitaire de l'axe N, + /// éventuellement mêlé d'un peu de bruit stable (axis:N#seed). Deux textes sur + /// des axes différents sont donc quasi orthogonaux — distance cosinus proche de 1. + /// Ce qui suit les chiffres du seed est ignoré : cela permet d'écrire des textes + /// distincts qui partagent un même vecteur, sans quoi la déduplication par texte de + /// SearchAsync écraserait les lignes et fausserait tout comptage de résultats. + /// + public class FakeEmbeddingService : IEmbeddingService + { + public int Dimensions => ContentEmbedding.Dimensions; + + public Task EmbedAsync(string text, CancellationToken cancellationToken = default) => + Task.FromResult(Embed(text)); + + public Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken cancellationToken = default) => + Task.FromResult>(texts.Select(Embed).ToList()); + + public float[] Embed(string text) + { + var vector = new float[Dimensions]; + + var axis = 0; + var seed = 0; + + if (text != null && text.StartsWith("axis:")) + { + var payload = text.Substring("axis:".Length); + var hash = payload.IndexOf('#'); + if (hash >= 0) + { + var digits = new string(payload.Substring(hash + 1).TakeWhile(char.IsDigit).ToArray()); + int.TryParse(digits, out seed); + payload = payload.Substring(0, hash); + } + int.TryParse(payload, out axis); + } + else + { + axis = Math.Abs((text ?? "").GetHashCode()) % Dimensions; + } + + vector[axis % Dimensions] = 1f; + + // Bruit stable : deux morceaux du même axe ne sont pas strictement identiques, + // sinon HNSW n'aurait aucun ordre à produire entre eux. + if (seed != 0) + vector[(axis + 1 + (seed % 16)) % Dimensions] = 0.01f * (seed % 10); + + return vector; + } + } +} diff --git a/ManagerService.Tests/Infrastructure/PostgresFixture.cs b/ManagerService.Tests/Infrastructure/PostgresFixture.cs new file mode 100644 index 0000000..8b040b4 --- /dev/null +++ b/ManagerService.Tests/Infrastructure/PostgresFixture.cs @@ -0,0 +1,168 @@ +using DotNet.Testcontainers.Builders; +using ManagerService.Data; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using Testcontainers.PostgreSql; +using Xunit; + +namespace ManagerService.Tests.Infrastructure +{ + /// + /// Un vrai PostgreSQL, construit depuis Deployment/Dockerfile.postgres — + /// donc le même PostGIS + pgvector que la production, à l'image épinglée près. + /// + /// + /// Le vector store ne peut pas être éprouvé sur EF InMemory : le type vector, + /// l'index HNSW et le post-filtrage n'y existent pas, et le modèle exclut d'ailleurs + /// explicitement ContentEmbedding hors Npgsql. + /// + /// Sans démon Docker, la fixture ne lève pas : passe à false + /// et les tests se sautent. La suite reste verte sur une machine sans Docker, comme + /// MigrationDryRunTests le fait déjà sans Mongo. + /// + public class PostgresFixture : IAsyncLifetime + { + private const string ImageTag = "myinfomate-postgres-tests:latest"; + + private PostgreSqlContainer _container; + + public bool Available { get; private set; } + + public string SkipReason { get; private set; } + + public string ConnectionString { get; private set; } + + public async Task InitializeAsync() + { + try + { + BuildImage(); + + _container = new PostgreSqlBuilder() + .WithImage(ImageTag) + .WithDatabase("my_info_mate") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + await _container.StartAsync(); + ConnectionString = _container.GetConnectionString(); + Available = true; + } + catch (Exception ex) + { + SkipReason = $"Docker indisponible : {ex.Message}"; + Available = false; + } + } + + /// + /// Construit l'image via le CLI docker plutôt que par le constructeur d'images de + /// Testcontainers : celui-ci relit le FROM pour pré-tirer l'image de base et + /// ne sait pas parser la forme tag@sha256:…. Or ce digest est justement ce + /// qui protège la base d'un changement de glibc sous ses index — il n'est pas + /// négociable pour arranger un test. + /// + private static void BuildImage() + { + var dockerfileDirectory = Path.Combine( + CommonDirectoryPath.GetSolutionDirectory().DirectoryPath, "ManagerService", "Deployment"); + + var process = Process.Start(new ProcessStartInfo("docker") + { + ArgumentList = { "build", "-f", "Dockerfile.postgres", "-t", ImageTag, "." }, + WorkingDirectory = dockerfileDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true + }); + + process.WaitForExit((int)TimeSpan.FromMinutes(10).TotalMilliseconds); + + if (process.ExitCode != 0) + throw new InvalidOperationException( + $"docker build a échoué :\n{process.StandardError.ReadToEnd()}"); + } + + public async Task DisposeAsync() + { + if (_container != null) + await _container.DisposeAsync(); + } + + /// + /// Contexte sur une base neuve, migrations appliquées. Les extensions PostGIS et + /// pgvector sont créées par les migrations, pas par l'image : un CREATE EXTENSION + /// manuel masquerait justement l'oubli qu'on veut voir. + /// + public MyInfoMateDbContext CreateContext(string databaseName) + { + var builder = new NpgsqlConnectionStringBuilder(ConnectionString) { Database = databaseName }; + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(builder.ConnectionString); + dataSourceBuilder.UseNetTopologySuite(); + dataSourceBuilder.UseVector(); + dataSourceBuilder.EnableDynamicJson(); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(dataSourceBuilder.Build(), o => o.UseNetTopologySuite().UseVector()) + .Options; + + return new MyInfoMateDbContext(options, new HttpContextAccessor()); + } + + /// Crée une base vide puis y applique les migrations. + /// + /// Coupe le parcours séquentiel pour la base entière. Sur les volumes d'un test, + /// un seq scan reste moins cher qu'un index HNSW, et le post-filtrage — ce qu'on + /// vient justement éprouver — resterait invisible. Le réglage est posé sur la base + /// et non sur la session : un SET par ExecuteSqlRaw serait effacé au retour de la + /// connexion au pool, et le test passerait au vert sans rien avoir prouvé. + /// + public MyInfoMateDbContext CreateMigratedContext(string databaseName, bool forceIndexScan = false) + { + NpgsqlConnection.ClearAllPools(); + + using (var admin = new NpgsqlConnection(ConnectionString)) + { + admin.Open(); + using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{databaseName}\"", admin); + drop.ExecuteNonQuery(); + using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin); + create.ExecuteNonQuery(); + + if (forceIndexScan) + { + using var tune = new NpgsqlCommand($"ALTER DATABASE \"{databaseName}\" SET enable_seqscan = off", admin); + tune.ExecuteNonQuery(); + } + } + + var db = CreateContext(databaseName); + db.Database.Migrate(); + return db; + } + + /// Connexion brute sur une base du conteneur, hors EF. + public NpgsqlConnection OpenRawConnection(string databaseName) + { + var builder = new NpgsqlConnectionStringBuilder(ConnectionString) { Database = databaseName }; + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(builder.ConnectionString); + dataSourceBuilder.UseVector(); + + var connection = dataSourceBuilder.Build().OpenConnection(); + return connection; + } + } + + [CollectionDefinition(Name)] + public class PostgresCollection : ICollectionFixture + { + public const string Name = "postgres"; + } +} diff --git a/ManagerService.Tests/ManagerService.Tests.csproj b/ManagerService.Tests/ManagerService.Tests.csproj index 2a8135d..2a71edb 100644 --- a/ManagerService.Tests/ManagerService.Tests.csproj +++ b/ManagerService.Tests/ManagerService.Tests.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -24,6 +25,7 @@ + diff --git a/ManagerService.Tests/Services/VectorStoreServiceTests.cs b/ManagerService.Tests/Services/VectorStoreServiceTests.cs new file mode 100644 index 0000000..d1c30e1 --- /dev/null +++ b/ManagerService.Tests/Services/VectorStoreServiceTests.cs @@ -0,0 +1,312 @@ +using ManagerService.Data; +using ManagerService.Services; +using ManagerService.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Pgvector; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace ManagerService.Tests.Services +{ + /// + /// Le RAG n'avait tourné que sur une base à UNE instance — le cas où le + /// post-filtrage HNSW ne se voit pas. Ces tests le mettent en défaut à deux. + /// + [Collection(PostgresCollection.Name)] + public class VectorStoreServiceTests + { + /// + /// Un rapport de 30 pour 1 entre les deux instances : assez déséquilibré pour que + /// toutes les lignes de la minoritaire tombent derrière hnsw.ef_search + /// (40 par défaut) si jamais l'index HNSW était parcouru avant le filtre. + /// + private const int NoisyRows = 600; + private const int TargetRows = 20; + + private readonly PostgresFixture _postgres; + + public VectorStoreServiceTests(PostgresFixture postgres) + { + _postgres = postgres; + } + + private VectorStoreService BuildService(MyInfoMateDbContext db) => + new VectorStoreService(db, new FakeEmbeddingService(), FakeConfiguration.Create()); + + /// + /// Deux instances : « noisy » sature l'axe 0 (celui de la question), « target » + /// n'a que quelques lignes sur l'axe 5. Toutes les lignes de noisy passent donc + /// devant toutes celles de target au classement brut. + /// + private static void SeedTwoInstances(MyInfoMateDbContext db) + { + var embeddings = new FakeEmbeddingService(); + var rows = new List(); + + // Le suffixe rend chaque texte unique sans changer le vecteur : sinon la + // déduplication par texte de SearchAsync réduirait 20 lignes à 9. + for (var i = 0; i < NoisyRows; i++) + rows.Add(Row(embeddings, "noisy", $"noisy-{i}", i, $"axis:0#{i % 9 + 1}-{i}")); + + for (var i = 0; i < TargetRows; i++) + rows.Add(Row(embeddings, "target", $"target-{i}", i, $"axis:5#{i % 9 + 1}-{i}")); + + db.ContentEmbeddings.AddRange(rows); + db.SaveChanges(); + } + + private static ContentEmbedding Row(FakeEmbeddingService embeddings, string instanceId, + string contentId, int chunkIndex, string text) => + new ContentEmbedding + { + InstanceId = instanceId, + ConfigurationId = null, + ContentId = contentId, + ContentType = ContentSourceType.Section, + ChunkIndex = chunkIndex, + Language = "fr", + Text = text, + Embedding = new Vector(embeddings.Embed(text)), + UpdatedAt = DateTime.UtcNow + }; + + [SkippableFact] + public void Extensions_AreCreatedByMigrations() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_extensions"); + + using var connection = _postgres.OpenRawConnection("vs_extensions"); + using var command = new NpgsqlCommand( + "SELECT extname FROM pg_extension WHERE extname IN ('vector', 'postgis')", connection); + using var reader = command.ExecuteReader(); + + var found = new List(); + while (reader.Read()) found.Add(reader.GetString(0)); + + // Aucun CREATE EXTENSION n'est joué à la main : si les migrations ne les + // déclarent pas, un environnement neuf part sans, et rien ne le dit avant + // la première recherche. + Assert.Contains("vector", found); + Assert.Contains("postgis", found); + } + + [SkippableFact] + public void IterativeScan_IsSupportedByTheInstalledPgvector() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_iterative"); + + // SearchAsync pose ce paramètre à chaque recherche. Il n'existe qu'à partir de + // pgvector 0.8 : sur une version antérieure, la recherche échouerait en + // production sur « unrecognized configuration parameter ». + var exception = Record.Exception(() => + db.Database.ExecuteSqlRaw("SET hnsw.iterative_scan = relaxed_order")); + + Assert.Null(exception); + } + + private const string FilteredNearestQuery = + "SELECT 1 FROM \"ContentEmbeddings\" WHERE \"InstanceId\" = 'target' " + + "ORDER BY \"Embedding\" <=> $1 LIMIT 25"; + + private static NpgsqlCommand CountFilteredNearest(NpgsqlConnection connection) + { + var command = new NpgsqlCommand($"SELECT count(*) FROM ({FilteredNearestQuery}) s", connection); + // La question vise l'axe saturé par l'AUTRE instance : le pire cas, celui où + // toutes les lignes de « target » sont derrière toutes celles de « noisy ». + command.Parameters.Add(new NpgsqlParameter { Value = new Vector(new FakeEmbeddingService().Embed("axis:0")) }); + return command; + } + + /// + /// Ce qui protège réellement du post-filtrage, ce n'est pas le parcours itératif : + /// c'est l'index sur (InstanceId, ContentType). Le planificateur filtre par + /// instance d'abord et trie exactement — l'index HNSW n'est pas touché, donc il n'y + /// a rien à post-filtrer. Vérifié aussi à 22 000 lignes hors suite. + /// + [SkippableFact] + public void InstanceIndex_KeepsThePlannerAwayFromHnsw() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_plan", forceIndexScan: true); + SeedTwoInstances(db); + + using var connection = _postgres.OpenRawConnection("vs_plan"); + using var command = new NpgsqlCommand($"EXPLAIN (COSTS OFF) {FilteredNearestQuery}", connection); + command.Parameters.Add(new NpgsqlParameter { Value = new Vector(new FakeEmbeddingService().Embed("axis:0")) }); + + var plan = ""; + using (var reader = command.ExecuteReader()) + while (reader.Read()) plan += reader.GetString(0) + "\n"; + + Assert.Contains("IX_ContentEmbeddings_InstanceId_ContentType", plan); + Assert.DoesNotContain("IX_ContentEmbeddings_Embedding", plan); + } + + /// + /// Et voici ce qui arriverait si cet index disparaissait : le WHERE s'applique + /// APRÈS le parcours HNSW, les voisins ramenés appartiennent tous à « noisy », et + /// le filtre les jette tous. Les 20 lignes du client existent, la recherche en + /// rend zéro, et rien ne signale l'anomalie. + /// + /// + /// ⚠️ Le second volet est le résultat inattendu de ce chantier : + /// hnsw.iterative_scan = relaxed_order — le garde-fou que pose + /// SearchAsyncne rattrape rien ici. Le parcours s'épuise après + /// ~335 lignes (Rows Removed by Filter à l'EXPLAIN ANALYZE) sans avoir + /// atteint l'instance minoritaire, parce que le graphe est construit au fil des + /// insertions et non en bloc — la migration crée l'index avant qu'il n'y ait des + /// lignes. Le même jeu de données, index HNSW construit après l'insertion, rend + /// bien les 20 lignes : c'est donc la connectivité du graphe qui décide, pas le + /// réglage. La protection réelle est l'index sur (InstanceId, ContentType), que le + /// test précédent verrouille. Ce test-ci fige le comportement d'aujourd'hui pour + /// qu'un changement de version de pgvector se voie. + /// + [SkippableFact] + public void WithoutTheInstanceIndex_PostFilteringLosesEverything() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_postfilter", forceIndexScan: true); + SeedTwoInstances(db); + + using var connection = _postgres.OpenRawConnection("vs_postfilter"); + using (var drop = new NpgsqlCommand("DROP INDEX \"IX_ContentEmbeddings_InstanceId_ContentType\"", connection)) + drop.ExecuteNonQuery(); + + using (var off = new NpgsqlCommand("SET hnsw.iterative_scan = off", connection)) + off.ExecuteNonQuery(); + using (var command = CountFilteredNearest(connection)) + Assert.Equal(0, Convert.ToInt32(command.ExecuteScalar())); + + using (var on = new NpgsqlCommand("SET hnsw.iterative_scan = relaxed_order", connection)) + on.ExecuteNonQuery(); + using (var command = CountFilteredNearest(connection)) + Assert.Equal(0, Convert.ToInt32(command.ExecuteScalar())); + } + + [SkippableFact] + public async Task Search_WithTwoInstances_StillReturnsTheMinorityInstance() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_postfilter_on", forceIndexScan: true); + SeedTwoInstances(db); + + // Exactement le cas que le test précédent met en échec : la question vise + // l'axe saturé par l'autre instance. C'est ce que SearchAsync doit rattraper. + var results = await BuildService(db).SearchAsync("target", null, "fr", "axis:0"); + + Assert.Equal(5, results.Count); + // Et jamais une ligne de l'autre client, quoi qu'en dise la distance. + Assert.All(results, r => Assert.StartsWith("target-", r.ContentId)); + } + + [SkippableFact] + public async Task Search_HonoursTheConfiguredTopK() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_topk", forceIndexScan: true); + SeedTwoInstances(db); + + var results = await BuildService(db).SearchAsync("target", null, "fr", "axis:0", topK: 12); + + Assert.Equal(12, results.Count); + Assert.All(results, r => Assert.StartsWith("target-", r.ContentId)); + } + + [SkippableFact] + public async Task Search_DeduplicatesIdenticalTexts() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_dedup"); + var embeddings = new FakeEmbeddingService(); + + // Le même paragraphe recopié sur plusieurs contenus — la description d'un lieu + // reprise sur chacun de ses événements. + for (var i = 0; i < 6; i++) + db.ContentEmbeddings.Add(Row(embeddings, "solo", $"c-{i}", 0, "axis:3")); + db.ContentEmbeddings.Add(Row(embeddings, "solo", "autre", 0, "axis:4")); + db.SaveChanges(); + + var results = await BuildService(db).SearchAsync("solo", null, "fr", "axis:3"); + + Assert.Equal(2, results.Count); + Assert.Equal(results.Select(r => r.Text).Distinct().Count(), results.Count); + } + + [SkippableFact] + public async Task ReplaceAsync_PurgesTheFormerChunks() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_replace"); + var service = BuildService(db); + + await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, new[] + { + new ContentChunk("axis:1", 0, null, "fr"), + new ContentChunk("axis:2", 1, null, "fr"), + new ContentChunk("axis:3", 2, null, "fr") + }); + + // Une version plus courte : sans purge, les morceaux 1 et 2 resteraient et + // continueraient d'alimenter les réponses avec du contenu supprimé. + await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, new[] + { + new ContentChunk("axis:1", 0, null, "fr") + }); + + Assert.Equal(1, db.ContentEmbeddings.Count(e => e.ContentId == "sect-1")); + } + + [SkippableFact] + public async Task ReplaceAsync_EmptyChunkList_PurgesWithoutLeavingGhosts() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_purge"); + var service = BuildService(db); + + await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, new[] + { + new ContentChunk("axis:1", 0, null, "fr") + }); + await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, Array.Empty()); + + Assert.Empty(db.ContentEmbeddings.Where(e => e.ContentId == "sect-1").ToList()); + } + + [SkippableFact] + public async Task ReplaceAsync_IsIdempotent_AgainstTheUniqueIndex() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("vs_idempotent"); + var service = BuildService(db); + + var chunks = new[] + { + new ContentChunk("axis:1", 0, null, "fr"), + new ContentChunk("axis:2", 1, null, "fr") + }; + + // Hangfire rejoue les jobs : un second passage ne doit pas dupliquer les + // morceaux, qui remonteraient ensuite deux fois dans chaque réponse. + await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, chunks); + await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, chunks); + + Assert.Equal(2, db.ContentEmbeddings.Count(e => e.ContentId == "sect-1")); + } + } +}