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>
This commit is contained in:
Thomas Fransolet 2026-08-11 10:48:10 +02:00
parent af6b5b76ef
commit 18e4240f0f
44 changed files with 1768 additions and 134 deletions

4
.gitignore vendored
View File

@ -60,3 +60,7 @@ ManagerService/obj
# MongoDB export data (données sensibles — ne jamais committer) # MongoDB export data (données sensibles — ne jamais committer)
migration-data/ migration-data/
# Dumps PostgreSQL (données clients — ne jamais committer)
*.dump
*.sql.gz

View File

@ -5,6 +5,7 @@ using ManagerService.Services;
using ManagerService.Tests.Infrastructure; using ManagerService.Tests.Infrastructure;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Hangfire;
using Moq; using Moq;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -28,7 +29,8 @@ namespace ManagerService.Tests.Controllers
.Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>())) .Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>()))
.ReturnsAsync(FakeResponse); .ReturnsAsync(FakeResponse);
var controller = new AiController(mockService.Object, db, NullLogger<AiController>.Instance); var controller = new AiController(mockService.Object, db, NullLogger<AiController>.Instance,
new Mock<IBackgroundJobClient>().Object);
FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId)); FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId));
return controller; return controller;
} }
@ -112,7 +114,7 @@ namespace ManagerService.Tests.Controllers
db.Instances.Add(new Instance db.Instances.Add(new Instance
{ {
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow, Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensThisMonth = 99, AiUsageMonthKey = "2020-01" AiTokensThisMonth = 99, AiTokensPerMonth = 1_000, AiUsageMonthKey = "2020-01"
}); });
db.ApplicationInstances.Add(new ApplicationInstance db.ApplicationInstances.Add(new ApplicationInstance
{ {
@ -136,7 +138,7 @@ namespace ManagerService.Tests.Controllers
db.Instances.Add(new Instance db.Instances.Add(new Instance
{ {
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow, Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensThisMonth = 3, AiUsageMonthKey = monthKey AiTokensThisMonth = 3, AiTokensPerMonth = 1_000, AiUsageMonthKey = monthKey
}); });
db.ApplicationInstances.Add(new ApplicationInstance db.ApplicationInstances.Add(new ApplicationInstance
{ {
@ -177,6 +179,34 @@ namespace ManagerService.Tests.Controllers
mockService.Verify(s => s.ChatAsync(It.IsAny<AiChatRequest>()), Times.Never); mockService.Verify(s => s.ChatAsync(It.IsAny<AiChatRequest>()), Times.Never);
} }
[Fact]
public async Task Chat_PlanWithoutAi_ReturnsForbiddenWithoutCallingAssistantService()
{
using var db = DbContextFactory.Create();
db.Instances.Add(new Instance
{
// AiTokensPerMonth à 0 = pas d'IA dans le plan, et surtout pas « illimité » :
// c'est l'état de plan-starter et de toute instance fraîchement migrée.
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensPerMonth = 0, AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM")
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true,
Languages = new List<string>()
});
db.SaveChanges();
var mockService = new Mock<IAssistantService>();
mockService.Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>())).ReturnsAsync(FakeResponse);
var result = await BuildController(db, mockService).Chat(MakeRequest("i1"));
var status = Assert.IsType<ObjectResult>(result);
Assert.Equal(403, status.StatusCode);
mockService.Verify(s => s.ChatAsync(It.IsAny<AiChatRequest>()), Times.Never);
}
// ── NOMINAL ────────────────────────────────────────────────────────── // ── NOMINAL ──────────────────────────────────────────────────────────
[Fact] [Fact]
@ -189,7 +219,7 @@ namespace ManagerService.Tests.Controllers
db.Instances.Add(new Instance db.Instances.Add(new Instance
{ {
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow, Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM") AiTokensPerMonth = 1_000, AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM")
}); });
db.ApplicationInstances.Add(new ApplicationInstance db.ApplicationInstances.Add(new ApplicationInstance
{ {

View File

@ -1,4 +1,6 @@
using Hangfire;
using Manager.Services; using Manager.Services;
using Manager.Interfaces.Models;
using ManagerService.Controllers; using ManagerService.Controllers;
using ManagerService.Data; using ManagerService.Data;
using ManagerService.DTOs; using ManagerService.DTOs;
@ -7,13 +9,14 @@ using ManagerService.Services;
using ManagerService.Tests.Infrastructure; using ManagerService.Tests.Infrastructure;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit; using Xunit;
namespace ManagerService.Tests.Controllers namespace ManagerService.Tests.Controllers
{ {
public class InstanceControllerTests public class InstanceControllerTests
{ {
private InstanceController BuildController(MyInfoMateDbContext db) private InstanceController BuildController(MyInfoMateDbContext db, Mock<IBackgroundJobClient> jobs = null)
{ {
var cfg = FakeMongoConfig.Create(); var cfg = FakeMongoConfig.Create();
var instanceService = new InstanceDatabaseService(cfg); var instanceService = new InstanceDatabaseService(cfg);
@ -27,7 +30,8 @@ namespace ManagerService.Tests.Controllers
userService, userService,
profileLogic, profileLogic,
db, db,
apiKeyService); apiKeyService,
(jobs ?? new Mock<IBackgroundJobClient>()).Object);
} }
// ── CREATE ─────────────────────────────────────────────────────────── // ── CREATE ───────────────────────────────────────────────────────────
@ -96,6 +100,62 @@ namespace ManagerService.Tests.Controllers
Assert.Equal("p1", db.Instances.First().SubscriptionPlanId); Assert.Equal("p1", db.Instances.First().SubscriptionPlanId);
} }
[Fact]
public void Updateinstance_ChangingPlan_CopiesPlanQuotasOntoInstance()
{
using var db = DbContextFactory.Create();
db.SubscriptionPlans.Add(new SubscriptionPlan
{
Id = "p-premium", Name = "Premium",
StorageQuotaBytes = 50_000, AiTokensPerMonth = 20_000, HasStats = true, HasAdvancedStats = true
});
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
db.SaveChanges();
BuildController(db).Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "p-premium" });
// CreateInstance recopiait les quotas du plan, pas Update : un client passé à un plan
// payant gardait 0 jeton IA, donc pas de guide, tout en payant pour.
var instance = db.Instances.First();
Assert.Equal(20_000, instance.AiTokensPerMonth);
Assert.Equal(50_000, instance.StorageQuotaBytes);
Assert.True(instance.HasAdvancedStats);
}
[Fact]
public void Updateinstance_GainingAiQuota_EnqueuesBackfill()
{
using var db = DbContextFactory.Create();
db.SubscriptionPlans.Add(new SubscriptionPlan { Id = "p-premium", Name = "Premium", AiTokensPerMonth = 20_000 });
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow, AiTokensPerMonth = 0 });
db.SaveChanges();
var jobs = new Mock<IBackgroundJobClient>();
BuildController(db, jobs).Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "p-premium" });
// Rien n'a été indexé tant que l'instance n'avait pas droit à l'IA : sans ce
// rattrapage le client paie un guide qui ne connaît rien de son contenu.
jobs.Verify(j => j.Create(It.IsAny<Hangfire.Common.Job>(), It.IsAny<Hangfire.States.IState>()), Times.Once);
}
[Fact]
public void Updateinstance_PlanUnchanged_DoesNotEnqueueBackfill()
{
using var db = DbContextFactory.Create();
db.SubscriptionPlans.Add(new SubscriptionPlan { Id = "p-premium", Name = "Premium", AiTokensPerMonth = 20_000 });
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow,
SubscriptionPlanId = "p-premium", AiTokensPerMonth = 20_000
});
db.SaveChanges();
var jobs = new Mock<IBackgroundJobClient>();
BuildController(db, jobs).Updateinstance(new InstanceDTO { id = "i1", name = "Musée d'Ixelles" });
jobs.Verify(j => j.Create(It.IsAny<Hangfire.Common.Job>(), It.IsAny<Hangfire.States.IState>()), Times.Never);
}
[Fact] [Fact]
public void Updateinstance_UnknownId_Returns404() public void Updateinstance_UnknownId_Returns404()
{ {

View File

@ -63,6 +63,47 @@ namespace ManagerService.Tests.Controllers
Assert.Single(resources); Assert.Single(resources);
} }
// ── CREATE ───────────────────────────────────────────────────────────
[Fact]
public void Create_FileType_SetsStoragePathAndSizeBytes()
{
using var db = DbContextFactory.Create();
var result = BuildController(db).Create(new ResourceDTO
{
instanceId = "inst-test",
label = "brochure",
type = ResourceType.PDF,
sizeBytes = 12345
});
Assert.IsType<OkObjectResult>(result);
var created = db.Resources.First();
Assert.Equal($"pictures/inst-test/{created.Id}", created.StoragePath);
Assert.Equal(12345, created.SizeBytes);
}
[Fact]
public void Create_UrlType_LeavesStoragePathAndSizeBytesEmpty()
{
using var db = DbContextFactory.Create();
var result = BuildController(db).Create(new ResourceDTO
{
instanceId = "inst-test",
label = "wikipedia",
type = ResourceType.ImageUrl,
url = "https://example.org/photo.jpg",
sizeBytes = 12345
});
Assert.IsType<OkObjectResult>(result);
var created = db.Resources.First();
Assert.Null(created.StoragePath);
Assert.Equal(0, created.SizeBytes);
}
// ── UPDATE ─────────────────────────────────────────────────────────── // ── UPDATE ───────────────────────────────────────────────────────────
[Fact] [Fact]
@ -109,7 +150,9 @@ namespace ManagerService.Tests.Controllers
using var db = DbContextFactory.Create(); using var db = DbContextFactory.Create();
db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image }); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image });
db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", Title = new List<TranslationDTO>() }); db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", Title = new List<TranslationDTO>() });
db.Sections.Add(new Section { Id = "s1", InstanceId = "inst-test", Label = "S", ImageId = "r1", Type = SectionType.Article, ConfigurationId = "c1", Title = new List<TranslationDTO>(), Description = new List<TranslationDTO>() }); var section = TestSection.Article("s1", "inst-test", "S", "c1");
section.ImageId = "r1";
db.Sections.Add(section);
db.SaveChanges(); db.SaveChanges();
BuildController(db).Delete("r1"); BuildController(db).Delete("r1");

View File

