feat(backend): add semantic matchmaking with embedding pipeline

Add a pluggable embedding provider with an offline deterministic default, vector indexing for problems and innovations, cosine semantic ranking, similar cases and solution suggestions, area filtering and an admin reindex endpoint. Refresh the index when innovations or submissions change.
This commit is contained in:
Rafał Tyński
2026-10-04 02:44:03 +02:00
parent 2bc083fb95
commit 25ce901021
13 changed files with 611 additions and 2 deletions
+76 -1
View File
@@ -16,10 +16,14 @@ namespace TarninaHub.Controllers;
public class SubmissionsController : ControllerBase
{
private readonly ISubmissionService _submissionService;
private readonly IMatchmakingService _matchmakingService;
public SubmissionsController(ISubmissionService submissionService)
public SubmissionsController(
ISubmissionService submissionService,
IMatchmakingService matchmakingService)
{
_submissionService = submissionService;
_matchmakingService = matchmakingService;
}
[HttpPost]
@@ -36,6 +40,8 @@ public class SubmissionsController : ControllerBase
var created = await _submissionService.CreateAsync(userId, request, cancellationToken);
await _matchmakingService.IndexSubmissionAsync(created.Id, cancellationToken);
return CreatedAtAction(nameof(GetById), new { submissionId = created.Id }, created);
}
@@ -132,6 +138,75 @@ public class SubmissionsController : ControllerBase
return updated is null ? NotFound() : Ok(updated);
}
[HttpPost("{submissionId}/match")]
public async Task<ActionResult<MatchResponse>> Match(
string submissionId,
[FromBody] MatchRequest? request,
CancellationToken cancellationToken)
{
var access = await AuthorizeSubmissionAccessAsync(submissionId, cancellationToken);
if (access is not null)
{
return access;
}
var result = await _matchmakingService.MatchAsync(
submissionId,
request ?? new MatchRequest(null, null),
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{submissionId}/matches")]
public async Task<ActionResult<MatchResponse>> Matches(
string submissionId,
[FromQuery] string? challengeId,
[FromQuery] int? maxResults,
CancellationToken cancellationToken)
{
var access = await AuthorizeSubmissionAccessAsync(submissionId, cancellationToken);
if (access is not null)
{
return access;
}
var result = await _matchmakingService.MatchAsync(
submissionId,
new MatchRequest(maxResults, challengeId),
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
private async Task<ActionResult?> AuthorizeSubmissionAccessAsync(
string submissionId,
CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var submission = await _submissionService.GetAsync(submissionId, cancellationToken);
if (submission is null)
{
return NotFound();
}
if (!IsStaff() && !string.Equals(submission.AuthorId, userId, StringComparison.Ordinal))
{
return Forbid();
}
return null;
}
private bool IsStaff() =>
User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin);
}