divan/RMuseum/Controllers/SemanticSearchController.cs
Anas Rashid e8f2306422 Rename Ganjoor -> Diwan throughout the code
Projects (GanjooRazor -> DiwanRazor, GanjoorService.sln -> DiwanService.sln), files,
folders, classes, namespaces, settings, API routes (/api/diwan) and DB tables (Diwan*).
External addresses (ganjoor.net, github.com/ganjoor) unchanged. Migrations renamed
consistently (no pending model changes); requires a fresh database.
Upstream RUNNING_LOCALLY/SEMANTIC_SEARCH docs archived unmodified under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:49:01 +02:00

82 lines
3.5 KiB
C#

using Microsoft.AspNetCore.Mvc;
using RMuseum.Models.Diwan.SemanticSearch;
using RMuseum.Services.Implementation;
using RMuseum.Utils.SemanticSearch;
using System;
using System.Net;
using System.Threading.Tasks;
namespace RMuseum.Controllers
{
/// <summary>
/// Semantic ("find a poem about...") search — deliberately its own controller, not a method
/// on DiwanController, after a production incident: DiwanController'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/diwan, not just this feature. Same
/// route prefix as before (api/diwan), so the endpoint's URL is unchanged
/// (POST /api/diwan/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 DiwanController or anything else.
/// </summary>
[Produces("application/json")]
[Route("api/diwan")]
[ApiController]
public class SemanticSearchController : ControllerBase
{
protected readonly ISemanticSearchService _semanticSearchService;
public SemanticSearchController(ISemanticSearchService semanticSearchService)
{
_semanticSearchService = semanticSearchService;
}
/// <summary>
/// semantic ("find a poem about...") search
/// </summary>
[HttpPost("search/semantic")]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
[ProducesResponseType((int)HttpStatusCode.ServiceUnavailable, Type = typeof(string))]
public async Task<IActionResult> 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());
}
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("search/semantic/click")]
[ProducesResponseType((int)HttpStatusCode.OK)]
public async Task<IActionResult> SemanticSearchClick([FromBody] SemanticSearchClickDto click)
{
await _semanticSearchService.ReportClickAsync(click);
return Ok();
}
}
}