@ -45,8 +45,8 @@ namespace ManagerService.Tests.Controllers
{ {
using var db = DbContextFactory.Create(); using var db = DbContextFactory.Create();
db.Sections.AddRange( db.Sections.AddRange(
new Section { Id = "s1", InstanceId = "inst-test", Label = "A", Type = SectionType.Article, ConfigurationId = "c1", Title = new List<TranslationDTO>(), Description = new List<TranslationDTO>() }, TestSection.Article("s1", "inst-test", "A", "c1"),
new Section { Id = "s2", InstanceId = "other-inst", Label = "B", Type = SectionType.Article, ConfigurationId = "c2", Title = new List<TranslationDTO>(), Description = new List<TranslationDTO>() } TestSection.Article("s2", "other-inst", "B", "c2")
); );
db.SaveChanges(); db.SaveChanges();
@ -102,7 +102,7 @@ namespace ManagerService.Tests.Controllers
public void Delete_ExistingSection_Returns202() public void Delete_ExistingSection_Returns202()
{ {
using var db = DbContextFactory.Create(); using var db = DbContextFactory.Create();
db.Sections.Add(new Section { Id = "s1", InstanceId = "inst-test", Label = "A", Type = SectionType.Article, ConfigurationId = "c1", Title = new List<TranslationDTO>(), Description = new List<TranslationDTO>() }); db.Sections.Add(TestSection.Article("s1", "inst-test", "A", "c1"));
db.SaveChanges(); db.SaveChanges();
var result = BuildController(db).Delete("s1"); var result = BuildController(db).Delete("s1");

View File

@ -0,0 +1,29 @@
using Manager.DTOs;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using System.Collections.Generic;
namespace ManagerService.Tests.Infrastructure
{
/// <summary>
/// Section concrète minimale pour les tests qui ne s'intéressent qu'aux champs de base.
/// Section est abstraite depuis l'ajout de GetEmbeddableText / GetReferencedResourceIds.
/// </summary>
public static class TestSection
{
public static SectionArticle Article(string id, string instanceId, string label, string configurationId) =>
new SectionArticle
{
Id = id,
InstanceId = instanceId,
Label = label,
ConfigurationId = configurationId,
Type = SectionType.Article,
Title = new List<TranslationDTO>(),
Description = new List<TranslationDTO>(),
ArticleContent = new List<TranslationDTO>(),
ArticleAudioIds = new List<TranslationDTO>(),
ArticleContents = new List<ContentDTO>()
};
}
}

View File

@ -1,3 +1,4 @@
using Hangfire;
using ManagerService.Data; using ManagerService.Data;
using ManagerService.DTOs; using ManagerService.DTOs;
using ManagerService.Services; using ManagerService.Services;
@ -7,6 +8,7 @@ using Microsoft.Extensions.Logging;
using NSwag.Annotations; using NSwag.Annotations;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -27,15 +29,20 @@ namespace ManagerService.Controllers
private readonly IAssistantService _assistantService; private readonly IAssistantService _assistantService;
private readonly MyInfoMateDbContext _context; private readonly MyInfoMateDbContext _context;
private readonly ILogger<AiController> _logger; private readonly ILogger<AiController> _logger;
// Injecté plutôt qu'appelé via la façade statique BackgroundJob : celle-ci lève
// sans JobStorage.Current, donc dans tout test qui touche cet endpoint.
private readonly IBackgroundJobClient _jobs;
public AiController( public AiController(
IAssistantService assistantService, IAssistantService assistantService,
MyInfoMateDbContext context, MyInfoMateDbContext context,
ILogger<AiController> logger) ILogger<AiController> logger,
IBackgroundJobClient jobs)
{ {
_assistantService = assistantService; _assistantService = assistantService;
_context = context; _context = context;
_logger = logger; _logger = logger;
_jobs = jobs;
} }
private string? GetCallerInstanceId() => private string? GetCallerInstanceId() =>
@ -60,7 +67,15 @@ namespace ManagerService.Controllers
} }
var quota = instance.AiTokensPerMonth; var quota = instance.AiTokensPerMonth;
if (quota > 0 && instance.AiTokensThisMonth >= quota)
// 0 ne veut pas dire « illimité » ici — contrairement à StorageQuotaBytes, où 0 lève
// la limite. C'est « pas d'IA dans ce plan » : plan-starter est à 0, et une instance
// migrée depuis Mongo l'est aussi tant que son plan n'est pas repris (§1quinquies, c).
// Sans ce test, une telle instance avec IsAssistant à true consommait sans compteur.
if (quota <= 0)
return StatusCode(403, "L'assistant IA n'est pas inclus dans ce plan");
if (instance.AiTokensThisMonth >= quota)
return StatusCode(429, "Quota IA mensuel dépassé"); return StatusCode(429, "Quota IA mensuel dépassé");
if (instance.IsTrialActive && instance.TrialAiTokensUsed >= TrialAiTokensCap) if (instance.IsTrialActive && instance.TrialAiTokensUsed >= TrialAiTokensCap)
@ -77,6 +92,211 @@ namespace ManagerService.Controllers
_context.SaveChanges(); _context.SaveChanges();
} }
/// <summary>
/// Journalise un tour de conversation. Ne fait jamais échouer la réponse au visiteur :
/// un incident de journalisation ne doit pas coûter un échange déjà payé au modèle.
/// </summary>
/// <remarks>
/// ⚠️ **RGPD — le volet CGU doit être en ligne avant la mise en service.** Cette méthode
/// enregistre du texte libre saisi par un visiteur, qui peut contenir des données
/// personnelles voire sensibles (« je suis en fauteuil, c'est accessible ? »). Rien de
/// nominatif n'est stocké — <c>ConversationId</c> est un GUID de session — et les
/// questions brutes se purgent à 90 jours, mais l'information doit être donnée.
///
/// <c>HasAnswer</c> se déduit des sources : le guide n'a rien trouvé quand la recherche
/// n'a rien remonté. C'est la colonne qui produit le rapport de trous de contenu, donc
/// l'argument de vente de tout l'onglet — la déduire du texte de la réponse serait
/// fragile, un repli poli ressemblant à une vraie réponse.
/// </remarks>
private void RecordVisitorQuestion(AiChatRequest request, AiChatResponse result)
{
try
{
var sources = result.Sources ?? new List<AiSourceDTO>();
_context.VisitorQuestions.Add(new VisitorQuestion
{
ConversationId = string.IsNullOrWhiteSpace(request.ConversationId)
? Guid.NewGuid().ToString()
: request.ConversationId,
InstanceId = request.InstanceId,
ConfigurationId = request.ConfigurationId,
AppType = request.AppType,
IsVoice = request.IsVoice,
Language = request.Language,
Question = request.Message,
Reply = result.Reply,
TokensUsed = result.TokensUsed,
HasAnswer = sources.Count > 0,
CitedContentIds = sources.Select(s => s.ContentId).Distinct().ToList(),
CreatedAt = DateTime.UtcNow
});
_context.SaveChanges();
}
catch (Exception ex)
{
_logger.LogError(ex, "Journalisation de la question visiteur impossible");
}
}
/// <summary>
/// Relance l'indexation complète du contenu d'une instance pour le guide IA.
/// </summary>
/// <remarks>
/// Réservé au SuperAdmin, volontairement. C'est un outil de réparation, pas une
/// fonctionnalité : exposé au client, il serait cliqué à chaque réponse décevante du
/// guide — alors qu'une mauvaise réponse vient presque toujours d'un contenu trop
/// maigre, pas d'un index périmé. Chaque relance recoûte un embedding par morceau de
/// toute l'instance, et c'est un moyen trivial de brûler le budget.
/// Le rattrapage normal est automatique au passage à un plan avec IA (InstanceController).
/// </remarks>
[HttpPost("reindex/{instanceId}")]
[Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)]
[ProducesResponseType(typeof(object), 202)]
[ProducesResponseType(typeof(string), 403)]
[ProducesResponseType(typeof(string), 404)]
public ObjectResult Reindex(string instanceId)
{
var instance = _context.Instances.FirstOrDefault(i => i.Id == instanceId);
if (instance == null)
return new NotFoundObjectResult("Instance inconnue");
// Le job s'arrêterait de toute façon sur la même garde — autant le dire tout de suite.
if (instance.AiTokensPerMonth <= 0)
return new ObjectResult("L'assistant IA n'est pas inclus dans le plan de cette instance") { StatusCode = 403 };
var sectionCount = _context.Sections.Count(s => s.InstanceId == instanceId);
var jobId = _jobs.Enqueue<IIngestionService>(s => s.BackfillInstanceAsync(instanceId));
// Le nombre de morceaux n'est connu qu'à l'exécution : il part dans les logs et dans
// /hangfire. On rend ici de quoi savoir si la relance avait la moindre matière.
return new ObjectResult(new { jobId, sectionsQueued = sectionCount }) { StatusCode = 202 };
}
/// <summary>
/// Ce que le guide connaît réellement d'une instance, mesuré sur l'index vectoriel.
/// </summary>
/// <remarks>
/// Tout vient de <c>ContentEmbedding</c> : ce sont les morceaux réellement indexés, donc
/// réellement interrogeables. Compter les sections dans <c>Sections</c> donnerait un chiffre
/// plus flatteur et faux — une section désactivée est purgée de l'index, une section sans
/// texte exploitable n'y entre jamais.
///
/// Le nombre de morceaux remplace les « points d'intérêt » de la maquette : les points d'une
/// carte sont indexés dans le texte de leur SectionMap, pas comme des contenus autonomes.
/// Les compter dans leur propre table répondrait « combien en avez-vous », pas « qu'est-ce
/// que le guide en sait » — et afficherait des points appartenant à une section non indexée.
/// </remarks>
[HttpGet("knowledge/{instanceId}")]
[ProducesResponseType(typeof(GuideKnowledgeDTO), 200)]
[ProducesResponseType(typeof(string), 403)]
public async Task<IActionResult> Knowledge(string instanceId)
{
if (!IsSuperAdmin() && GetCallerInstanceId() != instanceId)
return StatusCode(403, "Instance non autorisée");
var scope = _context.ContentEmbeddings.Where(e => e.InstanceId == instanceId);
return Ok(new GuideKnowledgeDTO
{
indexedSections = await scope
.Where(e => e.ContentType == ContentSourceType.Section)
.Select(e => e.ContentId)
.Distinct()
.CountAsync(),
chunks = await scope.CountAsync(),
languages = await scope
.Select(e => e.Language)
.Distinct()
.OrderBy(l => l)
.ToListAsync(),
lastIndexedAt = await scope
.MaxAsync(e => (DateTime?)e.UpdatedAt)
});
}
/// <summary>
/// Ce que les visiteurs ont demandé au guide sur les 30 derniers jours.
/// </summary>
/// <remarks>
/// Remplit exactement la structure attendue par l'onglet « Ce que demandent vos visiteurs »
/// de manager-app (<c>GuideIaInsights</c>) — c'est l'affichage qui a fixé le contrat.
///
/// <c>topics</c> reste vide tant que le job de regroupement en thèmes n'a pas tourné :
/// <c>ThemeId</c> est nul à l'écriture, rempli a posteriori. L'écran dégrade proprement.
///
/// Les questions sans réponse sont regroupées sur leur texte exact. Un regroupement
/// sémantique dirait mieux la même chose, mais il coûte un embedding par question et
/// c'est précisément le travail du job de thèmes — pas d'un endpoint de lecture.
/// </remarks>
[HttpGet("insights/{instanceId}")]
[ProducesResponseType(typeof(GuideInsightsDTO), 200)]
[ProducesResponseType(typeof(string), 403)]
public async Task<IActionResult> Insights(string instanceId, [FromQuery] int days = 30)
{
if (!IsSuperAdmin() && GetCallerInstanceId() != instanceId)
return StatusCode(403, "Instance non autorisée");
var since = DateTime.UtcNow.AddDays(-Math.Abs(days));
var scope = _context.VisitorQuestions
.Where(q => q.InstanceId == instanceId && q.CreatedAt >= since);
var citedIds = await scope
.SelectMany(q => q.CitedContentIds)
.ToListAsync();
// Les titres se résolvent en une seule requête, puis en mémoire : la liste des
// contenus cités est courte par nature, elle est déjà tronquée à 6.
var topCited = citedIds
.GroupBy(id => id)
.OrderByDescending(g => g.Count())
.Take(6)
.ToList();
var titles = await _context.Sections
.Where(s => topCited.Select(g => g.Key).Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Title);
return Ok(new GuideInsightsDTO
{
questions = await scope.CountAsync(),
unanswered = await scope.CountAsync(q => !q.HasAnswer),
themes = await scope.Where(q => q.ThemeId != null).Select(q => q.ThemeId).Distinct().CountAsync(),
languages = await scope.Select(q => q.Language).Distinct().CountAsync(),
unansweredQuestions = await scope
.Where(q => !q.HasAnswer)
.GroupBy(q => q.Question)
.Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(5)
.ToListAsync(),
topics = await scope
.Where(q => q.ThemeId != null)
.GroupBy(q => q.ThemeId)
.Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(6)
.ToListAsync(),
questionLanguages = await scope
.GroupBy(q => q.Language)
.Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.ToListAsync(),
citedContents = topCited
.Select(g => new CountedLabelDTO
{
label = titles.TryGetValue(g.Key, out var t) && t != null
? t.FirstOrDefault()?.value ?? g.Key
: g.Key,
count = g.Count()
})
.ToList()
});
}
/// <summary> /// <summary>
/// Traduit un texte HTML vers plusieurs langues via IA /// Traduit un texte HTML vers plusieurs langues via IA
/// </summary> /// </summary>
@ -147,6 +367,7 @@ namespace ManagerService.Controllers
var result = await _assistantService.ChatAsync(request); var result = await _assistantService.ChatAsync(request);
RecordUsage(instance, result.TokensUsed); RecordUsage(instance, result.TokensUsed);
RecordVisitorQuestion(request, result);
return Ok(result); return Ok(result);
} }

View File

