misc (ia assistant, SectionParcours + others fixs)

This commit is contained in:
Thomas Fransolet 2026-07-08 17:00:43 +02:00
parent 674655eb80
commit 3cd5f7573d
36 changed files with 11265 additions and 117 deletions

11
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "manager-service (debug)",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/ManagerService/ManagerService.csproj"
}
]
}

View File

@ -88,6 +88,7 @@ namespace ManagerService.Controllers
return Forbid();
// Vérifie que l'app concernée a activé l'assistant
// Pour AppType.Voice : fallback sur Mobile si pas d'instance Voice dédiée
var appInstance = _context.ApplicationInstances
.FirstOrDefault(ai => ai.InstanceId == request.InstanceId && ai.AppType == request.AppType);

View File

@ -229,7 +229,7 @@ namespace ManagerService.Controllers
break;
case SectionType.Game:
Resource resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == (dto as GameDTO).puzzleImageId);
(dto as GameDTO).puzzleImage = resource.ToDTO();
(dto as GameDTO).puzzleImage = resource?.ToDTO();
break;
case SectionType.Map:
var geoPoints = _myInfoMateDbContext.GeoPoints.Where(gp => gp.SectionMapId == section.Id)/*.OrderBy(gp => gp.or)*/.ToList();
@ -348,6 +348,15 @@ namespace ManagerService.Controllers
}
(dto as MenuDTO).sections = subSectionToReturn;
break;
case SectionType.Parcours:
var parcoursPaths = _myInfoMateDbContext.GuidedPaths
.Include(gp => gp.ImageResource)
.Include(gp => gp.Steps).ThenInclude(s => s.QuizQuestions)
.Where(gp => gp.SectionParcoursId == section.Id)
.OrderBy(gp => gp.Order)
.ToList();
(dto as ParcoursDTO).guidedPaths = parcoursPaths.Select(gp => gp.ToDTO()).ToList();
break;
}
sectionsToReturn.Add(dto);
@ -612,6 +621,12 @@ namespace ManagerService.Controllers
WebSource = "",
};
break;
case SectionType.Parcours:
section = new SectionParcours
{
GuidedPaths = new List<GuidedPath>()
};
break;
}
section.InstanceId = newSection.instanceId;
@ -936,13 +951,6 @@ namespace ManagerService.Controllers
// REMOVE ALL POINTS
var geoPoints = _myInfoMateDbContext.GeoPoints.Where(gp => gp.SectionMapId == section.Id).ToList();
_myInfoMateDbContext.RemoveRange(geoPoints);
var guidedPaths = _myInfoMateDbContext.GuidedPaths.Include(gp => gp.Steps).Where(gp => gp.SectionMapId == id);
foreach (var guidedPath in guidedPaths)
{
_myInfoMateDbContext.RemoveRange(guidedPath.Steps);
_myInfoMateDbContext.Remove(guidedPath);
}
}
if (section.Type == SectionType.Quiz)
@ -952,6 +960,16 @@ namespace ManagerService.Controllers
_myInfoMateDbContext.RemoveRange(quizQuestions);
}
if (section.Type == SectionType.Parcours)
{
var guidedPaths = _myInfoMateDbContext.GuidedPaths.Include(gp => gp.Steps).Where(gp => gp.SectionParcoursId == id);
foreach (var guidedPath in guidedPaths)
{
_myInfoMateDbContext.RemoveRange(guidedPath.Steps);
_myInfoMateDbContext.Remove(guidedPath);
}
}
_myInfoMateDbContext.Remove(section);
var configuration = _myInfoMateDbContext.Configurations.FirstOrDefault(c => c.Id == section.ConfigurationId);

View File

