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:
parent
af6b5b76ef
commit
18e4240f0f
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
29
ManagerService.Tests/Infrastructure/TestSection.cs
Normal file
29
ManagerService.Tests/Infrastructure/TestSection.cs
Normal 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>()
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
|
||||
32
ManagerService/DTOs/GuideInsightsDTO.cs
Normal file
32
ManagerService/DTOs/GuideInsightsDTO.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
19
ManagerService/DTOs/GuideKnowledgeDTO.cs
Normal file
19
ManagerService/DTOs/GuideKnowledgeDTO.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
{
|
||||
|
||||
200
ManagerService/Data/SectionIndexingInterceptor.cs
Normal file
200
ManagerService/Data/SectionIndexingInterceptor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
ManagerService/Data/SectionText.cs
Normal file
51
ManagerService/Data/SectionText.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
22
ManagerService/Services/IIngestionService.cs
Normal file
22
ManagerService/Services/IIngestionService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
48
ManagerService/Services/IVectorStoreService.cs
Normal file
48
ManagerService/Services/IVectorStoreService.cs
Normal 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);
|
||||
}
|
||||
236
ManagerService/Services/IngestionService.cs
Normal file
236
ManagerService/Services/IngestionService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
158
ManagerService/Services/VectorStoreService.cs
Normal file
158
ManagerService/Services/VectorStoreService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
ManagerService/Services/VisitorQuestionPurgeService.cs
Normal file
65
ManagerService/Services/VisitorQuestionPurgeService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user