@ -657,7 +657,7 @@ namespace ManagerService.Controllers
foreach (var section in exportConfiguration.sections.Where(s => !sectionsAlreadyInDB.Contains(s.id))) foreach (var section in exportConfiguration.sections.Where(s => !sectionsAlreadyInDB.Contains(s.id)))
{ {
Section newSection = new Section(); Section newSection = SectionFactory.CreateEmpty(section.type);
newSection.Id = section.id; newSection.Id = section.id;
newSection.InstanceId = section.instanceId; newSection.InstanceId = section.instanceId;
newSection.Label = section.label; newSection.Label = section.label;

View File

@ -1,7 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Hangfire;
using Manager.Services; using Manager.Services;
using ManagerService.Data; using ManagerService.Data;
using ManagerService.DTOs; using ManagerService.DTOs;
@ -27,9 +28,12 @@ namespace ManagerService.Controllers
private readonly ILogger<InstanceController> _logger; private readonly ILogger<InstanceController> _logger;
private readonly ProfileLogic _profileLogic; private readonly ProfileLogic _profileLogic;
private readonly ApiKeyDatabaseService _apiKeyService; private readonly ApiKeyDatabaseService _apiKeyService;
// Injecté plutôt qu'appelé via la façade statique BackgroundJob : celle-ci lève
// sans JobStorage.Current, donc dans tout test qui touche cet endpoint.
private readonly IBackgroundJobClient _jobs;
IHexIdGeneratorService idService = new HexIdGeneratorService(); IHexIdGeneratorService idService = new HexIdGeneratorService();
public InstanceController(ILogger<InstanceController> logger, InstanceDatabaseService instanceService, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext, ApiKeyDatabaseService apiKeyService) public InstanceController(ILogger<InstanceController> logger, InstanceDatabaseService instanceService, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext, ApiKeyDatabaseService apiKeyService, IBackgroundJobClient jobs)
{ {
_logger = logger; _logger = logger;
_instanceService = instanceService; _instanceService = instanceService;
@ -37,6 +41,7 @@ namespace ManagerService.Controllers
_profileLogic = profileLogic; _profileLogic = profileLogic;
_myInfoMateDbContext = myInfoMateDbContext; _myInfoMateDbContext = myInfoMateDbContext;
_apiKeyService = apiKeyService; _apiKeyService = apiKeyService;
_jobs = jobs;
} }
/// <summary> /// <summary>
@ -94,10 +99,32 @@ namespace ManagerService.Controllers
} }
} }
/// <summary>
/// Recopie sur l'instance les valeurs portées par son plan. Elles sont dupliquées
/// volontairement — un client peut recevoir un geste commercial sans changer de plan —
/// mais elles doivent repartir du plan à chaque changement, sinon l'instance garde
/// les quotas de l'ancien.
/// </summary>
private void ApplyPlanQuotas(Instance instance)
{
if (instance.SubscriptionPlanId == null)
return;
var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId);
if (plan == null)
return;
instance.StorageQuotaBytes = plan.StorageQuotaBytes;
instance.AiTokensPerMonth = plan.AiTokensPerMonth;
instance.HasStats = plan.HasStats;
instance.StatsHistoryDays = plan.StatsHistoryDays;
instance.HasAdvancedStats = plan.HasAdvancedStats;
}
/// <summary> /// <summary>
/// Create an instance /// Create an instance
/// </summary> /// </summary>
/// <param name="newInstance">New instance info</param> /// <param name="newInstance">New instance info</param>
//[AllowAnonymous] //[AllowAnonymous]
[ProducesResponseType(typeof(InstanceDTO), 200)] [ProducesResponseType(typeof(InstanceDTO), 200)]
[ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 400)]
@ -117,18 +144,7 @@ namespace ManagerService.Controllers
instance.Id = idService.GenerateHexId(); instance.Id = idService.GenerateHexId();
// Copier les valeurs du plan comme valeurs par défaut // Copier les valeurs du plan comme valeurs par défaut
if (instance.SubscriptionPlanId != null) ApplyPlanQuotas(instance);
{
var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId);
if (plan != null)
{
instance.StorageQuotaBytes = plan.StorageQuotaBytes;
instance.AiTokensPerMonth = plan.AiTokensPerMonth;
instance.HasStats = plan.HasStats;
instance.StatsHistoryDays = plan.StatsHistoryDays;
instance.HasAdvancedStats = plan.HasAdvancedStats;
}
}
/*List<OldInstance> instances = _instanceService.GetAll(); /*List<OldInstance> instances = _instanceService.GetAll();
Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == id);*/ Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == id);*/
@ -195,14 +211,33 @@ namespace ManagerService.Controllers
instance.IsWeb = updatedInstance.isWeb ?? instance.IsWeb; instance.IsWeb = updatedInstance.isWeb ?? instance.IsWeb;
instance.IsVR = updatedInstance.isVR ?? instance.IsVR; instance.IsVR = updatedInstance.isVR ?? instance.IsVR;
instance.IsAssistant = updatedInstance.isAssistant ?? instance.IsAssistant; instance.IsAssistant = updatedInstance.isAssistant ?? instance.IsAssistant;
var previousPlanId = instance.SubscriptionPlanId;
var previousAiTokens = instance.AiTokensPerMonth;
if (updatedInstance.subscriptionPlanId == "") if (updatedInstance.subscriptionPlanId == "")
instance.SubscriptionPlanId = null; instance.SubscriptionPlanId = null;
else if (updatedInstance.subscriptionPlanId != null) else if (updatedInstance.subscriptionPlanId != null)
instance.SubscriptionPlanId = updatedInstance.subscriptionPlanId; instance.SubscriptionPlanId = updatedInstance.subscriptionPlanId;
// CreateInstance recopie les valeurs du plan, pas Update : changer un client de
// Starter à Premium ne lui donnait donc ni stockage ni jetons IA supplémentaires.
// L'endpoint /quota masquait la moitié du problème en retombant sur le plan à la
// lecture, mais AiController lit `instance.AiTokensPerMonth` — un client passé à
// un plan payant restait à 0, donc sans IA.
if (instance.SubscriptionPlanId != previousPlanId)
ApplyPlanQuotas(instance);
//OldInstance instanceModified = _instanceService.Update(updatedInstance.Id, instance); //OldInstance instanceModified = _instanceService.Update(updatedInstance.Id, instance);
_myInfoMateDbContext.SaveChanges(); _myInfoMateDbContext.SaveChanges();
// Le contenu déjà créé n'a jamais été indexé tant que l'instance n'avait pas
// droit à l'IA : sans ce rattrapage, le client paie un guide qui ne connaît rien.
if (previousAiTokens <= 0 && instance.AiTokensPerMonth > 0)
{
var backfilledInstanceId = instance.Id;
_jobs.Enqueue<IIngestionService>(s => s.BackfillInstanceAsync(backfilledInstanceId));
}
var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList(); var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList();
return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList())); return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()));

View File

@ -339,6 +339,16 @@ namespace ManagerService.Controllers
resource.InstanceId = newResource.instanceId; resource.InstanceId = newResource.instanceId;
resource.Id = idService.GenerateHexId(); resource.Id = idService.GenerateHexId();
// Les types URL pointent hors du bucket : ni chemin de stockage, ni poids à compter dans le quota.
bool hasBlob = resource.Type != ResourceType.ImageUrl
&& resource.Type != ResourceType.VideoUrl
&& resource.Type != ResourceType.JSONUrl;
if (hasBlob)
{
resource.StoragePath = $"pictures/{resource.InstanceId}/{resource.Id}";
resource.SizeBytes = newResource.sizeBytes;
}
_myInfoMateDbContext.Add(resource); _myInfoMateDbContext.Add(resource);
_myInfoMateDbContext.SaveChanges(); _myInfoMateDbContext.SaveChanges();
//OldResource resourceCreated = _resourceService.Create(resource); //OldResource resourceCreated = _resourceService.Create(resource);

View File

@ -533,100 +533,14 @@ namespace ManagerService.Controllers
if (configuration == null) if (configuration == null)
throw new KeyNotFoundException("Configuration does not exist"); throw new KeyNotFoundException("Configuration does not exist");
// Todo add some verification ?
Section section = new Section();
// Preparation // Preparation
List<string> languages = _configuration.GetSection("SupportedLanguages").Get<List<string>>(); List<string> languages = _configuration.GetSection("SupportedLanguages").Get<List<string>>();
switch (newSection.type) Section section = SectionFactory.CreateEmpty(newSection.type);
if (section is SectionArticle article)
{ {
case SectionType.Agenda: article.ArticleContent = LanguageInit.Init("Content", languages);
section = new SectionAgenda article.ArticleAudioIds = LanguageInit.Init("Audio", languages, true);
{
AgendaResourceIds = new List<TranslationDTO>(),
EventAgendas = new List<EventAgenda>()
};
break;
case SectionType.Article:
section = new SectionArticle
{
ArticleContents = new List<ContentDTO>(),
ArticleContent = LanguageInit.Init("Content", languages),
ArticleAudioIds = LanguageInit.Init("Audio", languages, true)
};
break;
case SectionType.Event:
section = new SectionEvent
{
Programme = new List<ProgrammeBlock>(),
ParcoursIds = new List<string>()
};
break;
case SectionType.Map:
section = new SectionMap
{
MapMapType = MapTypeApp.hybrid,
MapTypeMapbox = MapTypeMapBox.standard,
MapMapProvider = MapProvider.Google,
MapZoom = 18,
MapPoints = new List<GeoPoint>(),
MapCategories = new List<CategorieDTO>()
};
break;
case SectionType.Menu:
section = new SectionMenu
{
MenuSections = new List<Section>(),
};
break;
case SectionType.PDF:
section = new SectionPdf
{
PDFOrderedTranslationAndResources = []
};
break;
case SectionType.Game:
section = new SectionGame
{
GameMessageDebut = [],
GameMessageFin = []
};
break;
case SectionType.Quiz:
section = new SectionQuiz
{
QuizQuestions = new List<QuizQuestion>(),
// TODO levels ?
};
break;
case SectionType.Slider:
section = new SectionSlider
{
SliderContents = new List<ContentDTO>()
};
break;
case SectionType.Video:
section = new SectionVideo
{
VideoSource = "",
};
break;
case SectionType.Weather:
section = new SectionWeather();
break;
case SectionType.Web:
section = new SectionWeb
{
WebSource = "",
};
break;
case SectionType.Parcours:
section = new SectionParcours
{
GuidedPaths = new List<GuidedPath>()
};
break;
} }
section.InstanceId = newSection.instanceId; section.InstanceId = newSection.instanceId;
@ -816,12 +730,16 @@ namespace ManagerService.Controllers
MqttClientService.PublishMessage($"config/{existingSection.ConfigurationId}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true })); MqttClientService.PublishMessage($"config/{existingSection.ConfigurationId}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true }));
if (existingSection.Type == SectionType.Agenda) // Un agenda en ligne doit d'abord rapatrier ses événements : la ré-indexation
{ // suivra le SaveChanges d'AgendaSyncService, donc sur les dates fraîches.
var sa = _myInfoMateDbContext.Sections.OfType<SectionAgenda>().FirstOrDefault(s => s.Id == existingSection.Id); // Pour tous les autres cas, SectionIndexingInterceptor a déjà pris le relais
if (sa?.IsOnlineAgenda == true && sa.AgendaResourceIds?.Count > 0) // au SaveChanges ci-dessus — rien à enqueue ici.
BackgroundJob.Enqueue<AgendaSyncService>(s => s.SyncSectionAsync(sa.Id)); var syncedAgenda = existingSection.Type == SectionType.Agenda
} && _myInfoMateDbContext.Sections.OfType<SectionAgenda>()
.Any(s => s.Id == existingSection.Id && s.IsOnlineAgenda && s.AgendaResourceIds.Count > 0);
if (syncedAgenda)
BackgroundJob.Enqueue<AgendaSyncService>(s => s.SyncSectionAsync(existingSection.Id));
} }
return new OkObjectResult(SectionFactory.ToDTO(existingSection)); return new OkObjectResult(SectionFactory.ToDTO(existingSection));

View File

@ -58,6 +58,24 @@ namespace ManagerService.DTOs
/// </summary> /// </summary>
public bool ExpectsReply { get; set; } = true; public bool ExpectsReply { get; set; } = true;
public long TokensUsed { get; set; } public long TokensUsed { get; set; }
/// <summary>
/// Contenus sur lesquels la réponse s'appuie, relevés côté serveur à chaque appel de
/// SearchKnowledge. Ils ne passent pas par le modèle : la règle « ne mentionne jamais
/// les identifiants techniques » lui interdit précisément de les recopier.
/// Destinés au client, pas au visiteur — c'est ce qui rend une réponse vérifiable
/// quand un client conteste ce que dit son guide.
/// </summary>
public List<AiSourceDTO>? Sources { get; set; }
}
public class AiSourceDTO
{
public string ContentId { get; set; }
public string ContentType { get; set; }
/// <summary>Null hors document paginé.</summary>
public int? PageNumber { get; set; }
} }
public class AiTranslateRequest public class AiTranslateRequest

View File

@ -0,0 +1,32 @@
using System.Collections.Generic;
namespace ManagerService.DTOs
{
/// <summary>
/// Ce que les visiteurs ont demandé au guide sur une période.
/// Miroir exact de `GuideIaInsights` dans manager-app : c'est l'écran qui a fixé la forme,
/// pour que le job de regroupement produise ce qui s'affiche plutôt que l'inverse.
/// </summary>
public class GuideInsightsDTO
{
public int questions { get; set; }
public int unanswered { get; set; }
public int themes { get; set; }
public int languages { get; set; }
/// <summary>Les trous de contenu : ce que les visiteurs cherchent sans le trouver.</summary>
public List<CountedLabelDTO> unansweredQuestions { get; set; } = new();
/// <summary>Vide tant que le job de regroupement en thèmes n'a pas tourné.</summary>
public List<CountedLabelDTO> topics { get; set; } = new();
public List<CountedLabelDTO> questionLanguages { get; set; } = new();
public List<CountedLabelDTO> citedContents { get; set; } = new();
}
public class CountedLabelDTO
{
public string label { get; set; }
public int count { get; set; }
}
}