@ -339,9 +339,8 @@ namespace ManagerService.Controllers
try
{
List<GuidedPath> guidedPaths = _myInfoMateDbContext.GuidedPaths
.Include(gp => gp.Steps).ThenInclude(gp => gp.QuizQuestions)
.Include(gp => gp.Steps).ThenInclude(gp => gp.TriggerGeoPoint)
.Where(gp => gp.SectionMapId == sectionMapId).ToList();
.Include(gp => gp.Steps).ThenInclude(gp => gp.QuizQuestions).ThenInclude(q => q.PuzzleImage)
.Where(gp => gp.SectionEventId == sectionMapId).ToList();
/*List<ProgrammeBlock> programmeBlocks = new List<ProgrammeBlock>();
foreach (var program in sectionEvent.Programme)
@ -387,13 +386,14 @@ namespace ManagerService.Controllers
if (guidedPathDTO == null)
throw new ArgumentNullException("GuidedPath param is null");
var existingSection = _myInfoMateDbContext.Sections.OfType<SectionMap>().FirstOrDefault(sm => sm.Id == sectionMapId);
if (existingSection == null)
throw new KeyNotFoundException("Section map does not exist");
var existingSectionEvent = _myInfoMateDbContext.Sections.OfType<SectionEvent>().FirstOrDefault(se => se.Id == sectionMapId);
if (existingSectionEvent == null)
throw new KeyNotFoundException("Section event does not exist");
GuidedPath guidedPath = new GuidedPath().FromDTO(guidedPathDTO);
guidedPath.Id = idService.GenerateHexId();
guidedPath.SectionMapId = sectionMapId;
guidedPath.SectionEventId = existingSectionEvent?.Id;
guidedPath.Steps = new List<GuidedStep>();
foreach (var stepDTO in guidedPathDTO.steps ?? new List<GuidedStepDTO>())
{
@ -438,7 +438,7 @@ namespace ManagerService.Controllers
throw new ArgumentNullException("GuidedPath param is null");
var existingGuidedPath = _myInfoMateDbContext.GuidedPaths
.Include(gp => gp.Steps)
.Include(gp => gp.Steps).ThenInclude(s => s.QuizQuestions)
.FirstOrDefault(gp => gp.Id == guidedPathDTO.id);
if (existingGuidedPath == null)
throw new KeyNotFoundException("GuidedPath does not exist");
@ -446,9 +446,7 @@ namespace ManagerService.Controllers
existingGuidedPath.InstanceId = guidedPathDTO.instanceId;
existingGuidedPath.Title = guidedPathDTO.title ?? new List<TranslationDTO>();
existingGuidedPath.Description = guidedPathDTO.description ?? new List<TranslationDTO>();
existingGuidedPath.SectionMapId = guidedPathDTO.sectionMapId;
existingGuidedPath.SectionEventId = guidedPathDTO.sectionEventId;
existingGuidedPath.SectionGameId = guidedPathDTO.sectionGameId;
existingGuidedPath.IsLinear = guidedPathDTO.isLinear;
existingGuidedPath.RequireSuccessToAdvance = guidedPathDTO.requireSuccessToAdvance;
existingGuidedPath.HideNextStepsUntilComplete = guidedPathDTO.hideNextStepsUntilComplete;
@ -553,7 +551,6 @@ namespace ManagerService.Controllers
{
List<GuidedStep> guidedSteps = _myInfoMateDbContext.GuidedSteps
.Include(gs => gs.QuizQuestions)
.Include(gs => gs.TriggerGeoPoint)
.Where(gs => gs.GuidedPathId == guidedPathId).ToList();
/*List<ProgrammeBlock> programmeBlocks = new List<ProgrammeBlock>();
@ -657,7 +654,6 @@ namespace ManagerService.Controllers
}
existingGuidedStep.ZoneRadiusMeters = guidedStepDTO.zoneRadiusMeters;
existingGuidedStep.ImageUrl = guidedStepDTO.imageUrl;
existingGuidedStep.TriggerGeoPointId = guidedStepDTO.triggerGeoPointId;
existingGuidedStep.IsHiddenInitially = guidedStepDTO.isHiddenInitially;
existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // <20> convertir si besoin ?
existingGuidedStep.IsStepTimer = guidedStepDTO.isStepTimer;

View File

@ -0,0 +1,382 @@
using Manager.DTOs;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using ManagerService.Helpers;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NSwag.Annotations;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ManagerService.Controllers
{
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Section parcours", Description = "Section parcours management")]
public class SectionParcoursController : ControllerBase
{
private readonly MyInfoMateDbContext _myInfoMateDbContext;
private readonly ILogger<SectionParcoursController> _logger;
private readonly IConfiguration _configuration;
IHexIdGeneratorService idService = new HexIdGeneratorService();
public SectionParcoursController(IConfiguration configuration, ILogger<SectionParcoursController> logger, MyInfoMateDbContext myInfoMateDbContext)
{
_logger = logger;
_configuration = configuration;
_myInfoMateDbContext = myInfoMateDbContext;
}
/// <summary>
/// Get all guided paths from a parcours section
/// </summary>
[AllowAnonymous]
[ProducesResponseType(typeof(List<GuidedPathDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{sectionParcoursId}/guided-path")]
public ObjectResult GetAllGuidedPathFromSection(string sectionParcoursId)
{
try
{
List<GuidedPath> guidedPaths = _myInfoMateDbContext.GuidedPaths
.Include(gp => gp.ImageResource)
.Include(gp => gp.Steps).ThenInclude(s => s.QuizQuestions).ThenInclude(q => q.PuzzleImage)
.Where(gp => gp.SectionParcoursId == sectionParcoursId)
.OrderBy(gp => gp.Order)
.ToList();
return new OkObjectResult(guidedPaths.Select(gp => gp.ToDTO()));
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Create a new guided path in a parcours section
/// </summary>
[ProducesResponseType(typeof(GuidedPathDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("{sectionParcoursId}/guided-path")]
public ObjectResult CreateGuidedPath(string sectionParcoursId, [FromBody] GuidedPathDTO guidedPathDTO)
{
try
{
if (sectionParcoursId == null)
throw new ArgumentNullException("SectionParcoursId param is null");
if (guidedPathDTO == null)
throw new ArgumentNullException("GuidedPath param is null");
var sectionParcours = _myInfoMateDbContext.Sections.OfType<SectionParcours>().FirstOrDefault(sp => sp.Id == sectionParcoursId);
if (sectionParcours == null)
throw new KeyNotFoundException("SectionParcours does not exist");
GuidedPath guidedPath = new GuidedPath().FromDTO(guidedPathDTO);
guidedPath.Id = idService.GenerateHexId();
guidedPath.SectionParcoursId = sectionParcoursId;
guidedPath.Order = _myInfoMateDbContext.GuidedPaths.Count(gp => gp.SectionParcoursId == sectionParcoursId);
guidedPath.Steps = new List<GuidedStep>();
foreach (var stepDTO in guidedPathDTO.steps ?? new List<GuidedStepDTO>())
{
GuidedStep step = new GuidedStep().FromDTO(stepDTO);
step.Id = idService.GenerateHexId();
step.GuidedPathId = guidedPath.Id;
guidedPath.Steps.Add(step);
}
_myInfoMateDbContext.GuidedPaths.Add(guidedPath);
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(guidedPath.ToDTO());
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message);
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Update a guided path
/// </summary>
[ProducesResponseType(typeof(GuidedPathDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPut("guided-path")]
public ObjectResult UpdateGuidedPath([FromBody] GuidedPathDTO guidedPathDTO)
{
try
{
if (guidedPathDTO == null)
throw new ArgumentNullException("GuidedPath param is null");
var existingGuidedPath = _myInfoMateDbContext.GuidedPaths
.Include(gp => gp.Steps).ThenInclude(s => s.QuizQuestions)
.FirstOrDefault(gp => gp.Id == guidedPathDTO.id);
if (existingGuidedPath == null)
throw new KeyNotFoundException("GuidedPath does not exist");
existingGuidedPath.InstanceId = guidedPathDTO.instanceId;
existingGuidedPath.Title = guidedPathDTO.title ?? new List<TranslationDTO>();
existingGuidedPath.Description = guidedPathDTO.description ?? new List<TranslationDTO>();
existingGuidedPath.IsLinear = guidedPathDTO.isLinear;
existingGuidedPath.RequireSuccessToAdvance = guidedPathDTO.requireSuccessToAdvance;
existingGuidedPath.HideNextStepsUntilComplete = guidedPathDTO.hideNextStepsUntilComplete;
existingGuidedPath.EstimatedDurationMinutes = guidedPathDTO.estimatedDurationMinutes;
existingGuidedPath.ImageResourceId = guidedPathDTO.imageResourceId;
existingGuidedPath.Order = guidedPathDTO.order.GetValueOrDefault();
existingGuidedPath.IsGameMode = guidedPathDTO.isGameMode;
existingGuidedPath.GameMessageDebut = guidedPathDTO.gameMessageDebut ?? new List<TranslationAndResourceDTO>();
existingGuidedPath.GameMessageFin = guidedPathDTO.gameMessageFin ?? new List<TranslationAndResourceDTO>();
// Sync steps
var dtoStepIds = (guidedPathDTO.steps ?? new List<GuidedStepDTO>())
.Where(s => s.id != null)
.Select(s => s.id)
.ToHashSet();
var stepsToRemove = existingGuidedPath.Steps
.Where(s => !dtoStepIds.Contains(s.Id))
.ToList();
foreach (var s in stepsToRemove)
existingGuidedPath.Steps.Remove(s);
foreach (var stepDTO in guidedPathDTO.steps ?? new List<GuidedStepDTO>())
{
var existing = existingGuidedPath.Steps.FirstOrDefault(s => s.Id == stepDTO.id);
if (existing != null)
{
existing.FromDTO(stepDTO);
existing.GuidedPathId = existingGuidedPath.Id;
}
else
{
var newStep = new GuidedStep().FromDTO(stepDTO);
newStep.Id = idService.GenerateHexId();
newStep.GuidedPathId = existingGuidedPath.Id;
existingGuidedPath.Steps.Add(newStep);
}
}
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(existingGuidedPath.ToDTO());
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message);
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Delete a guided path
/// </summary>
[ProducesResponseType(typeof(string), 202)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpDelete("guided-path/{guidedPathId}")]
public ObjectResult DeleteGuidedPath(string guidedPathId)
{
try
{
var guidedPath = _myInfoMateDbContext.GuidedPaths.Include(gp => gp.Steps).FirstOrDefault(gp => gp.Id == guidedPathId);
if (guidedPath == null)
throw new KeyNotFoundException("GuidedPath does not exist");
_myInfoMateDbContext.RemoveRange(guidedPath.Steps);
_myInfoMateDbContext.Remove(guidedPath);
_myInfoMateDbContext.SaveChanges();
return new ObjectResult("The guidedPath has been deleted") { StatusCode = 202 };
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Get all steps from a guided path
/// </summary>
[AllowAnonymous]
[ProducesResponseType(typeof(List<GuidedStepDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("guided-path/{guidedPathId}/guided-step")]
public ObjectResult GetAllGuidedStepFromGuidedPath(string guidedPathId)
{
try
{
List<GuidedStep> guidedSteps = _myInfoMateDbContext.GuidedSteps
.Include(gs => gs.QuizQuestions)
.Where(gs => gs.GuidedPathId == guidedPathId)
.OrderBy(gs => gs.Order)
.ToList();
return new OkObjectResult(guidedSteps.Select(gs => gs.ToDTO()));
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Create a new step in a guided path
/// </summary>
[ProducesResponseType(typeof(GuidedStepDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("guided-path/{guidedPathId}/guided-step")]
public ObjectResult CreateGuidedStep(string guidedPathId, [FromBody] GuidedStepDTO guidedStepDTO)
{
try
{
if (guidedPathId == null)
throw new ArgumentNullException("GuidedPathId param is null");
if (guidedStepDTO == null)
throw new ArgumentNullException("GuidedStep param is null");
var guidedPath = _myInfoMateDbContext.GuidedPaths.FirstOrDefault(gp => gp.Id == guidedPathId);
if (guidedPath == null)
throw new KeyNotFoundException("GuidedPath does not exist");
GuidedStep guidedStep = new GuidedStep().FromDTO(guidedStepDTO);
guidedStep.Id = idService.GenerateHexId();
guidedStep.GuidedPathId = guidedPathId;
guidedStep.Order = _myInfoMateDbContext.GuidedSteps.Count(gs => gs.GuidedPathId == guidedPathId);
_myInfoMateDbContext.GuidedSteps.Add(guidedStep);
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(guidedStep.ToDTO());
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message);
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Update a guided step
/// </summary>
[ProducesResponseType(typeof(GuidedStepDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPut("guided-step")]
public ObjectResult UpdateGuidedStep([FromBody] GuidedStepDTO guidedStepDTO)
{
try
{
if (guidedStepDTO == null)
throw new ArgumentNullException("GuidedStep param is null");
var existingGuidedStep = _myInfoMateDbContext.GuidedSteps
.Include(gs => gs.QuizQuestions)
.FirstOrDefault(gs => gs.Id == guidedStepDTO.id);
if (existingGuidedStep == null)
throw new KeyNotFoundException("GuidedStep does not exist");
existingGuidedStep.FromDTO(guidedStepDTO);
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(existingGuidedStep.ToDTO());
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message);
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Delete a guided step
/// </summary>
[ProducesResponseType(typeof(string), 202)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpDelete("guided-step/{guidedStepId}")]
public ObjectResult DeleteGuidedStep(string guidedStepId)
{
try
{
var guidedStep = _myInfoMateDbContext.GuidedSteps.Include(gs => gs.QuizQuestions).FirstOrDefault(gs => gs.Id == guidedStepId);
if (guidedStep == null)
throw new KeyNotFoundException("GuidedStep does not exist");
guidedStep.QuizQuestions?.Clear();
_myInfoMateDbContext.Remove(guidedStep);
_myInfoMateDbContext.SaveChanges();
return new ObjectResult("The guidedStep has been deleted") { StatusCode = 202 };
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
}
}

View File

@ -11,6 +11,12 @@ namespace ManagerService.DTOs
public string ConfigurationId { get; set; } // null = scope instance, fourni = scope configuration
public string Language { get; set; }
public List<AiChatMessage> History { get; set; } = new();
/// <summary>
/// true = interaction vocale (lunettes, assistant vocal).
/// Le backend adapte le prompt : pas de markdown, pas de navigation,
/// dates en toutes lettres, réponses courtes audio-friendly.
/// </summary>
public bool IsVoice { get; set; } = false;
}
public class AiChatMessage
@ -39,6 +45,11 @@ namespace ManagerService.DTOs
public string Reply { get; set; }
public List<AiCardDTO>? Cards { get; set; }
public NavigationActionDTO? Navigation { get; set; }
/// <summary>
/// false = le LLM ne s'attend pas à une réponse du visiteur (info pure, hors-sujet, politesse, beacon).
/// Le front peut désactiver l'écoute automatique après le TTS.
/// </summary>
public bool ExpectsReply { get; set; } = true;
}
public class AiTranslateRequest

View File

@ -8,13 +8,18 @@ namespace ManagerService.DTOs
public string instanceId { get; set; }
public List<TranslationDTO> title { get; set; }
public List<TranslationDTO> description { get; set; }
public string? sectionMapId { get; set; }
public string? sectionEventId { get; set; }
public string? sectionGameId { get; set; }
public string? sectionParcoursId { get; set; }
public bool isLinear { get; set; }
public bool requireSuccessToAdvance { get; set; }
public bool hideNextStepsUntilComplete { get; set; }
public int? estimatedDurationMinutes { get; set; }
public string? imageResourceId { get; set; }
public string? imageUrl { get; set; }
public int? order { get; set; }
public bool isGameMode { get; set; }
public List<TranslationAndResourceDTO> gameMessageDebut { get; set; }
public List<TranslationAndResourceDTO> gameMessageFin { get; set; }
public List<GuidedStepDTO> steps { get; set; }
}
}

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using Manager.DTOs;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
namespace ManagerService.DTOs
{
@ -12,10 +13,12 @@ namespace ManagerService.DTOs
public List<TranslationDTO> title { get; set; }
public List<TranslationDTO> description { get; set; }
public GeometryDTO geometry { get; set; }
public bool isGeoTriggered { get; set; }
public double? zoneRadiusMeters { get; set; }
public string imageUrl { get; set; }
public int? triggerGeoPointId { get; set; }
public GeoPointDTO? triggerGeoPoint { get; set; }
public List<TranslationDTO> audioIds { get; set; }
public List<ContentDTO> contents { get; set; }
public List<TranslationDTO> factContent { get; set; }
public bool isHiddenInitially { get; set; }
public bool isStepTimer { get; set; }
public bool isStepLocked { get; set; }

View File

@ -13,6 +13,7 @@
Game,
Agenda,
Weather,
Event
Event,
Parcours
}
}

View File

@ -13,6 +13,5 @@ namespace Manager.DTOs
public int rows { get; set; } = 3;
public int cols { get; set; } = 3;
public GameTypes gameType { get; set; } = GameTypes.Puzzle;
public List<GuidedPathDTO> guidedPaths { get; set; }
}
}

View File

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace ManagerService.DTOs
{
public class ParcoursDTO : SectionDTO
{
public bool showMap { get; set; }
public string? baseSectionMapId { get; set; }
public List<GuidedPathDTO> guidedPaths { get; set; }
}
}

View File

@ -7,8 +7,8 @@ namespace Manager.DTOs
{
public class SectionEventDTO : SectionDTO
{
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public string? BaseSectionMapId { get; set; }
public List<string> ParcoursIds { get; set; }
public List<MapAnnotationDTO> GlobalMapAnnotations { get; set; } = new();

View File

@ -105,7 +105,8 @@ namespace ManagerService.Data
Mobile,
Tablet,
Web,
VR
VR,
Voice // Lunettes Ray-Ban Meta / interface vocale — pas de navigation UI
}
public enum LayoutMainPageType

View File

@ -186,7 +186,8 @@ namespace ManagerService.Data
.HasValue<SectionSlider>("Slider")
.HasValue<SectionVideo>("Video")
.HasValue<SectionWeather>("Weather")
.HasValue<SectionWeb>("Web");
.HasValue<SectionWeb>("Web")
.HasValue<SectionParcours>("Parcours");
/*modelBuilder.Entity<GeoPoint>(entity =>
{
@ -283,6 +284,20 @@ namespace ManagerService.Data
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GuidedPath>()
.Property(gp => gp.GameMessageDebut)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationAndResourceDTO>>(v, options));
modelBuilder.Entity<GuidedPath>()
.Property(gp => gp.GameMessageFin)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationAndResourceDTO>>(v, options));
// Configurations JSON pour GuidedStep
modelBuilder.Entity<GuidedStep>()
.Property(gs => gs.Title)
@ -305,6 +320,43 @@ namespace ManagerService.Data
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GuidedStep>()
.Property(gs => gs.AudioIds)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GuidedStep>()
.Property(gs => gs.Contents)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<ContentDTO>>(v, options));
modelBuilder.Entity<GuidedStep>()
.Property(gs => gs.FactContent)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
// SectionParcours: lien optionnel vers une SectionMap de base
modelBuilder.Entity<SectionParcours>()
.HasOne(sp => sp.BaseMap)
.WithMany()
.HasForeignKey(sp => sp.BaseSectionMapId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
// GuidedPath: lien vers SectionParcours
modelBuilder.Entity<GuidedPath>()
.HasOne(gp => gp.SectionParcours)
.WithMany(sp => sp.GuidedPaths)
.HasForeignKey(gp => gp.SectionParcoursId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<EventAgenda>()
.Property(s => s.Label)
.HasColumnType("jsonb")

View File

@ -1,4 +1,5 @@
using ManagerService.DTOs;
using ManagerService.Data;
using ManagerService.DTOs;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
@ -21,28 +22,38 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")]
public List<TranslationDTO> Description { get; set; }
// Lié à une carte (optionnel)
public string? SectionMapId { get; set; }
[ForeignKey("SectionMapId")]
public SectionMap? SectionMap { get; set; }
// Lié à un événement (optionnel)
public string? SectionEventId { get; set; }
[ForeignKey("SectionEventId")]
public SectionEvent? SectionEvent { get; set; }
// Lié à une section game (optionnel) => Escape game / geolocalisé ou non
public string? SectionGameId { get; set; }
[ForeignKey("SectionGameId")]
public SectionGame? SectionGame { get; set; }
// Lié à une section parcours (FK principale)
public string? SectionParcoursId { get; set; }
[ForeignKey("SectionParcoursId")]
public SectionParcours? SectionParcours { get; set; }
// Type de parcours
public bool IsLinear { get; set; } = true; // Avancer dans lordre - Ordre obligatoire
public bool RequireSuccessToAdvance { get; set; } = false; // Par exemple: résoudre une énigme
public bool HideNextStepsUntilComplete { get; set; } = false; // Étapes cachées tant que non terminées
public int? EstimatedDurationMinutes { get; set; }
public string? ImageResourceId { get; set; }
[ForeignKey("ImageResourceId")]
public Resource? ImageResource { get; set; }
public int Order { get; set; }
public bool IsGameMode { get; set; } = false;
[Column(TypeName = "jsonb")]
public List<TranslationAndResourceDTO> GameMessageDebut { get; set; } = new();
[Column(TypeName = "jsonb")]
public List<TranslationAndResourceDTO> GameMessageFin { get; set; } = new();
public List<GuidedStep> Steps { get; set; } = new();
public GuidedPathDTO ToDTO()
@ -53,13 +64,19 @@ namespace ManagerService.Data.SubSection
instanceId = InstanceId,
title = Title,
description = Description,
sectionMapId = SectionMapId,
sectionEventId = SectionEventId,
sectionParcoursId = SectionParcoursId,
isLinear = IsLinear,
requireSuccessToAdvance = RequireSuccessToAdvance,
hideNextStepsUntilComplete = HideNextStepsUntilComplete,
estimatedDurationMinutes = EstimatedDurationMinutes,
imageResourceId = ImageResourceId,
imageUrl = ImageResource?.Url,
order = Order,
steps = Steps?.Select(s => s.ToDTO()).ToList()
isGameMode = IsGameMode,
gameMessageDebut = GameMessageDebut,
gameMessageFin = GameMessageFin,
steps = Steps?.OrderBy(s => s.Order).Select(s => s.ToDTO()).ToList()
};
}
@ -70,12 +87,17 @@ namespace ManagerService.Data.SubSection
InstanceId = dto.instanceId;
Title = dto.title ?? new List<TranslationDTO>();
Description = dto.description ?? new List<TranslationDTO>();
SectionMapId = dto.sectionMapId;
SectionEventId = dto.sectionEventId;
SectionParcoursId = dto.sectionParcoursId;
IsLinear = dto.isLinear;
RequireSuccessToAdvance = dto.requireSuccessToAdvance;
HideNextStepsUntilComplete = dto.hideNextStepsUntilComplete;
EstimatedDurationMinutes = dto.estimatedDurationMinutes;
ImageResourceId = dto.imageResourceId;
Order = dto.order.GetValueOrDefault();
IsGameMode = dto.isGameMode;
GameMessageDebut = dto.gameMessageDebut ?? new List<TranslationAndResourceDTO>();
GameMessageFin = dto.gameMessageFin ?? new List<TranslationAndResourceDTO>();
//Steps = dto.Steps?.Select(s => s.FromDTO()).ToList() ?? new List<GuidedStep>() // Other method
return this;
}

View File

@ -1,8 +1,10 @@
using ManagerService.DTOs;
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
@ -29,14 +31,22 @@ namespace ManagerService.Data.SubSection
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; }
public int? TriggerGeoPointId { get; set; } // Lieu lié à l'étape et genre si on veut plus d'info ou pourrait afficher les infos du geopoint.
// Rich content (calqué sur SectionArticle)
[Column(TypeName = "jsonb")]
public List<TranslationDTO> AudioIds { get; set; } = new();
[ForeignKey("TriggerGeoPointId")]
public GeoPoint? TriggerGeoPoint { get; set; }
[Column(TypeName = "jsonb")]
public List<ContentDTO> Contents { get; set; } = new();
[Column(TypeName = "jsonb")]
public List<TranslationDTO> FactContent { get; set; } = new();
public bool IsHiddenInitially { get; set; } = false;
@ -66,10 +76,12 @@ namespace ManagerService.Data.SubSection
title = Title,
description = Description,
geometry = Geometry?.ToDto(),
isGeoTriggered = IsGeoTriggered,
zoneRadiusMeters = ZoneRadiusMeters,
imageUrl = ImageUrl,
triggerGeoPointId = TriggerGeoPointId,
triggerGeoPoint = TriggerGeoPoint?.ToDTO(),
audioIds = AudioIds,
contents = Contents,
factContent = FactContent,
isHiddenInitially = IsHiddenInitially,
isStepTimer = IsStepTimer,
isStepLocked = IsStepLocked,
@ -87,19 +99,58 @@ namespace ManagerService.Data.SubSection
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;
TriggerGeoPointId = dto.triggerGeoPointId;
AudioIds = dto.audioIds ?? new List<TranslationDTO>();
Contents = dto.contents ?? new List<ContentDTO>();
FactContent = dto.factContent ?? new List<TranslationDTO>();
IsHiddenInitially = dto.isHiddenInitially;
IsStepTimer = dto.isStepTimer;
IsStepLocked = dto.isStepLocked;
TimerSeconds = dto.timerSeconds;
TimerExpiredMessage = dto.timerExpiredMessage ?? new List<TranslationDTO>();
Order = dto.order.GetValueOrDefault();
QuizQuestions = dto.quizQuestions;
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);
}
}
}
}
}

View File

@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace ManagerService.Data.SubSection
{
@ -33,13 +34,16 @@ namespace ManagerService.Data.SubSection
public string? SectionQuizId { get; set; }
[ForeignKey("SectionQuizId")]
[JsonIgnore]
public SectionQuiz? SectionQuiz { get; set; }
public string? GuidedStepId { get; set; }
[ForeignKey("GuidedStepId")]
[JsonIgnore]
public GuidedStep? GuidedStep { get; set; }
[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonNumberEnumConverter<QuestionType>))]
public QuestionType ValidationQuestionType { get; set; } = QuestionType.Simple;
public string? PuzzleImageId { get; set; }
@ -49,7 +53,7 @@ namespace ManagerService.Data.SubSection
public int? PuzzleCols { get; set; } = 3;
public bool IsSlidingPuzzle { get; set; } = false;
public bool? IsSlidingPuzzle { get; set; } = false;
// TODO
/*public TranslationDTO ToDTO()

View File

@ -15,8 +15,8 @@ namespace ManagerService.Data.SubSection
/// </summary>
public class SectionEvent : Section
{
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public string? BaseSectionMapId { get; set; }
[ForeignKey(nameof(BaseSectionMapId))]
public SectionMap? BaseMap { get; set; }

View File

@ -68,8 +68,7 @@ namespace ManagerService.Data.SubSection
public enum GameTypes
{
Puzzle,
SlidingPuzzle,
Escape
SlidingPuzzle
}
}
}

View File

@ -0,0 +1,48 @@
using Manager.DTOs;
using ManagerService.DTOs;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
namespace ManagerService.Data.SubSection
{
public class SectionParcours : Section
{
public bool ShowMap { get; set; } = true;
public string? BaseSectionMapId { get; set; }
[ForeignKey(nameof(BaseSectionMapId))]
public SectionMap? BaseMap { get; set; }
public List<GuidedPath> GuidedPaths { get; set; } = new();
public ParcoursDTO ToDTO()
{
return new ParcoursDTO
{
id = Id,
label = Label,
title = Title.ToList(),
description = Description.ToList(),
order = Order,
type = Type,
imageId = ImageId,
imageSource = ImageSource,
configurationId = ConfigurationId,
isSubSection = IsSubSection,
parentId = ParentId,
dateCreation = DateCreation,
instanceId = InstanceId,
isBeacon = IsBeacon,
beaconId = BeaconId,
latitude = Latitude,
longitude = Longitude,
meterZoneGPS = MeterZoneGPS,
showMap = ShowMap,
baseSectionMapId = BaseSectionMapId,
guidedPaths = GuidedPaths?.Select(gp => gp.ToDTO()).ToList()
};
}
}
}

View File

@ -41,10 +41,11 @@ namespace ManagerService.Helpers
};
}
public static Geometry FromDto(this GeometryDTO dto, GeometryFactory factory = null!)
public static Geometry? FromDto(this GeometryDTO? dto, GeometryFactory factory = null!)
{
factory ??= NtsGeometryServices.Instance.CreateGeometryFactory(srid: 4326);
if (dto == null) return null;
factory ??= NtsGeometryServices.Instance.CreateGeometryFactory(srid: 4326);
return dto.type switch
{

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class MakeIsSlidingPuzzleNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<bool>(
name: "IsSlidingPuzzle",
table: "QuizQuestions",
type: "boolean",
nullable: true,
oldClrType: typeof(bool),
oldType: "boolean");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<bool>(
name: "IsSlidingPuzzle",
table: "QuizQuestions",
type: "boolean",
nullable: false,
defaultValue: false,
oldClrType: typeof(bool),
oldType: "boolean",
oldNullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,184 @@
using System.Collections.Generic;
using ManagerService.DTOs;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddSectionParcours : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_GuidedPaths_Sections_SectionGameId",
table: "GuidedPaths");
migrationBuilder.RenameColumn(
name: "SectionGameId",
table: "GuidedPaths",
newName: "SectionParcoursId");
migrationBuilder.RenameIndex(
name: "IX_GuidedPaths_SectionGameId",
table: "GuidedPaths",
newName: "IX_GuidedPaths_SectionParcoursId");
migrationBuilder.AddColumn<bool>(
name: "IsGameMode",
table: "Sections",
type: "boolean",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SectionParcours_BaseSectionMapId",
table: "Sections",
type: "text",
nullable: true);
migrationBuilder.AddColumn<List<TranslationAndResourceDTO>>(
name: "SectionParcours_GameMessageDebut",
table: "Sections",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<List<TranslationAndResourceDTO>>(
name: "SectionParcours_GameMessageFin",
table: "Sections",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "ShowMap",
table: "Sections",
type: "boolean",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AudioIds",
table: "GuidedSteps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Contents",
table: "GuidedSteps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FactContent",
table: "GuidedSteps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "EstimatedDurationMinutes",
table: "GuidedPaths",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ImageResourceId",
table: "GuidedPaths",
type: "text",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Sections_SectionParcours_BaseSectionMapId",
table: "Sections",
column: "SectionParcours_BaseSectionMapId");
migrationBuilder.AddForeignKey(
name: "FK_GuidedPaths_Sections_SectionParcoursId",
table: "GuidedPaths",
column: "SectionParcoursId",
principalTable: "Sections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Sections_Sections_SectionParcours_BaseSectionMapId",
table: "Sections",
column: "SectionParcours_BaseSectionMapId",
principalTable: "Sections",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_GuidedPaths_Sections_SectionParcoursId",
table: "GuidedPaths");
migrationBuilder.DropForeignKey(
name: "FK_Sections_Sections_SectionParcours_BaseSectionMapId",
table: "Sections");
migrationBuilder.DropIndex(
name: "IX_Sections_SectionParcours_BaseSectionMapId",
table: "Sections");
migrationBuilder.DropColumn(
name: "IsGameMode",
table: "Sections");
migrationBuilder.DropColumn(
name: "SectionParcours_BaseSectionMapId",
table: "Sections");
migrationBuilder.DropColumn(
name: "SectionParcours_GameMessageDebut",
table: "Sections");
migrationBuilder.DropColumn(
name: "SectionParcours_GameMessageFin",
table: "Sections");
migrationBuilder.DropColumn(
name: "ShowMap",
table: "Sections");
migrationBuilder.DropColumn(
name: "AudioIds",
table: "GuidedSteps");
migrationBuilder.DropColumn(
name: "Contents",
table: "GuidedSteps");
migrationBuilder.DropColumn(
name: "FactContent",
table: "GuidedSteps");
migrationBuilder.DropColumn(
name: "EstimatedDurationMinutes",
table: "GuidedPaths");
migrationBuilder.DropColumn(
name: "ImageResourceId",
table: "GuidedPaths");
migrationBuilder.RenameColumn(
name: "SectionParcoursId",
table: "GuidedPaths",
newName: "SectionGameId");
migrationBuilder.RenameIndex(
name: "IX_GuidedPaths_SectionParcoursId",
table: "GuidedPaths",
newName: "IX_GuidedPaths_SectionGameId");
migrationBuilder.AddForeignKey(
name: "FK_GuidedPaths_Sections_SectionGameId",
table: "GuidedPaths",
column: "SectionGameId",
principalTable: "Sections",
principalColumn: "Id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,101 @@
using System.Collections.Generic;
using ManagerService.DTOs;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class MoveGameModeToGuidedPath : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsGameMode",
table: "Sections");
migrationBuilder.DropColumn(
name: "SectionParcours_GameMessageDebut",
table: "Sections");
migrationBuilder.DropColumn(
name: "SectionParcours_GameMessageFin",
table: "Sections");
migrationBuilder.AddColumn<string>(
name: "GameMessageDebut",
table: "GuidedPaths",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GameMessageFin",
table: "GuidedPaths",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsGameMode",
table: "GuidedPaths",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "IX_GuidedPaths_ImageResourceId",
table: "GuidedPaths",
column: "ImageResourceId");
migrationBuilder.AddForeignKey(
name: "FK_GuidedPaths_Resources_ImageResourceId",
table: "GuidedPaths",
column: "ImageResourceId",
principalTable: "Resources",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_GuidedPaths_Resources_ImageResourceId",
table: "GuidedPaths");
migrationBuilder.DropIndex(
name: "IX_GuidedPaths_ImageResourceId",
table: "GuidedPaths");
migrationBuilder.DropColumn(
name: "GameMessageDebut",
table: "GuidedPaths");
migrationBuilder.DropColumn(
name: "GameMessageFin",
table: "GuidedPaths");
migrationBuilder.DropColumn(
name: "IsGameMode",
table: "GuidedPaths");
migrationBuilder.AddColumn<bool>(
name: "IsGameMode",
table: "Sections",
type: "boolean",
nullable: true);
migrationBuilder.AddColumn<List<TranslationAndResourceDTO>>(
name: "SectionParcours_GameMessageDebut",
table: "Sections",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<List<TranslationAndResourceDTO>>(
name: "SectionParcours_GameMessageFin",
table: "Sections",
type: "jsonb",
nullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class RemoveGuidedStepTriggerGeoPoint : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_GuidedSteps_GeoPoints_TriggerGeoPointId",
table: "GuidedSteps");
migrationBuilder.DropIndex(
name: "IX_GuidedSteps_TriggerGeoPointId",
table: "GuidedSteps");
migrationBuilder.DropColumn(
name: "TriggerGeoPointId",
table: "GuidedSteps");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "TriggerGeoPointId",
table: "GuidedSteps",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_GuidedSteps_TriggerGeoPointId",
table: "GuidedSteps",
column: "TriggerGeoPointId");
migrationBuilder.AddForeignKey(
name: "FK_GuidedSteps_GeoPoints_TriggerGeoPointId",
table: "GuidedSteps",
column: "TriggerGeoPointId",
principalTable: "GeoPoints",
principalColumn: "Id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddGuidedStepIsGeoTriggered : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsGeoTriggered",
table: "GuidedSteps",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsGeoTriggered",
table: "GuidedSteps");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class RemoveGuidedPathSectionMapRelation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_GuidedPaths_Sections_SectionMapId",
table: "GuidedPaths");
migrationBuilder.DropIndex(
name: "IX_GuidedPaths_SectionMapId",
table: "GuidedPaths");
migrationBuilder.DropColumn(
name: "SectionMapId",
table: "GuidedPaths");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SectionMapId",
table: "GuidedPaths",
type: "text",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_GuidedPaths_SectionMapId",
table: "GuidedPaths",
column: "SectionMapId");
migrationBuilder.AddForeignKey(
name: "FK_GuidedPaths_Sections_SectionMapId",
table: "GuidedPaths",
column: "SectionMapId",
principalTable: "Sections",
principalColumn: "Id");
}
}
}

View File

@ -711,13 +711,28 @@ namespace ManagerService.Migrations
b.Property<string>("Description")
.HasColumnType("jsonb");
b.Property<int?>("EstimatedDurationMinutes")
.HasColumnType("integer");
b.Property<string>("GameMessageDebut")
.HasColumnType("jsonb");
b.Property<string>("GameMessageFin")
.HasColumnType("jsonb");
b.Property<bool>("HideNextStepsUntilComplete")
.HasColumnType("boolean");
b.Property<string>("ImageResourceId")
.HasColumnType("text");
b.Property<string>("InstanceId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsGameMode")
.HasColumnType("boolean");
b.Property<bool>("IsLinear")
.HasColumnType("boolean");
@ -730,10 +745,7 @@ namespace ManagerService.Migrations
b.Property<string>("SectionEventId")
.HasColumnType("text");
b.Property<string>("SectionGameId")
.HasColumnType("text");
b.Property<string>("SectionMapId")
b.Property<string>("SectionParcoursId")
.HasColumnType("text");
b.Property<string>("Title")
@ -742,11 +754,11 @@ namespace ManagerService.Migrations
b.HasKey("Id");
b.HasIndex("ImageResourceId");
b.HasIndex("SectionEventId");
b.HasIndex("SectionGameId");
b.HasIndex("SectionMapId");
b.HasIndex("SectionParcoursId");
b.ToTable("GuidedPaths");
});
@ -756,9 +768,18 @@ namespace ManagerService.Migrations
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AudioIds")
.HasColumnType("jsonb");
b.Property<string>("Contents")
.HasColumnType("jsonb");
b.Property<string>("Description")
.HasColumnType("jsonb");
b.Property<string>("FactContent")
.HasColumnType("jsonb");
b.Property<Geometry>("Geometry")
.HasColumnType("geometry");
@ -769,6 +790,9 @@ namespace ManagerService.Migrations
b.Property<string>("ImageUrl")
.HasColumnType("text");
b.Property<bool>("IsGeoTriggered")
.HasColumnType("boolean");
b.Property<bool>("IsHiddenInitially")
.HasColumnType("boolean");
@ -791,9 +815,6 @@ namespace ManagerService.Migrations
.IsRequired()
.HasColumnType("jsonb");
b.Property<int?>("TriggerGeoPointId")
.HasColumnType("integer");
b.Property<double?>("ZoneRadiusMeters")
.HasColumnType("double precision");
@ -801,8 +822,6 @@ namespace ManagerService.Migrations
b.HasIndex("GuidedPathId");
b.HasIndex("TriggerGeoPointId");
b.ToTable("GuidedSteps");
});
@ -817,7 +836,7 @@ namespace ManagerService.Migrations
b.Property<string>("GuidedStepId")
.HasColumnType("text");
b.Property<bool>("IsSlidingPuzzle")
b.Property<bool?>("IsSlidingPuzzle")
.HasColumnType("boolean");
b.Property<List<TranslationAndResourceDTO>>("Label")
@ -1127,13 +1146,13 @@ namespace ManagerService.Migrations
b.Property<string>("BaseSectionMapId")
.HasColumnType("text");
b.Property<DateTime>("EndDate")
b.Property<DateTime?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<List<string>>("ParcoursIds")
.HasColumnType("jsonb");
b.Property<DateTime>("StartDate")
b.Property<DateTime?>("StartDate")
.HasColumnType("timestamp with time zone");
b.HasIndex("BaseSectionMapId");
@ -1214,6 +1233,27 @@ namespace ManagerService.Migrations
b.HasDiscriminator().HasValue("Menu");
});
modelBuilder.Entity("ManagerService.Data.SubSection.SectionParcours", b =>
{
b.HasBaseType("ManagerService.Data.Section");
b.Property<string>("BaseSectionMapId")
.HasColumnType("text");
b.Property<bool>("ShowMap")
.HasColumnType("boolean");
b.HasIndex("BaseSectionMapId");
b.ToTable("Sections", t =>
{
t.Property("BaseSectionMapId")
.HasColumnName("SectionParcours_BaseSectionMapId");
});
b.HasDiscriminator().HasValue("Parcours");
});
modelBuilder.Entity("ManagerService.Data.SubSection.SectionPdf", b =>
{
b.HasBaseType("ManagerService.Data.Section");
@ -1413,23 +1453,24 @@ namespace ManagerService.Migrations
modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b =>
{
b.HasOne("ManagerService.Data.Resource", "ImageResource")
.WithMany()
.HasForeignKey("ImageResourceId");
b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent")
.WithMany()
.HasForeignKey("SectionEventId");
b.HasOne("ManagerService.Data.SubSection.SectionGame", "SectionGame")
.WithMany()
.HasForeignKey("SectionGameId");
b.HasOne("ManagerService.Data.SubSection.SectionParcours", "SectionParcours")
.WithMany("GuidedPaths")
.HasForeignKey("SectionParcoursId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ManagerService.Data.SubSection.SectionMap", "SectionMap")
.WithMany()
.HasForeignKey("SectionMapId");
b.Navigation("ImageResource");
b.Navigation("SectionEvent");
b.Navigation("SectionGame");
b.Navigation("SectionMap");
b.Navigation("SectionParcours");
});
modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b =>
@ -1440,13 +1481,7 @@ namespace ManagerService.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ManagerService.Data.SubSection.GeoPoint", "TriggerGeoPoint")
.WithMany()
.HasForeignKey("TriggerGeoPointId");
b.Navigation("GuidedPath");
b.Navigation("TriggerGeoPoint");
});
modelBuilder.Entity("ManagerService.Data.SubSection.QuizQuestion", b =>
@ -1529,6 +1564,16 @@ namespace ManagerService.Migrations
b.Navigation("MapResource");
});
modelBuilder.Entity("ManagerService.Data.SubSection.SectionParcours", b =>
{
b.HasOne("ManagerService.Data.SubSection.SectionMap", "BaseMap")
.WithMany()
.HasForeignKey("BaseSectionMapId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("BaseMap");
});
modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b =>
{
b.Navigation("Configurations");
@ -1571,6 +1616,11 @@ namespace ManagerService.Migrations
b.Navigation("MenuSections");
});
modelBuilder.Entity("ManagerService.Data.SubSection.SectionParcours", b =>
{
b.Navigation("GuidedPaths");
});
modelBuilder.Entity("ManagerService.Data.SubSection.SectionQuiz", b =>
{
b.Navigation("QuizQuestions");

View File

@ -63,6 +63,70 @@ namespace ManagerService.Services
private static string StripHtml(string? html) =>
string.IsNullOrEmpty(html) ? "" : System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", "").Trim();
private record DateWords(string Today, string Tomorrow, string This, string From, string To, string The, string NoDate);
private static readonly System.Collections.Generic.Dictionary<string, (string culture, DateWords words)> LangConfig = new(StringComparer.OrdinalIgnoreCase)
{
["FR"] = ("fr-FR", new("aujourd'hui", "demain", "ce", "du", "au", "le", "date non précisée")),
["NL"] = ("nl-BE", new("vandaag", "morgen", "deze", "van", "tot", "op", "datum niet bepaald")),
["EN"] = ("en-GB", new("today", "tomorrow", "this", "from", "to", "on", "date unknown")),
["DE"] = ("de-DE", new("heute", "morgen", "diesen", "vom", "bis", "am", "Datum unbekannt")),
["IT"] = ("it-IT", new("oggi", "domani", "questo", "dal", "al", "il", "data non precisata")),
["ES"] = ("es-ES", new("hoy", "mañana", "este", "del", "al", "el", "fecha no especificada")),
["PL"] = ("pl-PL", new("dzisiaj", "jutro", "w ten", "od", "do", "w", "data nieznana")),
};
private static (System.Globalization.CultureInfo culture, DateWords words) GetLangConfig(string lang)
{
if (LangConfig.TryGetValue(lang ?? "FR", out var cfg))
return (new System.Globalization.CultureInfo(cfg.culture), cfg.words);
return (new System.Globalization.CultureInfo("fr-FR"), LangConfig["FR"].words);
}
private static (string text, bool expectsReply) StripMarkdownForVoice(string text)
{
var expectsReply = true;
if (text.Contains("[FIN]"))
{
expectsReply = false;
text = text.Replace("[FIN]", "").TrimEnd();
}
text = Regex.Replace(text, @"(?m)^\s*[-*•]\s+", "");
text = Regex.Replace(text, @"(?m)^\s*\d+\.\s+", "");
text = Regex.Replace(text, @"\*{1,2}([^*]+)\*{1,2}", "$1");
text = Regex.Replace(text, @"_{1,2}([^_]+)_{1,2}", "$1");
text = Regex.Replace(text, @"(?m)^#{1,6}\s+", "");
text = text.Replace("`", "");
text = Regex.Replace(text, @"\n{3,}", "\n\n").Trim();
return (text, expectsReply);
}
private static string FormatDateVoice(DateTime date, DateTime today, string lang)
{
var (culture, w) = GetLangConfig(lang);
var diff = (date.Date - today.Date).Days;
var dayName = culture.DateTimeFormat.GetDayName(date.DayOfWeek).ToLower();
var monthName = culture.DateTimeFormat.GetMonthName(date.Month).ToLower();
var yearSuffix = date.Year != today.Year ? $" {date.Year}" : "";
var absolute = $"{dayName} {date.Day} {monthName}{yearSuffix}";
return diff switch
{
0 => $"{w.Today} {date.Day} {monthName}{yearSuffix}",
1 => $"{w.Tomorrow} {dayName} {date.Day} {monthName}{yearSuffix}",
>= 2 and <= 7 => $"{w.This} {absolute}",
_ => absolute,
};
}
private static string FormatDateRangeVoice(DateTime? from, DateTime? to, DateTime today, string lang)
{
var (_, w) = GetLangConfig(lang);
if (from == null) return w.NoDate;
if (to == null) return $"{w.The} {FormatDateVoice(from.Value, today, lang)}";
return $"{w.From} {FormatDateVoice(from.Value, today, lang)} {w.To} {FormatDateVoice(to.Value, today, lang)}";
}
private static DateTime? ParseFilterDate(string? s)
{
if (string.IsNullOrEmpty(s)) return null;
@ -107,7 +171,33 @@ namespace ManagerService.Services
return $"- id:{s.Id} | type:{s.Type} | titre:\"{title}\"";
}));
messages.Add(new ChatMessage(ChatRole.System, $"""
var isVoiceMode = request.IsVoice;
var systemPrompt = isVoiceMode ? $"""
Tu es le guide audio de visite de "{config?.Label ?? "cette application"}".
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}.
Le visiteur t'écoute via des lunettes connectées Ray-Ban il ne voit PAS d'écran.
Tu réponds en {request.Language}. Tu es chaleureux, naturel et très concis (2-3 phrases max).
Voici les sections disponibles :
{sectionsSummary}
RÈGLES STRICTES pour le mode vocal chaque violation rend la réponse inutilisable :
1. ZÉRO markdown : absolument aucun astérisque (*), tiret de liste (- item ou item), dièse (#), backtick (`), ou crochet ([lien]). Écris uniquement des phrases.
2. Les dates dans les résultats des outils sont DÉJÀ formatées dans la langue de la réponse recopie-les telles quelles, ne les reformate JAMAIS.
3. ZÉRO liste. Cite 2-3 éléments maximum directement dans une phrase fluide, séparés par des virgules.
4. Pour les événements appelle "GetUpcomingEvents", puis cite exactement 2-3 des plus pertinents dans une seule phrase fluide. Même si l'outil en retourne 50, n'en cite que 2-3. Ajoute [FIN] à la fin.
5. Pour les détails d'un item appelle "GetItemDetails" et résume en 2-3 phrases fluides. Ajoute [FIN] à la fin.
6. Pour les sections de type Article appelle "GetSectionDetail", résume le contenu en 2-3 phrases, puis demande "Veux-tu en savoir plus ?" (sans ajouter [FIN] car tu attends une réponse).
7. Les sections de type Video, PDF, Slider, Quiz, Web, Game sont des contenus visuels. Si le visiteur en parle, mentionne leur existence mais précise qu'il doit les consulter sur l'écran.
8. NE propose JAMAIS de navigation le visiteur ne peut pas toucher l'écran.
9. Ne mentionne JAMAIS les identifiants techniques (id, guid).
10. Chaque réponse doit tenir en moins de 20 secondes à l'oral.
11. Termine TOUJOURS par une phrase complète.
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), décline poliment en une phrase courte et naturelle varie la formulation à chaque fois (ex: "Ma spécialité c'est cette visite, pas ce sujet !", "Je suis ton guide ici, pas un moteur de recherche !", "Bonne question, mais là je sèche — demande-moi plutôt ce qu'il y a à voir ici !") et ajoute [FIN].
13. Politesses et fin de conversation ("merci", "au revoir", "c'est tout", "ok merci") réponds chaleureusement en une phrase courte ("Bonne visite !" ou "Avec plaisir, bonne visite !") et 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).
""" : $"""
Tu es l'assistant de visite de l'application "{config?.Label ?? "cette application"}".
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}.
Le weekend en cours (ou prochain) : samedi {weekendSat:dd/MM/yyyy} dimanche {weekendSun:dd/MM/yyyy}.
@ -126,7 +216,9 @@ namespace ManagerService.Services
- 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.
"""));
""";
messages.Add(new ChatMessage(ChatRole.System, systemPrompt));
foreach (var h in request.History.TakeLast(MaxHistoryMessages))
messages.Add(new ChatMessage(h.Role == "user" ? ChatRole.User : ChatRole.Assistant, h.Content));
@ -141,14 +233,37 @@ namespace ManagerService.Services
AIFunctionFactory.Create(
(string sectionId) =>
{
var section = sections.FirstOrDefault(s => s.Id == sectionId);
var section = _context.Sections
.FirstOrDefault(s => s.Id == sectionId && s.ConfigurationId == request.ConfigurationId);
if (section == null) return "Section non trouvée.";
var title = section.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? section.Label;
var desc = section.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? "";
return $"Titre: {title}\nDescription: {desc}\nType: {section.Type}";
var lang = request.Language;
var title = StripHtml(section.Title?.FirstOrDefault(t => t.language == lang)?.value
?? section.Title?.FirstOrDefault()?.value
?? section.Label);
var desc = StripHtml(section.Description?.FirstOrDefault(t => t.language == lang)?.value
?? section.Description?.FirstOrDefault()?.value
?? "");
// Requêtes explicites par type pour contourner les problèmes de cache EF Core
var extra = "";
var asArticle = _context.Sections
.OfType<ManagerService.Data.SubSection.SectionArticle>()
.FirstOrDefault(s => s.Id == sectionId);
if (asArticle != null)
{
extra = StripHtml(asArticle.ArticleContent?.FirstOrDefault(t => t.language == lang)?.value
?? asArticle.ArticleContent?.FirstOrDefault()?.value ?? "");
}
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Titre: {title}");
if (!string.IsNullOrWhiteSpace(desc)) sb.AppendLine($"Description: {desc}");
if (!string.IsNullOrWhiteSpace(extra)) sb.AppendLine($"Contenu: {extra}");
sb.AppendLine($"Type: {section.Type}");
return sb.ToString().Trim();
},
"GetSectionDetail",
"Récupère le titre et la description d'une section."
"Récupère le titre, la description et le contenu complet d'une section (article, vidéo, etc.)."
),
AIFunctionFactory.Create(
async (string? dateFrom, string? dateTo) =>
@ -193,10 +308,14 @@ namespace ManagerService.Services
if (end == null || end.Value.Date < today || dtFrom?.Date > horizon) continue;
if (filterFrom.HasValue && end.Value.Date < filterFrom.Value) continue;
if (filterTo.HasValue && dtFrom?.Date > filterTo.Value) continue;
var dateStr = dtFrom.HasValue ? dtFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée";
if (dtTo.HasValue && dtTo.Value.Date != dtFrom?.Date)
dateStr += $" → {dtTo.Value.ToString("dd/MM/yyyy")}";
allEvents.Add((dtFrom ?? DateTime.MaxValue, $"- [En ligne] AgendaId:{agenda.Id} | Titre:{e.name ?? "Sans titre"} | Date:{dateStr}"));
var dtToOpt = dtTo.HasValue && dtTo.Value.Date != dtFrom?.Date ? dtTo : null;
var dateStr = isVoiceMode
? FormatDateRangeVoice(dtFrom, dtToOpt, today, request.Language)
: (dtFrom.HasValue ? dtFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée")
+ (dtToOpt.HasValue ? $" → {dtToOpt.Value:dd/MM/yyyy}" : "");
allEvents.Add((dtFrom ?? DateTime.MaxValue, isVoiceMode
? $"{e.name ?? "Sans titre"} ({dateStr})"
: $"- [En ligne] AgendaId:{agenda.Id} | Titre:{e.name ?? "Sans titre"} | Date:{dateStr}"));
}
}
}
@ -213,10 +332,14 @@ namespace ManagerService.Services
foreach (var e in events)
{
var title = e.Label?.FirstOrDefault(t => t.language == request.Language)?.value ?? e.Label?.FirstOrDefault()?.value ?? "Sans titre";
var dateStr = e.DateFrom.HasValue ? e.DateFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée";
if (e.DateTo.HasValue && e.DateTo.Value.Date != e.DateFrom?.Date)
dateStr += $" → {e.DateTo.Value.ToString("dd/MM/yyyy")}";
allEvents.Add((e.DateFrom ?? DateTime.MaxValue, $"- SectionId:{agenda.Id} | EventId:{e.Id} | Titre:{title} | Date:{dateStr}"));
var dtToOpt2 = e.DateTo.HasValue && e.DateTo.Value.Date != e.DateFrom?.Date ? e.DateTo : null;
var dateStr = isVoiceMode
? FormatDateRangeVoice(e.DateFrom, dtToOpt2, today, request.Language)
: (e.DateFrom.HasValue ? e.DateFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée")
+ (dtToOpt2.HasValue ? $" → {dtToOpt2.Value:dd/MM/yyyy}" : "");
allEvents.Add((e.DateFrom ?? DateTime.MaxValue, isVoiceMode
? $"{title} ({dateStr})"
: $"- SectionId:{agenda.Id} | EventId:{e.Id} | Titre:{title} | Date:{dateStr}"));
}
}
}
@ -324,7 +447,12 @@ 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(
};
// Navigation et cards : uniquement en mode UI (pas Voice)
if (!isVoiceMode)
{
tools.Add(AIFunctionFactory.Create(
(string sectionId, string sectionTitle, string sectionType) =>
{
var imageUrl = sections.FirstOrDefault(s => s.Id == sectionId)?.ImageSource;
@ -333,8 +461,8 @@ namespace ManagerService.Services
},
"navigate_to_section",
"Propose navigation to a specific section when the user wants to go there or see it."
),
AIFunctionFactory.Create(
));
tools.Add(AIFunctionFactory.Create(
(string[] titles, string[] subtitles, string[]? icons) =>
{
cards = titles.Select((t, i) => new AiCardDTO
@ -347,14 +475,25 @@ namespace ManagerService.Services
},
"show_cards",
"Display structured info cards in the chat. Use for lists of items (events, activities...) that benefit from visual presentation."
)
};
));
}
try
{
options = new ChatOptions { Tools = tools };
var response = await _chatClient.GetResponseAsync(messages, options);
return new AiChatResponse { Reply = response.Text ?? "", Cards = cards, Navigation = navigation };
string reply;
bool expectsReply;
if (isVoiceMode)
{
(reply, expectsReply) = StripMarkdownForVoice(response.Text ?? "");
}
else
{
reply = response.Text ?? "";
expectsReply = true;
}
return new AiChatResponse { Reply = reply, Cards = cards, Navigation = navigation, ExpectsReply = expectsReply };
}
catch (System.ClientModel.ClientResultException ex)
{
@ -391,7 +530,27 @@ namespace ManagerService.Services
return $"- id:{c.Id} | titre:\"{title}\"{typesStr}";
}));
messages.Add(new ChatMessage(ChatRole.System, $"""
var isVoiceMode = request.IsVoice;
var instanceSystemPrompt = isVoiceMode ? $"""
Tu es le guide audio de visite.
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}.
Le visiteur t'écoute via des lunettes connectées il ne voit PAS d'écran.
Tu réponds en {request.Language}. Tu es chaleureux, naturel et très concis (2-3 phrases max).
Voici les expériences (visites) disponibles :
{configsSummary}
RÈGLES STRICTES pour le mode vocal :
1. ZÉRO markdown : aucun astérisque, tiret de liste, dièse, backtick. Écris uniquement des phrases.
2. ZÉRO liste. Cite 2-3 éléments maximum dans une phrase fluide séparés par des virgules.
3. Pour les événements appelle "SearchEventsGlobal", puis cite exactement 2-3 des plus pertinents dans une phrase fluide. Ajoute [FIN].
4. NE propose JAMAIS de navigation. Ne mentionne JAMAIS les identifiants techniques.
5. Chaque réponse doit tenir en moins de 20 secondes à l'oral.
6. Tu réponds UNIQUEMENT aux questions liées à cette visite. Pour toute question hors-sujet, réponds : "Je suis uniquement là pour t'accompagner dans ta visite." et ajoute [FIN].
7. Politesses ("merci", "au revoir") réponse courte et chaleureuse + [FIN].
8. Signal [FIN] : ajoute-le quand ta réponse est une information pure sans question au visiteur.
""" : $"""
Tu es l'assistant de visite principal. Ton rôle est d'orienter l'utilisateur vers la bonne expérience de visite.
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}.
Le weekend en cours (ou prochain) : samedi {weekendSat:dd/MM/yyyy} dimanche {weekendSun:dd/MM/yyyy}.
@ -409,7 +568,9 @@ namespace ManagerService.Services
6. Pour les détails d'un item "GetItemDetailsGlobal".
- 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.
"""));
""";
messages.Add(new ChatMessage(ChatRole.System, instanceSystemPrompt));
foreach (var h in request.History.TakeLast(MaxHistoryMessages))
messages.Add(new ChatMessage(h.Role == "user" ? ChatRole.User : ChatRole.Assistant, h.Content));
@ -617,7 +778,18 @@ namespace ManagerService.Services
{
options = new ChatOptions { Tools = tools };
var response = await _chatClient.GetResponseAsync(messages, options);
return new AiChatResponse { Reply = response.Text ?? "", Navigation = navigation };
string reply;
bool expectsReply;
if (isVoiceMode)
{
(reply, expectsReply) = StripMarkdownForVoice(response.Text ?? "");
}
else
{
reply = response.Text ?? "";
expectsReply = true;
}
return new AiChatResponse { Reply = reply, Navigation = navigation, ExpectsReply = expectsReply };
}
catch (System.ClientModel.ClientResultException ex)
{

View File

@ -25,6 +25,7 @@ namespace ManagerService.Services
VideoDTO videoDTO = new VideoDTO();
WeatherDTO weatherDTO = new WeatherDTO();
WebDTO webDTO = new WebDTO();
ParcoursDTO parcoursDTO = new ParcoursDTO();
switch (dto.type)
{
@ -35,7 +36,10 @@ namespace ManagerService.Services
articleDTO = JsonConvert.DeserializeObject<ArticleDTO>(jsonElement.ToString());
break;
case SectionType.Event:
sectionEventDTO = JsonConvert.DeserializeObject<SectionEventDTO>(jsonElement.ToString());
sectionEventDTO = JsonConvert.DeserializeObject<SectionEventDTO>(
jsonElement.ToString(),
new JsonSerializerSettings { Error = (_, args) => args.ErrorContext.Handled = true }
);
break;
case SectionType.Map:
mapDTO = JsonConvert.DeserializeObject<MapDTO>(jsonElement.ToString());
@ -64,6 +68,9 @@ namespace ManagerService.Services
case SectionType.Web:
webDTO = JsonConvert.DeserializeObject<WebDTO>(jsonElement.ToString());
break;
case SectionType.Parcours:
parcoursDTO = JsonConvert.DeserializeObject<ParcoursDTO>(jsonElement.ToString());
break;
}
return dto.type switch
@ -139,8 +146,8 @@ namespace ManagerService.Services
Longitude = dto.longitude,
MeterZoneGPS = dto.meterZoneGPS,
Type = dto.type,
StartDate = sectionEventDTO.StartDate,
EndDate = sectionEventDTO.EndDate,
StartDate = sectionEventDTO.StartDate?.ToUniversalTime(),
EndDate = sectionEventDTO.EndDate?.ToUniversalTime(),
ParcoursIds = sectionEventDTO.ParcoursIds,
BaseSectionMapId = sectionEventDTO.BaseSectionMapId,
//Programmes = // TODO specific
@ -363,6 +370,29 @@ namespace ManagerService.Services
Type = dto.type,
WebSource = webDTO.source,
},
SectionType.Parcours => new SectionParcours
{
Id = dto.id,
DateCreation = dto.dateCreation.Value,
ConfigurationId = dto.configurationId,
InstanceId = dto.instanceId,
Label = dto.label,
Title = dto.title,
Description = dto.description,
Order = dto.order.Value,
ImageId = dto.imageId,
ImageSource = dto.imageSource,
IsSubSection = dto.isSubSection,
ParentId = dto.parentId,
IsBeacon = dto.isBeacon,
BeaconId = dto.beaconId,
Latitude = dto.latitude,
Longitude = dto.longitude,
MeterZoneGPS = dto.meterZoneGPS,
Type = dto.type,
ShowMap = parcoursDTO.showMap,
BaseSectionMapId = parcoursDTO.baseSectionMapId,
},
_ => throw new NotImplementedException("Section type not handled")
};
}
@ -446,8 +476,8 @@ namespace ManagerService.Services
longitude = sectionEvent.Longitude,
meterZoneGPS = sectionEvent.MeterZoneGPS,
type = sectionEvent.Type,
StartDate = sectionEvent.StartDate,
EndDate = sectionEvent.EndDate,
StartDate = sectionEvent.StartDate?.Year > 1000 ? sectionEvent.StartDate : null,
EndDate = sectionEvent.EndDate?.Year > 1000 ? sectionEvent.EndDate : null,
ParcoursIds = sectionEvent.ParcoursIds,
BaseSectionMapId = sectionEvent.BaseSectionMapId,
GlobalMapAnnotations = sectionEvent.GlobalMapAnnotations?.Select(ma => ma.ToDTO()).ToList() ?? new(),
@ -681,6 +711,31 @@ namespace ManagerService.Services
type = web.Type,
source = web.WebSource
},
SectionParcours parcours => new ParcoursDTO
{
id = parcours.Id,
dateCreation = parcours.DateCreation,
configurationId = parcours.ConfigurationId,
instanceId = parcours.InstanceId,
label = parcours.Label,
title = parcours.Title,
description = parcours.Description,
order = parcours.Order,
imageId = parcours.ImageId,
imageSource = parcours.ImageSource,
isActive = parcours.IsActive,
isSubSection = parcours.IsSubSection,
parentId = parcours.ParentId,
isBeacon = parcours.IsBeacon,
beaconId = parcours.BeaconId,
latitude = parcours.Latitude,
longitude = parcours.Longitude,
meterZoneGPS = parcours.MeterZoneGPS,
type = parcours.Type,
showMap = parcours.ShowMap,
baseSectionMapId = parcours.BaseSectionMapId,
// guidedPaths chargés spécifiquement dans GetFromConfigurationDetail
},
_ => throw new NotImplementedException("Section type not handled")
};
}