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)
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 Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Hangfire;
using Moq;
using System;
using System.Collections.Generic;
@ -28,7 +29,8 @@ namespace ManagerService.Tests.Controllers
.Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>()))
.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));
return controller;
}
@ -112,7 +114,7 @@ namespace ManagerService.Tests.Controllers
db.Instances.Add(new Instance
{
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
{
@ -136,7 +138,7 @@ namespace ManagerService.Tests.Controllers
db.Instances.Add(new Instance
{
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
{
@ -177,6 +179,34 @@ namespace ManagerService.Tests.Controllers
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 ──────────────────────────────────────────────────────────
[Fact]
@ -189,7 +219,7 @@ namespace ManagerService.Tests.Controllers
db.Instances.Add(new Instance
{
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
{

View File

@ -1,4 +1,6 @@
using Hangfire;
using Manager.Services;
using Manager.Interfaces.Models;
using ManagerService.Controllers;
using ManagerService.Data;
using ManagerService.DTOs;
@ -7,13 +9,14 @@ using ManagerService.Services;
using ManagerService.Tests.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace ManagerService.Tests.Controllers
{
public class InstanceControllerTests
{
private InstanceController BuildController(MyInfoMateDbContext db)
private InstanceController BuildController(MyInfoMateDbContext db, Mock<IBackgroundJobClient> jobs = null)
{
var cfg = FakeMongoConfig.Create();
var instanceService = new InstanceDatabaseService(cfg);
@ -27,7 +30,8 @@ namespace ManagerService.Tests.Controllers
userService,
profileLogic,
db,
apiKeyService);
apiKeyService,
(jobs ?? new Mock<IBackgroundJobClient>()).Object);
}
// ── CREATE ───────────────────────────────────────────────────────────
@ -96,6 +100,62 @@ namespace ManagerService.Tests.Controllers
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]
public void Updateinstance_UnknownId_Returns404()
{

View File

@ -63,6 +63,47 @@ namespace ManagerService.Tests.Controllers
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 ───────────────────────────────────────────────────────────
[Fact]
@ -109,7 +150,9 @@ namespace ManagerService.Tests.Controllers
using var db = DbContextFactory.Create();
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.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();
BuildController(db).Delete("r1");

View File

@ -45,8 +45,8 @@ namespace ManagerService.Tests.Controllers
{
using var db = DbContextFactory.Create();
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>() },
new Section { Id = "s2", InstanceId = "other-inst", Label = "B", Type = SectionType.Article, ConfigurationId = "c2", Title = new List<TranslationDTO>(), Description = new List<TranslationDTO>() }
TestSection.Article("s1", "inst-test", "A", "c1"),
TestSection.Article("s2", "other-inst", "B", "c2")
);
db.SaveChanges();
@ -102,7 +102,7 @@ namespace ManagerService.Tests.Controllers
public void Delete_ExistingSection_Returns202()
{
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();
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.DTOs;
using ManagerService.Services;
@ -7,6 +8,7 @@ using Microsoft.Extensions.Logging;
using NSwag.Annotations;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@ -27,15 +29,20 @@ namespace ManagerService.Controllers
private readonly IAssistantService _assistantService;
private readonly MyInfoMateDbContext _context;
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(
IAssistantService assistantService,
MyInfoMateDbContext context,
ILogger<AiController> logger)
ILogger<AiController> logger,
IBackgroundJobClient jobs)
{
_assistantService = assistantService;
_context = context;
_logger = logger;
_jobs = jobs;
}
private string? GetCallerInstanceId() =>
@ -60,7 +67,15 @@ namespace ManagerService.Controllers
}
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é");
if (instance.IsTrialActive && instance.TrialAiTokensUsed >= TrialAiTokensCap)
@ -77,6 +92,211 @@ namespace ManagerService.Controllers
_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>
/// Traduit un texte HTML vers plusieurs langues via IA
/// </summary>
@ -147,6 +367,7 @@ namespace ManagerService.Controllers
var result = await _assistantService.ChatAsync(request);
RecordUsage(instance, result.TokensUsed);
RecordVisitorQuestion(request, 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)))
{
Section newSection = new Section();
Section newSection = SectionFactory.CreateEmpty(section.type);
newSection.Id = section.id;
newSection.InstanceId = section.instanceId;
newSection.Label = section.label;

View File

@ -1,7 +1,8 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Hangfire;
using Manager.Services;
using ManagerService.Data;
using ManagerService.DTOs;
@ -27,9 +28,12 @@ namespace ManagerService.Controllers
private readonly ILogger<InstanceController> _logger;
private readonly ProfileLogic _profileLogic;
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();
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;
_instanceService = instanceService;
@ -37,6 +41,7 @@ namespace ManagerService.Controllers
_profileLogic = profileLogic;
_myInfoMateDbContext = myInfoMateDbContext;
_apiKeyService = apiKeyService;
_jobs = jobs;
}
/// <summary>
@ -94,6 +99,28 @@ 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>
/// Create an instance
/// </summary>
@ -117,18 +144,7 @@ namespace ManagerService.Controllers
instance.Id = idService.GenerateHexId();
// Copier les valeurs du plan comme valeurs par défaut
if (instance.SubscriptionPlanId != null)
{
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;
}
}
ApplyPlanQuotas(instance);
/*List<OldInstance> instances = _instanceService.GetAll();
Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == id);*/
@ -195,14 +211,33 @@ namespace ManagerService.Controllers
instance.IsWeb = updatedInstance.isWeb ?? instance.IsWeb;
instance.IsVR = updatedInstance.isVR ?? instance.IsVR;
instance.IsAssistant = updatedInstance.isAssistant ?? instance.IsAssistant;
var previousPlanId = instance.SubscriptionPlanId;
var previousAiTokens = instance.AiTokensPerMonth;
if (updatedInstance.subscriptionPlanId == "")
instance.SubscriptionPlanId = null;
else if (updatedInstance.subscriptionPlanId != null)
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);
_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();
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.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.SaveChanges();
//OldResource resourceCreated = _resourceService.Create(resource);

View File

@ -533,100 +533,14 @@ namespace ManagerService.Controllers
if (configuration == null)
throw new KeyNotFoundException("Configuration does not exist");
// Todo add some verification ?
Section section = new Section();
// Preparation
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:
section = new SectionAgenda
{
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;
article.ArticleContent = LanguageInit.Init("Content", languages);
article.ArticleAudioIds = LanguageInit.Init("Audio", languages, true);
}
section.InstanceId = newSection.instanceId;
@ -816,12 +730,16 @@ namespace ManagerService.Controllers
MqttClientService.PublishMessage($"config/{existingSection.ConfigurationId}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true }));
if (existingSection.Type == SectionType.Agenda)
{
var sa = _myInfoMateDbContext.Sections.OfType<SectionAgenda>().FirstOrDefault(s => s.Id == existingSection.Id);
if (sa?.IsOnlineAgenda == true && sa.AgendaResourceIds?.Count > 0)
BackgroundJob.Enqueue<AgendaSyncService>(s => s.SyncSectionAsync(sa.Id));
}
// 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.
// Pour tous les autres cas, SectionIndexingInterceptor a déjà pris le relais
// au SaveChanges ci-dessus — rien à enqueue ici.
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));

View File

@ -58,6 +58,24 @@ namespace ManagerService.DTOs
/// </summary>
public bool ExpectsReply { get; set; } = true;
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

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.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Microsoft.EntityFrameworkCore;
@ -12,7 +14,7 @@ namespace ManagerService.Data
/// Section Information
/// </summary>
[Index(nameof(InstanceId))]
public class Section : IAuditableEntity
public abstract class Section : IAuditableEntity
{
[Key]
[Required]
@ -64,6 +66,30 @@ namespace ManagerService.Data
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()
{

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.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
namespace ManagerService.Data.SubSection
{
@ -68,6 +69,18 @@ namespace ManagerService.Data.SubSection
[ForeignKey("SectionEventId")]
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()
{
return new EventAgendaDTO()

View File

@ -63,6 +63,26 @@ namespace ManagerService.Data.SubSection
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()
{
return new GuidedPathDTO

View File

@ -58,6 +58,27 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")]
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()
{
return new GuidedStepDTO

View File

@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text.Json.Serialization;
namespace ManagerService.Data.SubSection
@ -55,6 +56,12 @@ namespace ManagerService.Data.SubSection
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
/*public TranslationDTO ToDTO()
{

View File

@ -6,6 +6,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -25,6 +27,18 @@ namespace ManagerService.Data.SubSection
[Required]
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()
{
return new AgendaDTO()

View File

@ -3,8 +3,11 @@ using ManagerService.DTOs;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -28,6 +31,16 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")]
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()
{
return new ArticleDTO()

View File

@ -8,6 +8,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -25,6 +27,31 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")]
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
{
[Key]

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -33,6 +35,17 @@ namespace ManagerService.Data.SubSection
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()
{
return new GameDTO()

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -29,6 +31,23 @@ namespace ManagerService.Data.SubSection
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()
{
return new MapDTO()
@ -120,6 +139,19 @@ namespace ManagerService.Data.SubSection
[ForeignKey(nameof(SectionEventId))]
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()
{
return new GeoPointDTO

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -17,6 +19,12 @@ namespace ManagerService.Data.SubSection
[Required]
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()
{
return new MenuDTO()

View File

@ -1,10 +1,12 @@
using Manager.DTOs;
using Manager.DTOs;
using ManagerService.DTOs;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
public class SectionParcours : Section
@ -17,6 +19,18 @@ namespace ManagerService.Data.SubSection
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()
{
return new ParcoursDTO

View File

@ -6,6 +6,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -17,6 +19,20 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")]
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()
{
return new PdfDTO()

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -34,6 +36,25 @@ namespace ManagerService.Data.SubSection
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()
{
return new QuizDTO()

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -18,6 +20,12 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")]
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()
{
return new SliderDTO()

View File

@ -1,7 +1,11 @@
using Manager.DTOs;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -12,6 +16,16 @@ namespace ManagerService.Data.SubSection
[Required]
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()
{
return new VideoDTO()

View File

@ -7,6 +7,8 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -20,6 +22,13 @@ namespace ManagerService.Data.SubSection
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()
{
return new WeatherDTO()

View File

@ -1,8 +1,11 @@
using Manager.DTOs;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using static ManagerService.Data.SectionText;
namespace ManagerService.Data.SubSection
{
/// <summary>
@ -13,6 +16,11 @@ namespace ManagerService.Data.SubSection
[Required]
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()
{
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();
_logger.LogInformation("Synced agenda section {Id}", sectionAgendaId);
}

View File

@ -19,14 +19,27 @@ namespace ManagerService.Services
private readonly IChatClient _chatClient;
private readonly MyInfoMateDbContext _context;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IVectorStoreService _vectorStore;
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;
_context = context;
_httpClientFactory = httpClientFactory;
_vectorStore = vectorStore;
}
public async Task<AiTranslateResponse> TranslateAsync(AiTranslateRequest request)
@ -194,6 +207,26 @@ namespace ManagerService.Services
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)
{
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].
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).
15. {SearchKnowledgeRule}
""" : $"""
Tu es le guide de visite de l'application "{config?.Label ?? "cette application"}".
{guideIdentity}
@ -265,6 +299,7 @@ namespace ManagerService.Services
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".
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 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.
@ -279,6 +314,7 @@ namespace ManagerService.Services
NavigationActionDTO? navigation = null;
List<AiCardDTO>? cards = null;
var sources = new List<AiSourceDTO>();
var tools = new List<AITool>
{
@ -499,6 +535,23 @@ namespace ManagerService.Services
"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."
),
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)
@ -545,7 +598,7 @@ namespace ManagerService.Services
reply = response.Text ?? "";
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)
{
@ -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].
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.
9. {SearchKnowledgeRule}
""" : $"""
Tu es le guide principal. Ton rôle est d'orienter le visiteur vers la bonne expérience de visite.
{guideIdentity}
@ -621,6 +675,7 @@ namespace ManagerService.Services
5. Pour orienter vers une visite spécifique (jeu, quiz...) "navigate_to_configuration".
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}.
8. {SearchKnowledgeRule}
- 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.
""";
@ -633,6 +688,7 @@ namespace ManagerService.Services
messages.Add(new ChatMessage(ChatRole.User, request.Message));
NavigationActionDTO? navigation = null;
var sources = new List<AiSourceDTO>();
var tools = new List<AITool>
{
@ -817,6 +873,23 @@ namespace ManagerService.Services
"GetItemDetailsGlobal",
"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(
(string configurationId, string configurationTitle) =>
{
@ -844,7 +917,7 @@ namespace ManagerService.Services
reply = response.Text ?? "";
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)
{

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 Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@ -11,6 +12,53 @@ namespace ManagerService.Services
{
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)
{
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());
services.AddScoped<IAssistantService, AssistantService>();
services.AddScoped<IEmbeddingService, GoogleEmbeddingService>();
services.AddScoped<IVectorStoreService, VectorStoreService>();
services.AddScoped<IIngestionService, IngestionService>();
// Push Notifications
var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"];
@ -206,6 +208,7 @@ namespace ManagerService
services.AddScoped<WeatherSyncService>();
services.AddScoped<TrialLifecycleService>();
services.AddScoped<VisitEventPurgeService>();
services.AddScoped<VisitorQuestionPurgeService>();
var connectionString = Configuration.GetConnectionString("PostgresConnection");
@ -215,7 +218,18 @@ namespace ManagerService
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.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);
dataSourceBuilder.UseNetTopologySuite();
@ -223,8 +237,14 @@ namespace ManagerService
dataSourceBuilder.EnableDynamicJson();
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())
.AddInterceptors(serviceProvider.GetRequiredService<SectionIndexingInterceptor>())
.EnableSensitiveDataLogging()
.LogTo(Console.WriteLine, LogLevel.Information)
);
@ -306,6 +326,12 @@ namespace ManagerService
s => s.PurgeAsync(),
"0 3 * * *");
// Actif sans condition : les 90 jours sont un engagement des CGU §8.4, pas un réglage.
RecurringJob.AddOrUpdate<VisitorQuestionPurgeService>(
"visitor-questions-purge",
s => s.PurgeAsync(),
"30 3 * * *");
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();

View File

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