using Microsoft.AspNetCore.Mvc; using RMuseum.Models.Divan.SemanticSearch; using RMuseum.Services.Implementation; using RMuseum.Utils.SemanticSearch; using System; using System.Net; using System.Threading.Tasks; namespace RMuseum.Controllers { /// /// Semantic ("find a poem about...") search — deliberately its own controller, not a method /// on DivanController, after a production incident: DivanController's constructor took /// ISemanticSearchService (indirectly requiring EmbeddingIndex/QueryEmbedder to load /// successfully), so a resource-loading failure prevented the ENTIRE controller from being /// constructed — a 503 on every endpoint under /api/divan, not just this feature. Same /// route prefix as before (api/divan), so the endpoint's URL is unchanged /// (POST /api/divan/search/semantic) — only which controller class hosts it changed. /// A future failure in this feature's own dependencies can now only ever affect this one /// controller/endpoint, never DivanController or anything else. /// [Produces("application/json")] [Route("api/divan")] [ApiController] public class SemanticSearchController : ControllerBase { protected readonly ISemanticSearchService _semanticSearchService; public SemanticSearchController(ISemanticSearchService semanticSearchService) { _semanticSearchService = semanticSearchService; } /// /// semantic ("find a poem about...") search /// [HttpPost("search/semantic")] [ProducesResponseType((int)HttpStatusCode.OK)] [ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))] [ProducesResponseType((int)HttpStatusCode.ServiceUnavailable, Type = typeof(string))] public async Task SemanticSearch([FromBody] SemanticSearchRequestDto request) { try { var result = await _semanticSearchService.SearchAsync(request); return Ok(result); } catch (SemanticSearchUnavailableException exp) { // distinct from a plain 400/500 - lets a client (or a person reading logs) tell // "this feature isn't loaded/configured right now" apart from a bad query or an // actual crash return StatusCode((int)HttpStatusCode.ServiceUnavailable, exp.Message); } catch (ArgumentException exp) { return BadRequest(exp.Message); } catch (Exception exp) { return BadRequest(exp.ToString()); } } /// /// Fire-and-forget click reporting — called via navigator.sendBeacon (or a keepalive /// fetch as fallback) right as a result link is clicked, so it can complete even as the /// browser navigates away. ReportClickAsync itself is fully best-effort (never throws in /// a way that matters here), so this always returns 200 regardless of whether the /// underlying write actually succeeded — the caller isn't listening for the response /// either way. /// [HttpPost("search/semantic/click")] [ProducesResponseType((int)HttpStatusCode.OK)] public async Task SemanticSearchClick([FromBody] SemanticSearchClickDto click) { await _semanticSearchService.ReportClickAsync(click); return Ok(); } } }