From 25ce9010216bb1cfaf3f2f626df7b1aead433a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ty=C5=84ski?= Date: Sun, 4 Oct 2026 02:44:03 +0200 Subject: [PATCH] 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. --- .../Abstractions/IMatchmakingService.cs | 19 ++ backend/Application/Dtos/MatchmakingDtos.cs | 23 ++ .../Matchmaking/EmbeddingConstants.cs | 7 + .../Matchmaking/HashingEmbeddingProvider.cs | 103 +++++++ .../Matchmaking/IEmbeddingProvider.cs | 12 + .../Application/Matchmaking/SemanticRanker.cs | 20 ++ backend/Application/Matchmaking/VectorMath.cs | 30 +++ backend/Controllers/InnovationsController.cs | 11 +- backend/Controllers/MatchmakingController.cs | 32 +++ backend/Controllers/SubmissionsController.cs | 77 +++++- backend/Infrastructure/DependencyInjection.cs | 3 + .../Persistence/MatchmakingService.cs | 252 ++++++++++++++++++ .../Infrastructure/Persistence/SurrealRows.cs | 24 ++ 13 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 backend/Application/Abstractions/IMatchmakingService.cs create mode 100644 backend/Application/Dtos/MatchmakingDtos.cs create mode 100644 backend/Application/Matchmaking/EmbeddingConstants.cs create mode 100644 backend/Application/Matchmaking/HashingEmbeddingProvider.cs create mode 100644 backend/Application/Matchmaking/IEmbeddingProvider.cs create mode 100644 backend/Application/Matchmaking/SemanticRanker.cs create mode 100644 backend/Application/Matchmaking/VectorMath.cs create mode 100644 backend/Controllers/MatchmakingController.cs create mode 100644 backend/Infrastructure/Persistence/MatchmakingService.cs diff --git a/backend/Application/Abstractions/IMatchmakingService.cs b/backend/Application/Abstractions/IMatchmakingService.cs new file mode 100644 index 0000000..8d845ad --- /dev/null +++ b/backend/Application/Abstractions/IMatchmakingService.cs @@ -0,0 +1,19 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IMatchmakingService +{ + Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken); + + Task IndexProblemAsync(string problemId, CancellationToken cancellationToken); + + Task IndexSubmissionAsync(string submissionId, CancellationToken cancellationToken); + + Task ReindexAsync(CancellationToken cancellationToken); + + Task MatchAsync( + string submissionId, + MatchRequest request, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Dtos/MatchmakingDtos.cs b/backend/Application/Dtos/MatchmakingDtos.cs new file mode 100644 index 0000000..0e602c2 --- /dev/null +++ b/backend/Application/Dtos/MatchmakingDtos.cs @@ -0,0 +1,23 @@ +namespace TarninaHub.Application.Dtos; + +public sealed record MatchRequest( + int? MaxResults, + string? ChallengeId); + +public sealed record SimilarCaseItem( + string Id, + string Title, + double Score); + +public sealed record SolutionItem( + string InnovationId, + string Title, + string Stage, + string ChallengeId, + double Score, + string Rationale); + +public sealed record MatchResponse( + IReadOnlyList SimilarCases, + IReadOnlyList Solutions, + bool Fallback); diff --git a/backend/Application/Matchmaking/EmbeddingConstants.cs b/backend/Application/Matchmaking/EmbeddingConstants.cs new file mode 100644 index 0000000..0285e99 --- /dev/null +++ b/backend/Application/Matchmaking/EmbeddingConstants.cs @@ -0,0 +1,7 @@ +namespace TarninaHub.Application.Matchmaking; + +public static class EmbeddingConstants +{ + /// Embedding dimension; must match the HNSW index definition. + public const int Dimension = 1536; +} diff --git a/backend/Application/Matchmaking/HashingEmbeddingProvider.cs b/backend/Application/Matchmaking/HashingEmbeddingProvider.cs new file mode 100644 index 0000000..82e6767 --- /dev/null +++ b/backend/Application/Matchmaking/HashingEmbeddingProvider.cs @@ -0,0 +1,103 @@ +using System.Text; + +namespace TarninaHub.Application.Matchmaking; + +/// +/// Deterministic, offline embedding provider (feature hashing). Produces +/// L2-normalized vectors so cosine similarity reflects shared vocabulary. +/// Interchangeable with a real LLM provider behind +/// (see ADR-0005). Used as the default until an external provider is wired. +/// +public sealed class HashingEmbeddingProvider : IEmbeddingProvider +{ + public int Dimension => EmbeddingConstants.Dimension; + + public Task EmbedAsync(string text, CancellationToken cancellationToken) + { + return Task.FromResult(Embed(text)); + } + + public Task> EmbedBatchAsync( + IReadOnlyList texts, + CancellationToken cancellationToken) + { + IReadOnlyList result = texts.Select(Embed).ToList(); + + return Task.FromResult(result); + } + + public float[] Embed(string text) + { + var vector = new float[Dimension]; + + if (string.IsNullOrWhiteSpace(text)) + { + return vector; + } + + foreach (var token in Tokenize(text)) + { + var index = (int)(Fnv1a(token) % Dimension); + vector[index] += 1f; + } + + Normalize(vector); + + return vector; + } + + private static IEnumerable Tokenize(string text) + { + var buffer = new StringBuilder(); + + foreach (var character in text) + { + if (char.IsLetterOrDigit(character)) + { + buffer.Append(char.ToLowerInvariant(character)); + } + else if (buffer.Length > 0) + { + yield return buffer.ToString(); + buffer.Clear(); + } + } + + if (buffer.Length > 0) + { + yield return buffer.ToString(); + } + } + + private static void Normalize(float[] vector) + { + double sumOfSquares = vector.Sum(value => (double)value * value); + var magnitude = Math.Sqrt(sumOfSquares); + + if (magnitude <= 0) + { + return; + } + + for (var i = 0; i < vector.Length; i++) + { + vector[i] = (float)(vector[i] / magnitude); + } + } + + private static uint Fnv1a(string token) + { + const uint offset = 2166136261; + const uint prime = 16777619; + + var hash = offset; + + foreach (var character in token) + { + hash ^= character; + hash *= prime; + } + + return hash; + } +} diff --git a/backend/Application/Matchmaking/IEmbeddingProvider.cs b/backend/Application/Matchmaking/IEmbeddingProvider.cs new file mode 100644 index 0000000..d55b726 --- /dev/null +++ b/backend/Application/Matchmaking/IEmbeddingProvider.cs @@ -0,0 +1,12 @@ +namespace TarninaHub.Application.Matchmaking; + +public interface IEmbeddingProvider +{ + int Dimension { get; } + + Task EmbedAsync(string text, CancellationToken cancellationToken); + + Task> EmbedBatchAsync( + IReadOnlyList texts, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Matchmaking/SemanticRanker.cs b/backend/Application/Matchmaking/SemanticRanker.cs new file mode 100644 index 0000000..d2278e8 --- /dev/null +++ b/backend/Application/Matchmaking/SemanticRanker.cs @@ -0,0 +1,20 @@ +namespace TarninaHub.Application.Matchmaking; + +public sealed record RankedItem(T Item, double Score); + +public static class SemanticRanker +{ + public static IReadOnlyList> Rank( + IReadOnlyList queryVector, + IEnumerable<(T Item, IReadOnlyList Vector)> candidates, + int topK, + double minScore = 0) + { + return candidates + .Select(candidate => new RankedItem(candidate.Item, VectorMath.CosineSimilarity(queryVector, candidate.Vector))) + .Where(ranked => ranked.Score >= minScore) + .OrderByDescending(ranked => ranked.Score) + .Take(topK) + .ToList(); + } +} diff --git a/backend/Application/Matchmaking/VectorMath.cs b/backend/Application/Matchmaking/VectorMath.cs new file mode 100644 index 0000000..5a6a995 --- /dev/null +++ b/backend/Application/Matchmaking/VectorMath.cs @@ -0,0 +1,30 @@ +namespace TarninaHub.Application.Matchmaking; + +public static class VectorMath +{ + public static double CosineSimilarity(IReadOnlyList a, IReadOnlyList b) + { + if (a.Count == 0 || a.Count != b.Count) + { + return 0; + } + + double dot = 0; + double magnitudeA = 0; + double magnitudeB = 0; + + for (var i = 0; i < a.Count; i++) + { + dot += (double)a[i] * b[i]; + magnitudeA += (double)a[i] * a[i]; + magnitudeB += (double)b[i] * b[i]; + } + + if (magnitudeA <= 0 || magnitudeB <= 0) + { + return 0; + } + + return dot / (Math.Sqrt(magnitudeA) * Math.Sqrt(magnitudeB)); + } +} diff --git a/backend/Controllers/InnovationsController.cs b/backend/Controllers/InnovationsController.cs index 1526d95..41c7b6b 100644 --- a/backend/Controllers/InnovationsController.cs +++ b/backend/Controllers/InnovationsController.cs @@ -15,10 +15,12 @@ namespace TarninaHub.Controllers; public class InnovationsController : ControllerBase { private readonly IInnovationService _innovations; + private readonly IMatchmakingService _matchmakingService; - public InnovationsController(IInnovationService innovations) + public InnovationsController(IInnovationService innovations, IMatchmakingService matchmakingService) { _innovations = innovations; + _matchmakingService = matchmakingService; } [HttpGet] @@ -56,6 +58,8 @@ public class InnovationsController : ControllerBase var created = await _innovations.CreateAsync(request, cancellationToken); + await _matchmakingService.IndexInnovationAsync(created.Id, cancellationToken); + return CreatedAtAction(nameof(GetById), new { innovationId = created.Id }, created); } @@ -73,6 +77,11 @@ public class InnovationsController : ControllerBase var updated = await _innovations.UpdateAsync(innovationId, request, cancellationToken); + if (updated is not null) + { + await _matchmakingService.IndexInnovationAsync(updated.Id, cancellationToken); + } + return updated is null ? NotFound() : Ok(updated); } diff --git a/backend/Controllers/MatchmakingController.cs b/backend/Controllers/MatchmakingController.cs new file mode 100644 index 0000000..358699d --- /dev/null +++ b/backend/Controllers/MatchmakingController.cs @@ -0,0 +1,32 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Security; + +namespace TarninaHub.Controllers; + +[ApiController] +[ApiVersion("1.0")] +[Route("api/matchmaking")] +[Authorize] +public class MatchmakingController : ControllerBase +{ + private readonly IMatchmakingService _matchmakingService; + + public MatchmakingController(IMatchmakingService matchmakingService) + { + _matchmakingService = matchmakingService; + } + + [HttpPost("reindex")] + [Authorize(Policy = Policies.AdminOnly)] + public async Task> Reindex(CancellationToken cancellationToken) + { + var indexed = await _matchmakingService.ReindexAsync(cancellationToken); + + return Ok(new MatchmakingReindexResponse(indexed)); + } +} + +public sealed record MatchmakingReindexResponse(int Indexed); diff --git a/backend/Controllers/SubmissionsController.cs b/backend/Controllers/SubmissionsController.cs index 509b54d..a0063b4 100644 --- a/backend/Controllers/SubmissionsController.cs +++ b/backend/Controllers/SubmissionsController.cs @@ -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> 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> 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 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); } diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs index 3ea658e..3f1011c 100644 --- a/backend/Infrastructure/DependencyInjection.cs +++ b/backend/Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SurrealDb.Net; using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Matchmaking; using TarninaHub.Infrastructure.Auth; using TarninaHub.Infrastructure.Events; using TarninaHub.Infrastructure.Persistence; @@ -59,6 +60,8 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/backend/Infrastructure/Persistence/MatchmakingService.cs b/backend/Infrastructure/Persistence/MatchmakingService.cs new file mode 100644 index 0000000..c96b84f --- /dev/null +++ b/backend/Infrastructure/Persistence/MatchmakingService.cs @@ -0,0 +1,252 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; +using TarninaHub.Application.Matchmaking; +using TarninaHub.Application.Text; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class MatchmakingService : IMatchmakingService +{ + private readonly ISurrealDbClient _client; + private readonly IEmbeddingProvider _embeddingProvider; + private readonly ISubmissionService _submissionService; + + public MatchmakingService( + ISurrealDbClient client, + IEmbeddingProvider embeddingProvider, + ISubmissionService submissionService) + { + _client = client; + _embeddingProvider = embeddingProvider; + _submissionService = submissionService; + } + + public async Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(innovationId); + + var response = await _client.RawQuery( + "SELECT type::string(id) AS id, title, summary, description, type::string(challenge) AS challenge " + + "FROM type::record($table, $id) LIMIT 1;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + if (rows.Count == 0) + { + return; + } + + var text = EmbeddingTextBuilder.Build(rows[0].Title, rows[0].Summary, rows[0].Description); + var embedding = await _embeddingProvider.EmbedAsync(text, cancellationToken); + + var update = await _client.RawQuery( + "UPDATE type::record($table, $id) SET embedding = $embedding;", + new Dictionary { ["table"] = table, ["id"] = id, ["embedding"] = embedding }, + cancellationToken); + update.EnsureAllOks(); + } + + public async Task IndexProblemAsync(string problemId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(problemId); + + var response = await _client.RawQuery( + "SELECT type::string(id) AS id, title, description, type::string(challenge) AS challenge " + + "FROM type::record($table, $id) LIMIT 1;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + if (rows.Count == 0) + { + return; + } + + var text = EmbeddingTextBuilder.Build(rows[0].Title, null, rows[0].Description); + var embedding = await _embeddingProvider.EmbedAsync(text, cancellationToken); + + var update = await _client.RawQuery( + "UPDATE type::record($table, $id) SET embedding = $embedding;", + new Dictionary { ["table"] = table, ["id"] = id, ["embedding"] = embedding }, + cancellationToken); + update.EnsureAllOks(); + } + + public async Task IndexSubmissionAsync(string submissionId, CancellationToken cancellationToken) + { + var submission = await _submissionService.GetAsync(submissionId, cancellationToken); + + if (submission is not null) + { + await IndexProblemAsync(submission.ProblemId, cancellationToken); + } + } + + public async Task ReindexAsync(CancellationToken cancellationToken) + { + var count = 0; + + var innovations = await _client.RawQuery( + "SELECT type::string(id) AS id FROM innovation;", + cancellationToken: cancellationToken); + innovations.EnsureAllOks(); + + foreach (var row in innovations.GetValue>(0) ?? []) + { + await IndexInnovationAsync(row.Id, cancellationToken); + count++; + } + + var problems = await _client.RawQuery( + "SELECT type::string(id) AS id FROM problem;", + cancellationToken: cancellationToken); + problems.EnsureAllOks(); + + foreach (var row in problems.GetValue>(0) ?? []) + { + await IndexProblemAsync(row.Id, cancellationToken); + count++; + } + + return count; + } + + public async Task MatchAsync( + string submissionId, + MatchRequest request, + CancellationToken cancellationToken) + { + var submission = await _submissionService.GetAsync(submissionId, cancellationToken); + + if (submission is null) + { + return null; + } + + var maxResults = Math.Clamp(request.MaxResults ?? 5, 1, 20); + var queryText = EmbeddingTextBuilder.Build( + submission.Title, + null, + submission.Description); + + if (submission.Keywords.Count > 0) + { + queryText += "\n" + string.Join(" ", submission.Keywords); + } + + var queryVector = await _embeddingProvider.EmbedAsync(queryText, cancellationToken); + + var solutions = await RankSolutionsAsync(submission, request.ChallengeId, queryVector, maxResults, cancellationToken); + var similarCases = await RankSimilarCasesAsync(submission, request.ChallengeId, queryVector, maxResults, cancellationToken); + + return new MatchResponse(similarCases, solutions, Fallback: solutions.Count == 0); + } + + private async Task> RankSolutionsAsync( + SubmissionResponse submission, + string? challengeFilter, + float[] queryVector, + int maxResults, + CancellationToken cancellationToken) + { + var clauses = new List { "published = true", "embedding != NONE" }; + var parameters = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(challengeFilter)) + { + var (table, id) = SurrealRecordId.Split(challengeFilter); + clauses.Add("challenge = type::record($challengeTable, $challengeId)"); + parameters["challengeTable"] = table; + parameters["challengeId"] = id; + } + + var response = await _client.RawQuery( + "SELECT type::string(id) AS id, title, summary, type::string(challenge) AS challenge, stage, embedding " + + "FROM innovation WHERE " + string.Join(" AND ", clauses) + ";", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + var candidates = rows + .Where(row => row.Embedding is { Count: > 0 }) + .Select(row => (Item: row, Vector: (IReadOnlyList)row.Embedding!)); + + var ranked = SemanticRanker.Rank(queryVector, candidates, maxResults, minScore: 0.05); + + return ranked + .Select(item => new SolutionItem( + item.Item.Id, + item.Item.Title, + item.Item.Stage, + NormalizeRecordId(item.Item.Challenge) ?? string.Empty, + Math.Round(item.Score, 3), + BuildRationale(item.Item.Challenge, submission.ChallengeId))) + .ToList(); + } + + private async Task> RankSimilarCasesAsync( + SubmissionResponse submission, + string? challengeFilter, + float[] queryVector, + int maxResults, + CancellationToken cancellationToken) + { + var (problemTable, problemId) = SurrealRecordId.Split(submission.ProblemId); + var clauses = new List { "embedding != NONE", "id != type::record($problemTable, $problemId)" }; + var parameters = new Dictionary + { + ["problemTable"] = problemTable, + ["problemId"] = problemId, + }; + + if (!string.IsNullOrWhiteSpace(challengeFilter)) + { + var (table, id) = SurrealRecordId.Split(challengeFilter); + clauses.Add("challenge = type::record($challengeTable, $challengeId)"); + parameters["challengeTable"] = table; + parameters["challengeId"] = id; + } + + var response = await _client.RawQuery( + "SELECT type::string(id) AS id, title, type::string(challenge) AS challenge, embedding " + + "FROM problem WHERE " + string.Join(" AND ", clauses) + ";", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + var candidates = rows + .Where(row => row.Embedding is { Count: > 0 }) + .Select(row => (Item: row, Vector: (IReadOnlyList)row.Embedding!)); + + var ranked = SemanticRanker.Rank(queryVector, candidates, maxResults, minScore: 0.05); + + return ranked + .Select(item => new SimilarCaseItem( + item.Item.Id, + item.Item.Title, + Math.Round(item.Score, 3))) + .ToList(); + } + + private static string BuildRationale(string challenge, string queryChallengeId) + { + var normalized = NormalizeRecordId(challenge); + + return string.Equals(normalized, queryChallengeId, StringComparison.Ordinal) + ? "Pasuje obszarowo do zgłoszenia." + : "Wysoka zbieżność semantyczna opisu."; + } + + private static string? NormalizeRecordId(string? value) => + string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value; +} diff --git a/backend/Infrastructure/Persistence/SurrealRows.cs b/backend/Infrastructure/Persistence/SurrealRows.cs index 1c72c27..a8f975b 100644 --- a/backend/Infrastructure/Persistence/SurrealRows.cs +++ b/backend/Infrastructure/Persistence/SurrealRows.cs @@ -118,6 +118,25 @@ internal sealed class InnovationRow [CborProperty("published")] public bool Published { get; set; } [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } + + [CborProperty("embedding")] public List? Embedding { get; set; } +} + +internal sealed class ProblemRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("title")] public string Title { get; set; } = string.Empty; + + [CborProperty("description")] public string Description { get; set; } = string.Empty; + + [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; + + [CborProperty("location")] public string Location { get; set; } = string.Empty; + + [CborProperty("author")] public string Author { get; set; } = string.Empty; + + [CborProperty("embedding")] public List? Embedding { get; set; } } internal sealed class TrendRow @@ -131,3 +150,8 @@ internal sealed class TrendSubmissionRow { [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; } + +internal sealed class IdRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; +}