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>
164 lines
6.5 KiB
C#
164 lines
6.5 KiB
C#
using Manager.DTOs;
|
||
using ManagerService.DTOs;
|
||
using NetTopologySuite.Geometries;
|
||
using System.Collections.Generic;
|
||
using System.ComponentModel.DataAnnotations.Schema;
|
||
using System.ComponentModel.DataAnnotations;
|
||
using System.Linq;
|
||
using ManagerService.Helpers;
|
||
|
||
namespace ManagerService.Data.SubSection
|
||
{
|
||
public class GuidedStep // Étape d’un parcours
|
||
{
|
||
[Key]
|
||
public string Id { get; set; }
|
||
|
||
[Required]
|
||
public string GuidedPathId { get; set; }
|
||
|
||
[ForeignKey("GuidedPathId")]
|
||
public GuidedPath GuidedPath { get; set; }
|
||
|
||
public int Order { get; set; }
|
||
|
||
[Required]
|
||
[Column(TypeName = "jsonb")]
|
||
public List<TranslationDTO> Title { get; set; }
|
||
|
||
[Column(TypeName = "jsonb")]
|
||
public List<TranslationDTO> Description { get; set; }
|
||
|
||
public Geometry? Geometry { get; set; } // Polygon ou centre du cercle
|
||
|
||
// Option : si true, cette étape doit être atteinte géographiquement (zone/rayon) pour être validée
|
||
public bool IsGeoTriggered { get; set; } = false;
|
||
|
||
public double? ZoneRadiusMeters { get; set; } // Optionnel, utile si zone cercle ou point
|
||
|
||
public string ImageUrl { get; set; }
|
||
|
||
// Rich content (calqué sur SectionArticle)
|
||
[Column(TypeName = "jsonb")]
|
||
public List<TranslationDTO> AudioIds { get; set; } = new();
|
||
|
||
[Column(TypeName = "jsonb")]
|
||
public List<ContentDTO> Contents { get; set; } = new();
|
||
|
||
// Exemple pour escape game
|
||
public List<QuizQuestion>? QuizQuestions { get; set; } // One or multiple question
|
||
|
||
// Option : si true, cette étape a un compte à rebourds, false sinon
|
||
public bool IsStepTimer { get; set; } = false;
|
||
|
||
// Timer en secondes (durée max pour valider cette étape, optionnel)
|
||
public int? TimerSeconds { get; set; }
|
||
|
||
// Option : message ou action à effectuer si timer expire (ex: afficher aide, fin du jeu...)
|
||
[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
|
||
{
|
||
id = Id,
|
||
guidedPathId = GuidedPathId,
|
||
order = Order,
|
||
title = Title,
|
||
description = Description,
|
||
geometry = Geometry?.ToDto(),
|
||
isGeoTriggered = IsGeoTriggered,
|
||
zoneRadiusMeters = ZoneRadiusMeters,
|
||
imageUrl = ImageUrl,
|
||
audioIds = AudioIds,
|
||
contents = Contents,
|
||
isStepTimer = IsStepTimer,
|
||
timerSeconds = TimerSeconds,
|
||
timerExpiredMessage = TimerExpiredMessage,
|
||
quizQuestions = QuizQuestions
|
||
};
|
||
}
|
||
|
||
public GuidedStep FromDTO(GuidedStepDTO dto)
|
||
{
|
||
if (dto == null) return null;
|
||
|
||
GuidedPathId = dto.guidedPathId;
|
||
Title = dto.title ?? new List<TranslationDTO>();
|
||
Description = dto.description ?? new List<TranslationDTO>();
|
||
Geometry = dto.geometry.FromDto();
|
||
IsGeoTriggered = dto.isGeoTriggered;
|
||
ZoneRadiusMeters = dto.zoneRadiusMeters;
|
||
ImageUrl = dto.imageUrl;
|
||
AudioIds = dto.audioIds ?? new List<TranslationDTO>();
|
||
Contents = dto.contents ?? new List<ContentDTO>();
|
||
IsStepTimer = dto.isStepTimer;
|
||
TimerSeconds = dto.timerSeconds;
|
||
TimerExpiredMessage = dto.timerExpiredMessage ?? new List<TranslationDTO>();
|
||
Order = dto.order.GetValueOrDefault();
|
||
SyncQuizQuestions(dto.quizQuestions);
|
||
return this;
|
||
}
|
||
|
||
// Merges incoming quiz questions into the tracked QuizQuestions collection by Id,
|
||
// instead of replacing the reference outright — a blind reassignment would make EF
|
||
// treat every incoming question (including already-persisted ones) as a brand new
|
||
// row to insert, which fails with a duplicate key error as soon as a step already
|
||
// has a saved question and a new one is added alongside it.
|
||
public void SyncQuizQuestions(List<QuizQuestion>? dtoQuestions)
|
||
{
|
||
dtoQuestions ??= new List<QuizQuestion>();
|
||
QuizQuestions ??= new List<QuizQuestion>();
|
||
|
||
var dtoIds = dtoQuestions.Where(q => q.Id != 0).Select(q => q.Id).ToHashSet();
|
||
QuizQuestions.RemoveAll(q => !dtoIds.Contains(q.Id));
|
||
|
||
foreach (var qDto in dtoQuestions)
|
||
{
|
||
var existing = qDto.Id != 0 ? QuizQuestions.FirstOrDefault(q => q.Id == qDto.Id) : null;
|
||
if (existing != null)
|
||
{
|
||
existing.Label = qDto.Label;
|
||
existing.ResourceId = qDto.ResourceId;
|
||
existing.Order = qDto.Order;
|
||
existing.Responses = qDto.Responses;
|
||
existing.ValidationQuestionType = qDto.ValidationQuestionType;
|
||
existing.PuzzleImageId = qDto.PuzzleImageId;
|
||
existing.PuzzleRows = qDto.PuzzleRows;
|
||
existing.PuzzleCols = qDto.PuzzleCols;
|
||
existing.IsSlidingPuzzle = qDto.IsSlidingPuzzle;
|
||
}
|
||
else
|
||
{
|
||
qDto.Id = 0;
|
||
QuizQuestions.Add(qDto);
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
}
|