View File

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace ManagerService.DTOs
{
/// <summary>
/// Mesure de l'index vectoriel d'une instance : ce que le guide IA sait réellement.
/// Tous les chiffres viennent de ContentEmbedding, pas des tables de contenu.
/// </summary>
public class GuideKnowledgeDTO
{
public int indexedSections { get; set; }
public int chunks { get; set; }
public List<string> languages { get; set; } = new();
/// <summary>Null tant que rien n'a été indexé.</summary>
public DateTime? lastIndexedAt { get; set; }
}
}

View File

@ -1,8 +1,10 @@
using ManagerService.DTOs; using Manager.DTOs;
using ManagerService.DTOs;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -12,7 +14,7 @@ namespace ManagerService.Data
/// Section Information /// Section Information
/// </summary> /// </summary>
[Index(nameof(InstanceId))] [Index(nameof(InstanceId))]
public class Section : IAuditableEntity public abstract class Section : IAuditableEntity
{ {
[Key] [Key]
[Required] [Required]
@ -31,7 +33,7 @@ namespace ManagerService.Data
public int Order { get; set; } public int Order { get; set; }
[Required] [Required]
public string ConfigurationId { get; set; } // Parent id public string ConfigurationId { get; set; } // Parent id
public string ImageId { get; set; } public string ImageId { get; set; }
@ -64,6 +66,30 @@ namespace ManagerService.Data
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
/// <summary>
/// Texte de la section dans une langue, tel qu'il sera indexé pour le guide IA.
/// Rend une chaîne vide quand la section ne porte rien d'exploitable dans cette langue.
/// </summary>
/// <remarks>
/// Abstraite, et pas virtuelle avec un comportement par défaut : la collecte des
/// ressources vivait dans un switch centralisé, un nouveau sous-type n'obligeait
/// personne à le mettre à jour, et il est devenu faux en silence. Ici le compilateur
/// réclame l'implémentation.
/// </remarks>
public abstract string GetEmbeddableText(string language);
/// <summary>
/// Ids des ressources référencées par la section, pour l'export hors ligne.
/// <paramref name="language"/> null rend toutes les langues.
/// </summary>
public abstract IEnumerable<string> GetReferencedResourceIds(string language = null);
/// <summary>Titre et description, communs à tous les sous-types.</summary>
protected string BaseText(string language) =>
SectionText.JoinText(SectionText.Translate(Title, language), SectionText.Translate(Description, language));
/// <summary>Vignette de la section, commune à tous les sous-types.</summary>
protected IEnumerable<string> BaseResourceIds() => SectionText.ResourceId(ImageId);
public SectionDTO ToDTO() public SectionDTO ToDTO()
{ {

View File

@ -0,0 +1,200 @@
using Hangfire;
using ManagerService.Data.SubSection;
using ManagerService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using static ManagerService.Data.SubSection.SectionEvent;
namespace ManagerService.Data
{
/// <summary>
/// Déclenche la ré-indexation des sections touchées par un enregistrement.
/// </summary>
/// <remarks>
/// Central plutôt qu'un <c>Enqueue</c> par contrôleur : le contenu d'une section se modifie
/// depuis six endroits (SectionController, les sous-contrôleurs Map/Quiz/Event/Parcours/Agenda,
/// et AgendaSyncService), et la plupart d'entre eux enregistrent une entité fille sans jamais
/// toucher la ligne Section. Un enqueue posé dans chaque contrôleur laisse passer tout ce qu'on
/// oublie d'y ajouter — c'est déjà comme ça que le switch de l'export hors ligne a pourri.
/// </remarks>
public class SectionIndexingInterceptor : SaveChangesInterceptor
{
private readonly IBackgroundJobClient _jobs;
private readonly HashSet<string> _sectionIds = new();
private readonly HashSet<string> _deletedSectionIds = new();
private readonly HashSet<string> _guidedPathIds = new();
private readonly HashSet<string> _guidedStepIds = new();
public SectionIndexingInterceptor(IBackgroundJobClient jobs)
{
_jobs = jobs;
}
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
Collect(eventData.Context);
return result;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
Collect(eventData.Context);
return ValueTask.FromResult(result);
}
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
Enqueue(eventData.Context);
return result;
}
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
{
Enqueue(eventData.Context);
return ValueTask.FromResult(result);
}
/// <summary>
/// Les entités supprimées ne sont plus interrogeables une fois l'enregistrement passé :
/// les clés étrangères sont donc relevées ici, avant. Les propriétés sont lues via l'entrée
/// et non l'objet, pour atteindre aussi les clés étrangères fantômes (ProgrammeBlock).
/// </summary>
private void Collect(DbContext context)
{
if (context == null) return;
foreach (var entry in context.ChangeTracker.Entries())
{
if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
continue;
switch (entry.Entity)
{
case Section section:
if (entry.State == EntityState.Deleted)
Add(_deletedSectionIds, section.Id);
else if (entry.State == EntityState.Added || TouchesIndexedContent(entry))
Add(_sectionIds, section.Id);
break;
case GeoPoint point:
Add(_sectionIds, point.SectionMapId ?? point.SectionEventId);
break;
case EventAgenda eventAgenda:
Add(_sectionIds, eventAgenda.SectionAgendaId ?? eventAgenda.SectionEventId);
break;
case MapAnnotation annotation:
// Null pour une annotation de bloc : c'est alors le ProgrammeBlock qui porte
// le rattachement à l'événement, et il est enregistré dans la même passe.
Add(_sectionIds, annotation.SectionEventId);
break;
case ProgrammeBlock:
Add(_sectionIds, entry.Property("SectionEventId").CurrentValue as string);
break;
case GuidedPath path:
Add(_sectionIds, path.SectionParcoursId ?? path.SectionEventId);
break;
case GuidedStep step:
Add(_guidedPathIds, step.GuidedPathId);
break;
case QuizQuestion question:
Add(_sectionIds, question.SectionQuizId);
Add(_guidedStepIds, question.GuidedStepId);
break;
}
}
}
private void Enqueue(DbContext context)
{
if (context == null) return;
var sectionIds = Drain(_sectionIds);
var deletedSectionIds = Drain(_deletedSectionIds);
var guidedPathIds = Drain(_guidedPathIds);
var guidedStepIds = Drain(_guidedStepIds);
// La purge ne coûte rien et doit passer quel que soit le plan : sans elle, une section
// supprimée continue d'alimenter les réponses du guide.
foreach (var sectionId in deletedSectionIds)
_jobs.Enqueue<IVectorStoreService>(s => s.DeleteAsync(sectionId, ContentSourceType.Section, default));
if (guidedStepIds.Count > 0)
{
foreach (var pathId in context.Set<GuidedStep>()
.Where(s => guidedStepIds.Contains(s.Id))
.Select(s => s.GuidedPathId)
.ToList())
Add(guidedPathIds, pathId);
}
if (guidedPathIds.Count > 0)
{
foreach (var ownerId in context.Set<GuidedPath>()
.Where(p => guidedPathIds.Contains(p.Id))
.Select(p => p.SectionParcoursId ?? p.SectionEventId)
.ToList())
Add(sectionIds, ownerId);
}
sectionIds.ExceptWith(deletedSectionIds);
if (sectionIds.Count == 0) return;
// Un plan sans IA n'a pas à payer d'embedding : plan-starter est à 0 jeton, et
// l'indexation étant silencieuse, rien ne le signalerait avant la facture Google.
var indexable = context.Set<Section>()
.Where(s => sectionIds.Contains(s.Id))
.Join(context.Set<Instance>().Where(i => i.AiTokensPerMonth > 0),
s => s.InstanceId, i => i.Id, (s, _) => s.Id)
.ToList();
foreach (var sectionId in indexable)
_jobs.Enqueue<IIngestionService>(s => s.IngestSectionAsync(sectionId));
}
private static HashSet<string> Drain(HashSet<string> source)
{
var copy = new HashSet<string>(source);
source.Clear();
return copy;
}
/// <summary>
/// Réordonner des sections ne change pas un mot du texte indexé. Sans ce filtre, un
/// glisser-déposer dans le manager fait payer un embedding par section déplacée —
/// et plusieurs contrôleurs enregistrent dans une boucle, un SaveChanges par élément.
/// </summary>
private static readonly HashSet<string> NonIndexedSectionProperties = new()
{
nameof(Section.Order), nameof(Section.DateUpdate)
};
/// <summary>
/// Repli volontairement prudent : toute autre propriété modifiée déclenche la
/// ré-indexation. Un champ ajouté plus tard sera donc réindexé pour rien plutôt
/// qu'oublié en silence — le sens de l'erreur qui se voit.
/// </summary>
private static bool TouchesIndexedContent(EntityEntry entry) =>
entry.Properties.Any(p => p.IsModified && !NonIndexedSectionProperties.Contains(p.Metadata.Name));
private static void Add(HashSet<string> target, string id)
{
if (!string.IsNullOrEmpty(id))
target.Add(id);
}
}
}

View File

@ -0,0 +1,51 @@
using Manager.DTOs;
using ManagerService.DTOs;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ManagerService.Data
{
/// <summary>
/// Briques communes à <see cref="Section.GetEmbeddableText"/> et
/// <see cref="Section.GetReferencedResourceIds"/>. Sorties de <see cref="Section"/> parce que
/// les structures parcourues (GeoPoint, GuidedStep, EventAgenda…) n'en héritent pas.
/// </summary>
public static class SectionText
{
public static string Translate(IEnumerable<TranslationDTO> translations, string language) =>
translations?.FirstOrDefault(t => string.Equals(t.language, language, StringComparison.OrdinalIgnoreCase))?.value;
public static string Translate(IEnumerable<TranslationAndResourceDTO> translations, string language) =>
translations?.FirstOrDefault(t => string.Equals(t.language, language, StringComparison.OrdinalIgnoreCase))?.value;
public static string TranslateContents(IEnumerable<ContentDTO> contents, string language) =>
JoinText((contents ?? Enumerable.Empty<ContentDTO>())
.OrderBy(c => c.order ?? 0)
.SelectMany(c => new[] { Translate(c.title, language), Translate(c.description, language) })
.ToArray());
public static string JoinText(params string[] parts) =>
string.Join("\n", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
public static IEnumerable<string> ResourceId(string id) =>
string.IsNullOrWhiteSpace(id) ? Enumerable.Empty<string>() : new[] { id };
/// <summary>
/// Champs où la valeur traduite <em>est</em> un id de ressource (audios, PDF, JSON d'agenda),
/// et non du texte à afficher.
/// </summary>
public static IEnumerable<string> ResourceIdsFromValues(IEnumerable<TranslationDTO> translations, string language) =>
(translations ?? Enumerable.Empty<TranslationDTO>())
.Where(t => language == null || string.Equals(t.language, language, StringComparison.OrdinalIgnoreCase))
.SelectMany(t => ResourceId(t.value));
public static IEnumerable<string> ResourceIds(IEnumerable<TranslationAndResourceDTO> translations, string language) =>
(translations ?? Enumerable.Empty<TranslationAndResourceDTO>())
.Where(t => language == null || string.Equals(t.language, language, StringComparison.OrdinalIgnoreCase))
.SelectMany(t => ResourceId(t.resourceId));
public static IEnumerable<string> ResourceIds(IEnumerable<ContentDTO> contents) =>
(contents ?? Enumerable.Empty<ContentDTO>()).SelectMany(c => ResourceId(c.resourceId));
}
}

View File

@ -4,6 +4,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
@ -68,6 +69,18 @@ namespace ManagerService.Data.SubSection
[ForeignKey("SectionEventId")] [ForeignKey("SectionEventId")]
public SectionEvent? SectionEvent { get; set; } // Genre lancer l'event (vue vraiment detail d'un event) public SectionEvent? SectionEvent { get; set; } // Genre lancer l'event (vue vraiment detail d'un event)
public string GetEmbeddableText(string language) =>
SectionText.JoinText(
SectionText.Translate(Label, language),
SectionText.Translate(Description, language),
Type,
Address?.City,
DateFrom.HasValue ? $"{DateFrom:yyyy-MM-dd} → {DateTo:yyyy-MM-dd}" : null);
// SyncedImageUrl pointe vers une image externe, sans ligne Resource : rien à télécharger.
public IEnumerable<string> GetReferencedResourceIds() =>
SectionText.ResourceId(ResourceId).Concat(SectionText.ResourceId(VideoResourceId));
public EventAgendaDTO ToDTO() public EventAgendaDTO ToDTO()
{ {
return new EventAgendaDTO() return new EventAgendaDTO()

View File

@ -63,6 +63,26 @@ namespace ManagerService.Data.SubSection
public List<GuidedStep> Steps { get; set; } = new(); public List<GuidedStep> Steps { get; set; } = new();
public string GetEmbeddableText(string language) =>
SectionText.JoinText(new[]
{
SectionText.Translate(Title, language),
SectionText.Translate(Description, language),
SectionText.Translate(GameMessageDebut, language),
SectionText.Translate(GameMessageFin, language)
}
.Concat((Steps ?? new List<GuidedStep>())
.OrderBy(s => s.Order)
.Select(s => s.GetEmbeddableText(language)))
.ToArray());
public IEnumerable<string> GetReferencedResourceIds(string language = null) =>
SectionText.ResourceId(ImageResourceId)
.Concat(SectionText.ResourceIds(GameMessageDebut, language))
.Concat(SectionText.ResourceIds(GameMessageFin, language))
.Concat((Steps ?? new List<GuidedStep>())
.SelectMany(s => s.GetReferencedResourceIds(language)));
public GuidedPathDTO ToDTO() public GuidedPathDTO ToDTO()
{ {
return new GuidedPathDTO return new GuidedPathDTO

View File

@ -58,6 +58,27 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")] [Column(TypeName = "jsonb")]
public List<TranslationDTO> TimerExpiredMessage { get; set; } public List<TranslationDTO> TimerExpiredMessage { get; set; }
// Même règle que SectionQuiz : les réponses des énigmes ne sont pas indexées.
public string GetEmbeddableText(string language) =>
SectionText.JoinText(new[]
{
SectionText.Translate(Title, language),
SectionText.Translate(Description, language),
SectionText.TranslateContents(Contents, language),
SectionText.Translate(TimerExpiredMessage, language)
}
.Concat((QuizQuestions ?? new List<QuizQuestion>())
.OrderBy(q => q.Order)
.Select(q => SectionText.Translate(q.Label, language)))
.ToArray());
// ImageUrl est une URL absolue, pas un id : rien à rapprocher d'une ligne Resource.
public IEnumerable<string> GetReferencedResourceIds(string language = null) =>
SectionText.ResourceIdsFromValues(AudioIds, language)
.Concat(SectionText.ResourceIds(Contents))
.Concat((QuizQuestions ?? new List<QuizQuestion>())
.SelectMany(q => q.GetReferencedResourceIds(language)));
public GuidedStepDTO ToDTO() public GuidedStepDTO ToDTO()
{ {
return new GuidedStepDTO return new GuidedStepDTO

View File

@ -4,6 +4,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
@ -55,6 +56,12 @@ namespace ManagerService.Data.SubSection
public bool? IsSlidingPuzzle { get; set; } = false; public bool? IsSlidingPuzzle { get; set; } = false;
public IEnumerable<string> GetReferencedResourceIds(string language = null) =>
SectionText.ResourceId(ResourceId)
.Concat(SectionText.ResourceId(PuzzleImageId))
.Concat((Responses ?? new List<ResponseDTO>())
.SelectMany(r => SectionText.ResourceIds(r.label, language)));
// TODO // TODO
/*public TranslationDTO ToDTO() /*public TranslationDTO ToDTO()
{ {

View File

@ -6,6 +6,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -25,6 +27,18 @@ namespace ManagerService.Data.SubSection
[Required] [Required]
public List<EventAgenda> EventAgendas { get; set; } public List<EventAgenda> EventAgendas { get; set; }
public override string GetEmbeddableText(string language) =>
JoinText(new[] { BaseText(language) }
.Concat((EventAgendas ?? new List<EventAgenda>())
.Select(e => e.GetEmbeddableText(language)))
.ToArray());
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat(ResourceIdsFromValues(AgendaResourceIds, language))
.Concat((EventAgendas ?? new List<EventAgenda>())
.SelectMany(e => e.GetReferencedResourceIds()));
public AgendaDTO ToDTO() public AgendaDTO ToDTO()
{ {
return new AgendaDTO() return new AgendaDTO()

View File

@ -3,8 +3,11 @@ using ManagerService.DTOs;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -28,6 +31,16 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")] [Column(TypeName = "jsonb")]
public List<ContentDTO> ArticleContents { get; set; } // List of picture etc public List<ContentDTO> ArticleContents { get; set; } // List of picture etc
public override string GetEmbeddableText(string language) =>
JoinText(BaseText(language),
Translate(ArticleContent, language),
TranslateContents(ArticleContents, language));
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat(ResourceIdsFromValues(ArticleAudioIds, language))
.Concat(ResourceIds(ArticleContents));
public ArticleDTO ToDTO() public ArticleDTO ToDTO()
{ {
return new ArticleDTO() return new ArticleDTO()

View File

@ -8,6 +8,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -25,6 +27,31 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")] [Column(TypeName = "jsonb")]
public List<string> ParcoursIds { get; set; } = new(); // Liens vers GeoPoints spécifiques public List<string> ParcoursIds { get; set; } = new(); // Liens vers GeoPoints spécifiques
public override string GetEmbeddableText(string language) =>
JoinText(new[]
{
BaseText(language),
StartDate.HasValue ? $"{StartDate:yyyy-MM-dd} → {EndDate:yyyy-MM-dd}" : null
}
.Concat((Programme ?? new List<ProgrammeBlock>())
.OrderBy(b => b.StartTime)
.SelectMany(b => new[]
{
Translate(b.Title, language),
Translate(b.Description, language)
}))
.Concat((GlobalMapAnnotations ?? new List<MapAnnotation>())
.Select(a => Translate(a.Label, language)))
.ToArray());
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat((GlobalMapAnnotations ?? new List<MapAnnotation>())
.SelectMany(a => ResourceId(a.IconResourceId)))
.Concat((Programme ?? new List<ProgrammeBlock>())
.SelectMany(b => b.MapAnnotations ?? new List<MapAnnotation>())
.SelectMany(a => ResourceId(a.IconResourceId)));
public class ProgrammeBlock public class ProgrammeBlock
{ {
[Key] [Key]

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -33,6 +35,17 @@ namespace ManagerService.Data.SubSection
public GameTypes GameType { get; set; } = GameTypes.Puzzle; public GameTypes GameType { get; set; } = GameTypes.Puzzle;
public override string GetEmbeddableText(string language) =>
JoinText(BaseText(language),
Translate(GameMessageDebut, language),
Translate(GameMessageFin, language));
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat(ResourceId(GamePuzzleImageId))
.Concat(ResourceIds(GameMessageDebut, language))
.Concat(ResourceIds(GameMessageFin, language));
public GameDTO ToDTO() public GameDTO ToDTO()
{ {
return new GameDTO() return new GameDTO()

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -29,6 +31,23 @@ namespace ManagerService.Data.SubSection
public string MapCenterLongitude { get; set; } // Center on public string MapCenterLongitude { get; set; } // Center on
public override string GetEmbeddableText(string language) =>
JoinText(new[] { BaseText(language) }
.Concat((MapCategories ?? new List<CategorieDTO>())
.OrderBy(c => c.order ?? 0)
.Select(c => Translate(c.label, language)))
.Concat((MapPoints ?? new List<GeoPoint>())
.Select(p => p.GetEmbeddableText(language)))
.ToArray());
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat(ResourceId(MapResourceId))
.Concat((MapCategories ?? new List<CategorieDTO>())
.SelectMany(c => ResourceId(c.resourceDTO?.id)))
.Concat((MapPoints ?? new List<GeoPoint>())
.SelectMany(p => p.GetReferencedResourceIds()));
public MapDTO ToDTO() public MapDTO ToDTO()
{ {
return new MapDTO() return new MapDTO()
@ -120,6 +139,19 @@ namespace ManagerService.Data.SubSection
[ForeignKey(nameof(SectionEventId))] [ForeignKey(nameof(SectionEventId))]
public SectionEvent? SectionEvent { get; set; } public SectionEvent? SectionEvent { get; set; }
public string GetEmbeddableText(string language) =>
JoinText(Translate(Title, language),
Translate(Description, language),
TranslateContents(Contents, language),
Translate(Schedules, language),
Translate(Prices, language),
Translate(Phone, language),
Translate(Email, language),
Translate(Site, language));
public IEnumerable<string> GetReferencedResourceIds() =>
ResourceId(ImageResourceId).Concat(ResourceIds(Contents));
public GeoPointDTO ToDTO() public GeoPointDTO ToDTO()
{ {
return new GeoPointDTO return new GeoPointDTO

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -17,6 +19,12 @@ namespace ManagerService.Data.SubSection
[Required] [Required]
public List<Section> MenuSections { get; set; } // All json files for all languages public List<Section> MenuSections { get; set; } // All json files for all languages
// MenuSections sont des sections à part entière, indexées et exportées pour elles-mêmes :
// les reprendre ici dupliquerait leur contenu.
public override string GetEmbeddableText(string language) => BaseText(language);
public override IEnumerable<string> GetReferencedResourceIds(string language = null) => BaseResourceIds();
public MenuDTO ToDTO() public MenuDTO ToDTO()
{ {
return new MenuDTO() return new MenuDTO()

View File

@ -1,10 +1,12 @@
using Manager.DTOs; using Manager.DTOs;
using ManagerService.DTOs; using ManagerService.DTOs;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
public class SectionParcours : Section public class SectionParcours : Section
@ -17,6 +19,18 @@ namespace ManagerService.Data.SubSection
public List<GuidedPath> GuidedPaths { get; set; } = new(); public List<GuidedPath> GuidedPaths { get; set; } = new();
public override string GetEmbeddableText(string language) =>
JoinText(new[] { BaseText(language) }
.Concat((GuidedPaths ?? new List<GuidedPath>())
.OrderBy(p => p.Order)
.Select(p => p.GetEmbeddableText(language)))
.ToArray());
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat((GuidedPaths ?? new List<GuidedPath>())
.SelectMany(p => p.GetReferencedResourceIds(language)));
public ParcoursDTO ToDTO() public ParcoursDTO ToDTO()
{ {
return new ParcoursDTO return new ParcoursDTO

View File

@ -6,6 +6,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -17,6 +19,20 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")] [Column(TypeName = "jsonb")]
public List<OrderedTranslationAndResourceDTO> PDFOrderedTranslationAndResources { get; set; } // All json files for all languages public List<OrderedTranslationAndResourceDTO> PDFOrderedTranslationAndResources { get; set; } // All json files for all languages
// Le texte des PDF eux-mêmes relève de l'ingestion documentaire (V2) : ici on n'indexe
// que les libellés saisis dans le CMS.
public override string GetEmbeddableText(string language) =>
JoinText(new[] { BaseText(language) }
.Concat((PDFOrderedTranslationAndResources ?? new List<OrderedTranslationAndResourceDTO>())
.OrderBy(p => p.order ?? 0)
.Select(p => Translate(p.translationAndResourceDTOs, language)))
.ToArray());
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat((PDFOrderedTranslationAndResources ?? new List<OrderedTranslationAndResourceDTO>())
.SelectMany(p => ResourceIds(p.translationAndResourceDTOs, language)));
public PdfDTO ToDTO() public PdfDTO ToDTO()
{ {
return new PdfDTO() return new PdfDTO()

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -34,6 +36,25 @@ namespace ManagerService.Data.SubSection
public List<TranslationAndResourceDTO> QuizGreatLevel { get; set; } public List<TranslationAndResourceDTO> QuizGreatLevel { get; set; }
// Les réponses ne sont pas indexées : le guide les réciterait au premier visiteur qui
// demande. Les libellés de questions, eux, portent le sujet et rendent la section
// trouvable. Les messages de niveau sont des félicitations, sans valeur informative.
public override string GetEmbeddableText(string language) =>
JoinText(new[] { BaseText(language) }
.Concat((QuizQuestions ?? new List<QuizQuestion>())
.OrderBy(q => q.Order)
.Select(q => Translate(q.Label, language)))
.ToArray());
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat(ResourceIds(QuizBadLevel, language))
.Concat(ResourceIds(QuizMediumLevel, language))
.Concat(ResourceIds(QuizGoodLevel, language))
.Concat(ResourceIds(QuizGreatLevel, language))
.Concat((QuizQuestions ?? new List<QuizQuestion>())
.SelectMany(q => q.GetReferencedResourceIds(language)));
public QuizDTO ToDTO() public QuizDTO ToDTO()
{ {
return new QuizDTO() return new QuizDTO()

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -18,6 +20,12 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")] [Column(TypeName = "jsonb")]
public List<ContentDTO> SliderContents { get; set; } // TODO check public List<ContentDTO> SliderContents { get; set; } // TODO check
public override string GetEmbeddableText(string language) =>
JoinText(BaseText(language), TranslateContents(SliderContents, language));
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds().Concat(ResourceIds(SliderContents));
public SliderDTO ToDTO() public SliderDTO ToDTO()
{ {
return new SliderDTO() return new SliderDTO()

View File

@ -1,7 +1,11 @@
using Manager.DTOs; using Manager.DTOs;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -12,6 +16,16 @@ namespace ManagerService.Data.SubSection
[Required] [Required]
public string VideoSource { get; set; } // url to resource id (local) or on internet public string VideoSource { get; set; } // url to resource id (local) or on internet
public override string GetEmbeddableText(string language) => BaseText(language);
// VideoSource porte soit un id de ressource uploadée, soit une URL YouTube/Vimeo —
// seule la première est téléchargeable hors ligne.
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat(VideoSource != null && VideoSource.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? Enumerable.Empty<string>()
: ResourceId(VideoSource));
public VideoDTO ToDTO() public VideoDTO ToDTO()
{ {
return new VideoDTO() return new VideoDTO()

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -20,6 +22,13 @@ namespace ManagerService.Data.SubSection
public string WeatherResult { get; set; } // Weather result public string WeatherResult { get; set; } // Weather result
// WeatherResult est un instantané réécrit à chaque rafraîchissement : l'indexer
// ferait re-calculer les embeddings en boucle pour une météo déjà périmée.
public override string GetEmbeddableText(string language) =>
JoinText(BaseText(language), WeatherCity);
public override IEnumerable<string> GetReferencedResourceIds(string language = null) => BaseResourceIds();
public WeatherDTO ToDTO() public WeatherDTO ToDTO()
{ {
return new WeatherDTO() return new WeatherDTO()

View File

@ -1,8 +1,11 @@
using Manager.DTOs; using Manager.DTOs;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection namespace ManagerService.Data.SubSection
{ {
/// <summary> /// <summary>
@ -13,6 +16,11 @@ namespace ManagerService.Data.SubSection
[Required] [Required]
public string WebSource { get; set; } // url to resource id (local) or on internet public string WebSource { get; set; } // url to resource id (local) or on internet
// WebSource pointe vers une page externe : son contenu n'est ni stocké ni téléchargeable.
public override string GetEmbeddableText(string language) => BaseText(language);
public override IEnumerable<string> GetReferencedResourceIds(string language = null) => BaseResourceIds();
public WebDTO ToDTO() public WebDTO ToDTO()
{ {
return new WebDTO() return new WebDTO()

View File

@ -122,6 +122,9 @@ namespace ManagerService.Services
} }
} }
// Les EventAgenda modifiés ci-dessus déclenchent la ré-indexation via
// SectionIndexingInterceptor : elle porte donc sur les dates fraîches, pas
// sur celles d'avant la synchro.
db.SaveChanges(); db.SaveChanges();
_logger.LogInformation("Synced agenda section {Id}", sectionAgendaId); _logger.LogInformation("Synced agenda section {Id}", sectionAgendaId);
} }

View File

@ -19,14 +19,27 @@ namespace ManagerService.Services
private readonly IChatClient _chatClient; private readonly IChatClient _chatClient;
private readonly MyInfoMateDbContext _context; private readonly MyInfoMateDbContext _context;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IVectorStoreService _vectorStore;
private const int MaxHistoryMessages = 10; private const int MaxHistoryMessages = 10;
public AssistantService(IChatClient chatClient, MyInfoMateDbContext context, IHttpClientFactory httpClientFactory) /// <summary>
/// Règle à reprendre dans les quatre prompts. Le second membre n'est pas décoratif :
/// sans lui, Gemini comble les trous avec ses connaissances générales, et une réponse
/// inventée sur un horaire ou une date de construction est pire qu'un « je ne sais pas ».
/// </summary>
private const string SearchKnowledgeRule =
"Toute question de fond sur le contenu (œuvres, histoire, explications, thématiques) → appelle "
+ "\"SearchKnowledge\" AVANT de répondre. Si l'outil ne rend rien, dis que l'information n'est pas "
+ "disponible ici — n'invente JAMAIS et ne réponds pas depuis tes connaissances générales.";
public AssistantService(IChatClient chatClient, MyInfoMateDbContext context,
IHttpClientFactory httpClientFactory, IVectorStoreService vectorStore)
{ {
_chatClient = chatClient; _chatClient = chatClient;
_context = context; _context = context;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_vectorStore = vectorStore;
} }
public async Task<AiTranslateResponse> TranslateAsync(AiTranslateRequest request) public async Task<AiTranslateResponse> TranslateAsync(AiTranslateRequest request)
@ -194,6 +207,26 @@ namespace ManagerService.Services
return $"réponds exactement ceci, sans rien y ajouter : « {picked} »"; return $"réponds exactement ceci, sans rien y ajouter : « {picked} »";
} }
/// <summary>
/// Dédoublonne sur (contenu, page) : plusieurs morceaux d'une même section remontent
/// souvent ensemble, et le client n'a pas besoin de voir trois fois la même source.
/// </summary>
private static void RecordSources(List<AiSourceDTO> sources, List<VectorSearchResult> hits)
{
foreach (var hit in hits)
{
if (sources.Any(s => s.ContentId == hit.ContentId && s.PageNumber == hit.PageNumber))
continue;
sources.Add(new AiSourceDTO
{
ContentId = hit.ContentId,
ContentType = hit.ContentType.ToString(),
PageNumber = hit.PageNumber
});
}
}
public async Task<AiChatResponse> ChatAsync(AiChatRequest request) public async Task<AiChatResponse> ChatAsync(AiChatRequest request)
{ {
var messages = new List<ChatMessage>(); var messages = new List<ChatMessage>();
@ -247,6 +280,7 @@ namespace ManagerService.Services
12. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction} et ajoute [FIN]. 12. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction} et ajoute [FIN].
13. Politesses et fin de conversation ("merci", "au revoir", "c'est tout", "ok merci") réponds en une phrase courte et souhaite une bonne visite, puis ajoute [FIN]. 13. Politesses et fin de conversation ("merci", "au revoir", "c'est tout", "ok merci") réponds en une phrase courte et souhaite une bonne visite, puis ajoute [FIN].
14. Signal [FIN] : ajoute le token [FIN] à la toute fin quand ta réponse est une information pure sans question posée au visiteur. Ne l'ajoute PAS quand tu poses une question (article, proposition de détails). 14. Signal [FIN] : ajoute le token [FIN] à la toute fin quand ta réponse est une information pure sans question posée au visiteur. Ne l'ajoute PAS quand tu poses une question (article, proposition de détails).
15. {SearchKnowledgeRule}
""" : $""" """ : $"""
Tu es le guide de visite de l'application "{config?.Label ?? "cette application"}". Tu es le guide de visite de l'application "{config?.Label ?? "cette application"}".
{guideIdentity} {guideIdentity}
@ -265,6 +299,7 @@ namespace ManagerService.Services
5. Si l'utilisateur veut voir les activités ou lieux utilise "GetMapPoints". 5. Si l'utilisateur veut voir les activités ou lieux utilise "GetMapPoints".
6. Pour les détails d'un item spécifique utilise "GetItemDetails". 6. Pour les détails d'un item spécifique utilise "GetItemDetails".
7. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction}. 7. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction}.
8. {SearchKnowledgeRule}
- Ne mentionne JAMAIS les identifiants techniques (id, guid) dans tes réponses finales. - Ne mentionne JAMAIS les identifiants techniques (id, guid) dans tes réponses finales.
- NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats. - NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats.
- NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide. - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide.
@ -279,6 +314,7 @@ namespace ManagerService.Services
NavigationActionDTO? navigation = null; NavigationActionDTO? navigation = null;
List<AiCardDTO>? cards = null; List<AiCardDTO>? cards = null;
var sources = new List<AiSourceDTO>();
var tools = new List<AITool> var tools = new List<AITool>
{ {
@ -499,6 +535,23 @@ namespace ManagerService.Services
"GetItemDetails", "GetItemDetails",
"Récupère les détails complets (prix, horaires, contact, site) d'un événement (type='event') ou d'un point d'intérêt (type='poi') via son ID." "Récupère les détails complets (prix, horaires, contact, site) d'un événement (type='event') ou d'un point d'intérêt (type='poi') via son ID."
), ),
AIFunctionFactory.Create(
async (string question) =>
{
var hits = await _vectorStore.SearchAsync(
request.InstanceId, request.ConfigurationId, request.Language, question);
// Dire qu'on n'a rien plutôt que rendre une chaîne vide : sans réponse
// explicite, le modèle comble le silence avec ses connaissances générales.
if (hits.Count == 0)
return "Aucun contenu du lieu ne traite de ce sujet.";
RecordSources(sources, hits);
return string.Join("\n---\n", hits.Select(h => h.Text));
},
"SearchKnowledge",
"Cherche dans le contenu rédigé du lieu (descriptions, articles, étapes de parcours) ce qui traite d'un sujet. À utiliser pour toute question de fond sur les œuvres, l'histoire, les thématiques."
),
}; };
// Navigation et cards : uniquement en mode UI (pas Voice) // Navigation et cards : uniquement en mode UI (pas Voice)
@ -545,7 +598,7 @@ namespace ManagerService.Services
reply = response.Text ?? ""; reply = response.Text ?? "";
expectsReply = true; expectsReply = true;
} }
return new AiChatResponse { Reply = reply, Cards = cards, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0 }; return new AiChatResponse { Reply = reply, Cards = cards, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0, Sources = sources.Count > 0 ? sources : null };
} }
catch (System.ClientModel.ClientResultException ex) catch (System.ClientModel.ClientResultException ex)
{ {
@ -603,6 +656,7 @@ namespace ManagerService.Services
6. Tu réponds UNIQUEMENT aux questions liées à cette visite. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction} et ajoute [FIN]. 6. Tu réponds UNIQUEMENT aux questions liées à cette visite. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction} et ajoute [FIN].
7. Politesses ("merci", "au revoir") réponds en une phrase courte et souhaite une bonne visite, puis ajoute [FIN]. 7. Politesses ("merci", "au revoir") réponds en une phrase courte et souhaite une bonne visite, puis ajoute [FIN].
8. Signal [FIN] : ajoute-le quand ta réponse est une information pure sans question au visiteur. 8. Signal [FIN] : ajoute-le quand ta réponse est une information pure sans question au visiteur.
9. {SearchKnowledgeRule}
""" : $""" """ : $"""
Tu es le guide principal. Ton rôle est d'orienter le visiteur vers la bonne expérience de visite. Tu es le guide principal. Ton rôle est d'orienter le visiteur vers la bonne expérience de visite.
{guideIdentity} {guideIdentity}
@ -621,6 +675,7 @@ namespace ManagerService.Services
5. Pour orienter vers une visite spécifique (jeu, quiz...) "navigate_to_configuration". 5. Pour orienter vers une visite spécifique (jeu, quiz...) "navigate_to_configuration".
6. Pour les détails d'un item "GetItemDetailsGlobal". 6. Pour les détails d'un item "GetItemDetailsGlobal".
7. Tu réponds UNIQUEMENT aux questions liées à cette visite et aux expériences ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction}. 7. Tu réponds UNIQUEMENT aux questions liées à cette visite et aux expériences ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction}.
8. {SearchKnowledgeRule}
- NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats. - NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats.
- NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide. - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide.
"""; """;
@ -633,6 +688,7 @@ namespace ManagerService.Services
messages.Add(new ChatMessage(ChatRole.User, request.Message)); messages.Add(new ChatMessage(ChatRole.User, request.Message));
NavigationActionDTO? navigation = null; NavigationActionDTO? navigation = null;
var sources = new List<AiSourceDTO>();
var tools = new List<AITool> var tools = new List<AITool>
{ {
@ -817,6 +873,23 @@ namespace ManagerService.Services
"GetItemDetailsGlobal", "GetItemDetailsGlobal",
"Détails d'un lieu ou événement au niveau instance." "Détails d'un lieu ou événement au niveau instance."
), ),
AIFunctionFactory.Create(
async (string question) =>
{
// ConfigurationId est null à ce scope : il ne sert que de bonus de
// classement, le filtre dur reste l'instance.
var hits = await _vectorStore.SearchAsync(
request.InstanceId, request.ConfigurationId, request.Language, question);
if (hits.Count == 0)
return "Aucun contenu du lieu ne traite de ce sujet.";
RecordSources(sources, hits);
return string.Join("\n---\n", hits.Select(h => h.Text));
},
"SearchKnowledge",
"Cherche dans le contenu rédigé du lieu (descriptions, articles, étapes de parcours) ce qui traite d'un sujet. À utiliser pour toute question de fond sur les œuvres, l'histoire, les thématiques."
),
AIFunctionFactory.Create( AIFunctionFactory.Create(
(string configurationId, string configurationTitle) => (string configurationId, string configurationTitle) =>
{ {
@ -844,7 +917,7 @@ namespace ManagerService.Services
reply = response.Text ?? ""; reply = response.Text ?? "";
expectsReply = true; expectsReply = true;
} }
return new AiChatResponse { Reply = reply, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0 }; return new AiChatResponse { Reply = reply, Navigation = navigation, ExpectsReply = expectsReply, TokensUsed = response.Usage?.TotalTokenCount ?? 0, Sources = sources.Count > 0 ? sources : null };
} }
catch (System.ClientModel.ClientResultException ex) catch (System.ClientModel.ClientResultException ex)
{ {

View File

@ -0,0 +1,22 @@
using System.Threading.Tasks;
namespace ManagerService.Services
{
/// <summary>
/// Ré-indexation du contenu CMS pour le guide IA, exécutée en tâche de fond.
/// Les méthodes prennent un id, jamais un contenu : Hangfire sérialise les arguments
/// de job en JSON dans sa table Postgres, et le texte y serait recopié à chaque tentative.
/// </summary>
public interface IIngestionService
{
Task IngestSectionAsync(string sectionId);
/// <summary>
/// Réindexe toutes les sections d'une instance et rend le nombre de morceaux produits.
/// Sert au rattrapage après un passage à un plan avec IA : sans lui, le client paie un
/// guide qui ne connaît rien de son contenu, puisque rien n'a été indexé tant qu'il
/// était sur un plan sans IA.
/// </summary>
Task<int> BackfillInstanceAsync(string instanceId);
}
}

View File

@ -0,0 +1,48 @@
using ManagerService.Data;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ManagerService.Services
{
/// <summary>
/// Lecture et écriture des morceaux de contenu indexés pour le guide IA.
/// </summary>
public interface IVectorStoreService
{
/// <summary>
/// Remplace tous les morceaux d'un contenu. Le nombre de morceaux change d'une version
/// à l'autre : mettre à jour morceau par morceau laisserait des fantômes qui continueraient
/// d'alimenter les réponses avec du contenu supprimé. Une liste vide purge le contenu.
/// </summary>
Task ReplaceAsync(string instanceId, string configurationId, string contentId,
ContentSourceType contentType, IReadOnlyList<ContentChunk> chunks,
CancellationToken cancellationToken = default);
/// <summary>
/// Cherche les morceaux les plus proches de la question.
/// Le seul filtre dur est l'instance. La langue ne filtre pas — un visiteur néerlandophone
/// doit retrouver une brochure qui n'existe qu'en français : le modèle d'embedding est
/// multilingue et la traduction se fait à la rédaction de la réponse.
/// <paramref name="language"/> et <paramref name="currentConfigurationId"/> ne servent
/// qu'à bonifier le classement.
/// <paramref name="topK"/> null retombe sur <c>AI:SearchTopK</c>.
/// </summary>
Task<List<VectorSearchResult>> SearchAsync(string instanceId, string currentConfigurationId,
string language, string query, int? topK = null,
CancellationToken cancellationToken = default);
Task DeleteAsync(string contentId, ContentSourceType contentType,
CancellationToken cancellationToken = default);
}
public record ContentChunk(string Text, int ChunkIndex, int? PageNumber, string Language);
public record VectorSearchResult(
string ContentId,
ContentSourceType ContentType,
string Text,
int? PageNumber,
string Language,
double Score);
}

View File

@ -0,0 +1,236 @@
using Hangfire;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace ManagerService.Services
{
[Queue(IngestionService.QueueName)]
// Le défaut de Hangfire est à 10 tentatives : sur un contenu que l'API d'embedding
// refuse, ça fait dix appels facturés pour le même échec.
[AutomaticRetry(Attempts = 2)]
public class IngestionService : IIngestionService
{
public const string QueueName = "ingestion";
/// <summary>
/// Un morceau doit tenir un sujet, pas une section entière : au-delà, le vecteur
/// moyenne tout et ne ressort plus sur aucune question précise.
/// </summary>
private const int MaxChunkChars = 1200;
private readonly MyInfoMateDbContext _db;
private readonly IVectorStoreService _vectorStore;
private readonly IConfiguration _configuration;
private readonly ILogger<IngestionService> _logger;
public IngestionService(MyInfoMateDbContext db, IVectorStoreService vectorStore,
IConfiguration configuration, ILogger<IngestionService> logger)
{
_db = db;
_vectorStore = vectorStore;
_configuration = configuration;
_logger = logger;
}
public Task IngestSectionAsync(string sectionId) => IngestSectionCountingAsync(sectionId);
/// <summary>Même travail, en rendant le nombre de morceaux — ce dont le backfill a besoin.</summary>
private async Task<int> IngestSectionCountingAsync(string sectionId)
{
var stub = await _db.Sections.AsNoTracking().FirstOrDefaultAsync(s => s.Id == sectionId);
// Section supprimée entre l'enqueue et l'exécution : il reste ses morceaux à retirer.
if (stub == null)
{
await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section);
return 0;
}
// Re-vérifié ici et pas seulement à l'enqueue : le plan a pu changer entre les deux,
// et c'est ce contrôle-ci qui décide de l'appel facturé.
var hasAi = await _db.Instances
.Where(i => i.Id == stub.InstanceId)
.Select(i => i.AiTokensPerMonth)
.FirstOrDefaultAsync() > 0;
// Sans IA on n'indexe plus, mais on ne purge pas : décidé le 2026-08-10. Ce qui est
// déjà indexé ne coûte que quelques Mo, l'usage est bloqué en amont, et un incident
// de paiement réglé le lendemain ne doit pas imposer de tout ré-embedder.
// Voir DOCS/v2/rag-indexing-trigger-decision.md.
if (!hasAi)
return 0;
// Une section désactivée doit disparaître des réponses du guide, pas seulement de l'app.
if (!stub.IsActive)
{
await _vectorStore.DeleteAsync(sectionId, ContentSourceType.Section);
return 0;
}
var section = await LoadWithChildrenAsync(stub);
var chunks = BuildChunks(section);
await _vectorStore.ReplaceAsync(section.InstanceId, section.ConfigurationId,
section.Id, ContentSourceType.Section, chunks);
_logger.LogInformation("Section {SectionId} indexée : {ChunkCount} morceaux", sectionId, chunks.Count);
return chunks.Count;
}
public async Task<int> BackfillInstanceAsync(string instanceId)
{
var sectionIds = await _db.Sections
.Where(s => s.InstanceId == instanceId)
.Select(s => s.Id)
.ToListAsync();
var chunkCount = 0;
foreach (var id in sectionIds)
chunkCount += await IngestSectionCountingAsync(id);
_logger.LogInformation("Backfill instance {InstanceId} : {Count} sections, {ChunkCount} morceaux",
instanceId, sectionIds.Count, chunkCount);
return chunkCount;
}
/// <summary>
/// Les collections filles vivent dans des tables séparées : sans chargement explicite,
/// GetEmbeddableText ne verrait ni les points d'une carte, ni les questions d'un quiz,
/// ni les étapes d'un parcours — et le guide ignorerait l'essentiel du contenu.
/// </summary>
private async Task<Section> LoadWithChildrenAsync(Section stub) => stub.Type switch
{
SectionType.Map => await _db.Sections.AsNoTracking().OfType<SectionMap>()
.Include(s => s.MapPoints)
.FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub,
SectionType.Quiz => await _db.Sections.AsNoTracking().OfType<SectionQuiz>()
.Include(s => s.QuizQuestions)
.FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub,
SectionType.Agenda => await _db.Sections.AsNoTracking().OfType<SectionAgenda>()
.Include(s => s.EventAgendas)
.FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub,
SectionType.Event => await _db.Sections.AsNoTracking().OfType<SectionEvent>()
.Include(s => s.Programme).ThenInclude(b => b.MapAnnotations)
.Include(s => s.GlobalMapAnnotations)
.FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub,
SectionType.Parcours => await _db.Sections.AsNoTracking().OfType<SectionParcours>()
.Include(s => s.GuidedPaths).ThenInclude(p => p.Steps).ThenInclude(st => st.QuizQuestions)
.FirstOrDefaultAsync(s => s.Id == stub.Id) ?? stub,
// Les autres sous-types portent tout leur contenu dans leurs colonnes jsonb.
_ => stub
};
private List<ContentChunk> BuildChunks(Section section)
{
var languages = _configuration.GetSection("SupportedLanguages").Get<List<string>>()
?? new List<string>();
var chunks = new List<ContentChunk>();
// ChunkIndex court sur toute la section, langues confondues : la contrainte d'unicité
// porte sur (ContentType, ContentId, ChunkIndex), un compteur remis à zéro par langue
// ferait échouer l'insertion dès la deuxième.
foreach (var language in languages)
{
var text = WithoutPlaceholders(StripHtml(section.GetEmbeddableText(language)));
if (string.IsNullOrWhiteSpace(text))
continue;
foreach (var piece in Split(text))
chunks.Add(new ContentChunk(piece, chunks.Count, null, language.ToUpperInvariant()));
}
return chunks;
}
/// <summary>
/// Gabarits posés par <c>LanguageInit.Init</c> à la création d'une section : « FR - Title »,
/// « NL - Description »… Ils restent tant que le client n'a pas rempli la langue.
/// </summary>
/// <remarks>
/// Mesuré sur l'instance de démo le 2026-08-10 : sans ce filtre, les cinq premiers
/// résultats d'une question en néerlandais étaient tous des « NL - Title NL - Description ».
/// Le bonus de langue suffit à les faire passer devant du vrai contenu français — soit
/// exactement le cas que la recherche cross-lingue devait servir. Une langue qui ne
/// contient que des gabarits ne produit donc plus aucun morceau.
/// </remarks>
private static readonly Regex PlaceholderLine =
new(@"^[A-Za-z]{2}\s*-\s*(Title|Description|Content|Audio)$", RegexOptions.Compiled);
private static string WithoutPlaceholders(string text) =>
string.Join("\n", text
.Split('\n')
.Where(line => !PlaceholderLine.IsMatch(line.Trim())));
/// <summary>
/// Les champs riches sont saisis en HTML dans le back-office. Les balises se retrouvent
/// à l'identique dans tous les contenus : elles tirent les vecteurs vers un fond commun
/// et écrasent les écarts de score entre un morceau pertinent et un hors-sujet.
/// Remplacées par une espace et non par rien, sinon deux paragraphes accolés fusionnent
/// leurs mots — c'est ce qui distingue cette version de celle d'AssistantService, écrite
/// pour de l'affichage.
/// </summary>
private static string StripHtml(string text) =>
WebUtility.HtmlDecode(Regex.Replace(text, "<[^>]+>", " "));
/// <summary>
/// Découpe sur les sauts de ligne — c'est déjà la frontière posée par JoinText entre
/// deux champs.
/// </summary>
private static IEnumerable<string> Split(string text)
{
var current = new System.Text.StringBuilder();
foreach (var line in text.Split('\n').SelectMany(SplitLongLine))
{
if (current.Length > 0 && current.Length + line.Length + 1 > MaxChunkChars)
{
yield return current.ToString().Trim();
current.Clear();
}
current.Append(line).Append('\n');
}
if (current.Length > 0)
yield return current.ToString().Trim();
}
/// <summary>
/// Le contenu d'un article est du HTML sans saut de ligne : une fois les balises retirées,
/// il forme une seule ligne de toute la longueur du texte. Sans cette coupe il part d'un
/// bloc à l'embedding, dépasse l'entrée maximale du modèle, et l'échec emporte les
/// 49 autres morceaux de son lot — la section la plus riche du CMS ne s'indexe jamais.
/// La coupe cherche une fin de phrase avant de se rabattre sur une espace : trancher au
/// caractère près séparerait une phrase de son sujet.
/// </summary>
private static IEnumerable<string> SplitLongLine(string line)
{
while (line.Length > MaxChunkChars)
{
var cut = line.LastIndexOf(". ", MaxChunkChars, StringComparison.Ordinal);
if (cut <= 0) cut = line.LastIndexOf(' ', MaxChunkChars);
if (cut <= 0) cut = MaxChunkChars - 1;
yield return line.Substring(0, cut + 1).Trim();
line = line.Substring(cut + 1).TrimStart();
}
yield return line;
}
}
}

View File

@ -4,6 +4,7 @@ using ManagerService.Data.SubSection;
using ManagerService.DTOs; using ManagerService.DTOs;
using Newtonsoft.Json; using Newtonsoft.Json;
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
@ -11,6 +12,53 @@ namespace ManagerService.Services
{ {
public static class SectionFactory public static class SectionFactory
{ {
/// <summary>
/// Sous-type concret vide, collections initialisées. Section étant abstraite,
/// c'est le seul point d'entrée pour créer une section dont on n'a que le type.
/// </summary>
public static Section CreateEmpty(SectionType type) => type switch
{
SectionType.Agenda => new SectionAgenda
{
AgendaResourceIds = new List<TranslationDTO>(),
EventAgendas = new List<EventAgenda>()
},
SectionType.Article => new SectionArticle
{
ArticleContents = new List<ContentDTO>(),
ArticleContent = new List<TranslationDTO>(),
ArticleAudioIds = new List<TranslationDTO>()
},
SectionType.Event => new SectionEvent
{
Programme = new List<SectionEvent.ProgrammeBlock>(),
ParcoursIds = new List<string>()
},
SectionType.Map => new SectionMap
{
MapMapType = MapTypeApp.hybrid,
MapTypeMapbox = MapTypeMapBox.standard,
MapMapProvider = MapProvider.Google,
MapZoom = 18,
MapPoints = new List<GeoPoint>(),
MapCategories = new List<CategorieDTO>()
},
SectionType.Menu => new SectionMenu { MenuSections = new List<Section>() },
SectionType.PDF => new SectionPdf { PDFOrderedTranslationAndResources = new List<OrderedTranslationAndResourceDTO>() },
SectionType.Game => new SectionGame
{
GameMessageDebut = new List<TranslationAndResourceDTO>(),
GameMessageFin = new List<TranslationAndResourceDTO>()
},
SectionType.Quiz => new SectionQuiz { QuizQuestions = new List<QuizQuestion>() },
SectionType.Slider => new SectionSlider { SliderContents = new List<ContentDTO>() },
SectionType.Video => new SectionVideo { VideoSource = "" },
SectionType.Weather => new SectionWeather(),
SectionType.Web => new SectionWeb { WebSource = "" },
SectionType.Parcours => new SectionParcours { GuidedPaths = new List<GuidedPath>() },
_ => throw new NotImplementedException($"Section type not handled: {type}")
};
public static Section Create(JsonElement jsonElement, SectionDTO dto) public static Section Create(JsonElement jsonElement, SectionDTO dto)
{ {
AgendaDTO agendaDTO = new AgendaDTO(); AgendaDTO agendaDTO = new AgendaDTO();

View File

@ -0,0 +1,158 @@
using ManagerService.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Pgvector;
using Pgvector.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace ManagerService.Services
{
public class VectorStoreService : IVectorStoreService
{
/// <summary>
/// Départage deux traductions du même contenu, qui sortent à des distances quasi
/// identiques. Volontairement petit : un contenu réellement plus pertinent dans une
/// autre langue doit rester devant.
/// </summary>
private const double LanguageBonus = 0.05;
/// <summary>Privilégie la visite en cours à pertinence comparable.</summary>
private const double ConfigurationBonus = 0.02;
/// <summary>
/// On reclasse en mémoire, donc il faut plus de candidats que de résultats voulus,
/// sinon les bonus ne peuvent que réordonner ce que la distance brute avait déjà retenu.
/// </summary>
private const int OversampleFactor = 5;
private const int MinimumCandidates = 25;
/// <summary>
/// Suffisant pour les questions d'un visiteur, qui portent sur un contenu précis.
/// À monter (`AI:SearchTopK`) si les réponses s'avèrent tronquées sur du volume réel —
/// se règle en même temps que <c>hnsw.iterative_scan</c>, sur le même test de charge.
/// </summary>
private const int DefaultTopK = 5;
private readonly MyInfoMateDbContext _db;
private readonly IEmbeddingService _embeddingService;
private readonly int _defaultTopK;
public VectorStoreService(MyInfoMateDbContext db, IEmbeddingService embeddingService,
IConfiguration configuration)
{
_db = db;
_embeddingService = embeddingService;
_defaultTopK = configuration.GetValue<int?>("AI:SearchTopK") ?? DefaultTopK;
}
public async Task ReplaceAsync(string instanceId, string configurationId, string contentId,
ContentSourceType contentType, IReadOnlyList<ContentChunk> chunks,
CancellationToken cancellationToken = default)
{
var texts = (chunks ?? Array.Empty<ContentChunk>())
.Where(c => !string.IsNullOrWhiteSpace(c.Text))
.ToList();
// L'embedding est un appel réseau : le sortir de la transaction évite de tenir
// un verrou sur les lignes pendant plusieurs secondes, et un échec d'API laisse
// alors l'index précédent intact plutôt qu'un contenu vidé.
var vectors = await _embeddingService.EmbedBatchAsync(
texts.Select(c => c.Text).ToList(), cancellationToken);
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
await _db.ContentEmbeddings
.Where(e => e.ContentType == contentType && e.ContentId == contentId)
.ExecuteDeleteAsync(cancellationToken);
var now = DateTime.UtcNow;
for (var i = 0; i < texts.Count; i++)
{
_db.ContentEmbeddings.Add(new ContentEmbedding
{
InstanceId = instanceId,
ConfigurationId = configurationId,
ContentId = contentId,
ContentType = contentType,
ChunkIndex = texts[i].ChunkIndex,
PageNumber = texts[i].PageNumber,
Language = texts[i].Language,
Text = texts[i].Text,
Embedding = new Vector(vectors[i]),
UpdatedAt = now
});
}
await _db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public async Task<List<VectorSearchResult>> SearchAsync(string instanceId, string currentConfigurationId,
string language, string query, int? topK = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return new List<VectorSearchResult>();
var resultCount = topK ?? _defaultTopK;
var queryVector = new Vector(await _embeddingService.EmbedAsync(query, cancellationToken));
var candidateCount = Math.Max(resultCount * OversampleFactor, MinimumCandidates);
// pgvector applique le WHERE *après* le parcours de l'index HNSW : avec plusieurs
// instances dans la table, le filtre jette l'essentiel des candidats et la requête
// rend moins de lignes qu'il n'en existe. Le parcours itératif (pgvector 0.8+)
// relance le parcours jusqu'à en avoir assez après filtrage.
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
await _db.Database.ExecuteSqlRawAsync("SET LOCAL hnsw.iterative_scan = relaxed_order", cancellationToken);
var candidates = await _db.ContentEmbeddings
.Where(e => e.InstanceId == instanceId)
.OrderBy(e => e.Embedding.CosineDistance(queryVector))
.Take(candidateCount)
.Select(e => new
{
e.ContentId,
e.ContentType,
e.Text,
e.PageNumber,
e.Language,
e.ConfigurationId,
Distance = e.Embedding.CosineDistance(queryVector)
})
.ToListAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return candidates
.Select(c => new VectorSearchResult(
c.ContentId,
c.ContentType,
c.Text,
c.PageNumber,
c.Language,
1 - c.Distance
+ (string.Equals(c.Language, language, StringComparison.OrdinalIgnoreCase) ? LanguageBonus : 0)
+ (c.ConfigurationId != null && c.ConfigurationId == currentConfigurationId ? ConfigurationBonus : 0)))
.OrderByDescending(r => r.Score)
// Le même texte revient à l'identique quand il est répété dans le contenu — la
// description d'un lieu recopiée sur chacun de ses événements, par exemple.
// Mesuré sur l'instance de démo : trois des cinq résultats étaient le même
// paragraphe, au même score. Autant de place perdue dans le contexte du modèle.
.DistinctBy(r => r.Text)
.Take(resultCount)
.ToList();
}
public async Task DeleteAsync(string contentId, ContentSourceType contentType,
CancellationToken cancellationToken = default)
{
await _db.ContentEmbeddings
.Where(e => e.ContentType == contentType && e.ContentId == contentId)
.ExecuteDeleteAsync(cancellationToken);
}
}
}

View File

@ -0,0 +1,65 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using ManagerService.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ManagerService.Services
{
/// <summary>
/// Supprime les questions posées au guide IA au-delà de 90 jours.
///
/// Contrairement à <see cref="VisitEventPurgeService"/>, cette purge n'est **pas**
/// conditionnée à un réglage : la durée est un engagement pris dans les CGU (§8.4), pas
/// un paramètre commercial. Un job de conformité qui ne s'exécute pas faute de
/// configuration serait pire que pas de job du tout — il donnerait l'illusion du respect.
/// La clé <c>VisitorQuestions:RetentionDays</c> n'existe que pour raccourcir le délai en
/// recette ; absente, la valeur légale s'applique.
///
/// ⚠️ **Dépendance non satisfaite à ce jour.** Les CGU annoncent que « les regroupements
/// par thème sont conservés au-delà, sous forme agrégée ». Or <c>ThemeId</c> est porté par
/// la ligne <c>VisitorQuestion</c> elle-même : supprimer la ligne supprime aussi l'agrégat.
/// Tant qu'une table d'agrégats distincte n'existe pas, la première purge fera perdre au
/// client tout son historique au 91ᵉ jour. Rien n'est encore collecté, donc le délai n'a
/// pas commencé à courir — mais la table doit exister avant la mise en service.
/// </summary>
public class VisitorQuestionPurgeService
{
/// <summary>Durée annoncée aux visiteurs et aux clients. Voir CGU §8.4.</summary>
public const int RetentionDays = 90;
private readonly MyInfoMateDbContext _dbContext;
private readonly IConfiguration _configuration;
private readonly ILogger<VisitorQuestionPurgeService> _logger;
public VisitorQuestionPurgeService(
MyInfoMateDbContext dbContext,
IConfiguration configuration,
ILogger<VisitorQuestionPurgeService> logger)
{
_dbContext = dbContext;
_configuration = configuration;
_logger = logger;
}
public async Task PurgeAsync()
{
var configured = _configuration.GetValue<int>("VisitorQuestions:RetentionDays");
var retentionDays = configured > 0 ? configured : RetentionDays;
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
var deleted = await _dbContext.VisitorQuestions
.Where(q => q.CreatedAt < cutoff)
.ExecuteDeleteAsync();
if (deleted > 0)
{
_logger.LogInformation(
"Purge RGPD des questions visiteurs : {Deleted} question(s) antérieure(s) au {Cutoff:yyyy-MM-dd} supprimée(s).",
deleted, cutoff);
}
}
}
}

View File

@ -190,6 +190,8 @@ namespace ManagerService
.Build()); .Build());
services.AddScoped<IAssistantService, AssistantService>(); services.AddScoped<IAssistantService, AssistantService>();
services.AddScoped<IEmbeddingService, GoogleEmbeddingService>(); services.AddScoped<IEmbeddingService, GoogleEmbeddingService>();
services.AddScoped<IVectorStoreService, VectorStoreService>();
services.AddScoped<IIngestionService, IngestionService>();
// Push Notifications // Push Notifications
var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"]; var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"];
@ -206,6 +208,7 @@ namespace ManagerService
services.AddScoped<WeatherSyncService>(); services.AddScoped<WeatherSyncService>();
services.AddScoped<TrialLifecycleService>(); services.AddScoped<TrialLifecycleService>();
services.AddScoped<VisitEventPurgeService>(); services.AddScoped<VisitEventPurgeService>();
services.AddScoped<VisitorQuestionPurgeService>();
var connectionString = Configuration.GetConnectionString("PostgresConnection"); var connectionString = Configuration.GetConnectionString("PostgresConnection");
@ -215,7 +218,18 @@ namespace ManagerService
.UseSimpleAssemblyNameTypeSerializer() .UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings() .UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(c => c.UseNpgsqlConnection(connectionString))); .UsePostgreSqlStorage(c => c.UseNpgsqlConnection(connectionString)));
services.AddHangfireServer(); 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); var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.UseNetTopologySuite(); dataSourceBuilder.UseNetTopologySuite();
@ -223,8 +237,14 @@ namespace ManagerService
dataSourceBuilder.EnableDynamicJson(); dataSourceBuilder.EnableDynamicJson();
var dataSource = dataSourceBuilder.Build(); var dataSource = dataSourceBuilder.Build();
services.AddDbContext<MyInfoMateDbContext>(options => // 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()) options.UseNpgsql(dataSource, o => o.UseNetTopologySuite().UseVector())
.AddInterceptors(serviceProvider.GetRequiredService<SectionIndexingInterceptor>())
.EnableSensitiveDataLogging() .EnableSensitiveDataLogging()
.LogTo(Console.WriteLine, LogLevel.Information) .LogTo(Console.WriteLine, LogLevel.Information)
); );
@ -306,6 +326,12 @@ namespace ManagerService
s => s.PurgeAsync(), s => s.PurgeAsync(),
"0 3 * * *"); "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 => app.UseEndpoints(endpoints =>
{ {
endpoints.MapControllers(); endpoints.MapControllers();

View File

@ -38,7 +38,8 @@
"SupportedLanguages": [ "FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK" ], "SupportedLanguages": [ "FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK" ],
"OpenWeatherApiKey": "d489973b4c09ddc5fb56bd7b9270bbef", "OpenWeatherApiKey": "d489973b4c09ddc5fb56bd7b9270bbef",
"AI": { "AI": {
"ApiKey": "AIzaSyCIf-mzp4Nzm5VwHL7LLzitt9z_bOOGMwc" "ApiKey": "AIzaSyCIf-mzp4Nzm5VwHL7LLzitt9z_bOOGMwc",
"SearchTopK": 5
}, },
"Firebase": { "Firebase": {
"CredentialsPath": "firebase-adminsdk.json" "CredentialsPath": "firebase-adminsdk.json"