feat(ideas): add idea creator, grant calls and applications

Implement the idea creator and application generator: idea status workflow, social canvas card, grant call windows with 409 when closed, and an LLM-backed idea assistant with offline fallback.
This commit is contained in:
Rafał Tyński
2026-10-04 03:13:45 +02:00
parent fd408b102b
commit 127ca92f63
15 changed files with 1089 additions and 0 deletions
@@ -0,0 +1,87 @@
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Matchmaking;
namespace TarninaHub.Infrastructure.Assist;
/// <summary>
/// Idea-creator assistant. Uses the configured LLM when available and falls
/// back to deterministic, stage-aware suggestions otherwise.
/// </summary>
public sealed class IdeaAssistant : IIdeaAssistant
{
private const string SystemPrompt =
"Jesteś asystentem kreatora innowacji społecznych w Małopolskim Hubie Innowacji Społecznych. " +
"Rozwijasz pomysł mieszkańca: podajesz konkretne, praktyczne podpowiedzi prostym językiem. " +
"Nie wymyślaj faktów ani danych liczbowych.";
private readonly IIdeaService _ideaService;
private readonly ILlmProvider _llmProvider;
private readonly ILlmRequestLimiter _llmRequestLimiter;
public IdeaAssistant(
IIdeaService ideaService,
ILlmProvider llmProvider,
ILlmRequestLimiter llmRequestLimiter)
{
_ideaService = ideaService;
_llmProvider = llmProvider;
_llmRequestLimiter = llmRequestLimiter;
}
public async Task<IdeaAssistResponse?> AssistAsync(
string ideaId,
IdeaAssistRequest request,
CancellationToken cancellationToken)
{
var idea = await _ideaService.GetAsync(ideaId, cancellationToken);
if (idea is null)
{
return null;
}
var improvedDescription = idea.Description;
var fromLlm = false;
if (_llmProvider.IsConfigured && _llmRequestLimiter.TryAcquire())
{
var userPrompt =
$"Pomysł: {idea.Title}\n" +
$"Opis: {idea.Description}\n" +
$"Etap: {idea.Card?.Stage ?? "idea"}\n" +
$"Odbiorca: {idea.Card?.Audience ?? "nieokreślony"}\n" +
$"Fokus: {request.Focus ?? "ogólny"}\n" +
"Rozwiń opis pomysłu i dodaj praktyczne wskazówki.";
var completion = await _llmProvider.CompleteAsync(SystemPrompt, userPrompt, cancellationToken);
if (!string.IsNullOrWhiteSpace(completion))
{
improvedDescription = completion.Trim();
fromLlm = true;
}
}
return new IdeaAssistResponse(BuildSuggestions(idea), improvedDescription, fromLlm);
}
private static IReadOnlyList<string> BuildSuggestions(IdeaResponse idea)
{
var stage = idea.Card?.Stage ?? "idea";
var suggestions = new List<string>
{
"Doprecyzuj grupę docelową i jej konkretną potrzebę.",
"Określ, jak zmierzysz efekt (wskaźniki, liczba odbiorców).",
"Zaplanuj mały pilotaż i wskaż partnerów (NGO/JST/firma).",
$"Dopasuj opis do etapu '{stage}' i sprawdź wymagania naboru.",
};
if (!string.IsNullOrWhiteSpace(idea.Card?.Audience))
{
suggestions.Add($"Rozwiń opis odbiorcy: {idea.Card.Audience}.");
}
return suggestions;
}
}
@@ -4,6 +4,7 @@ using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Matchmaking;
using TarninaHub.Infrastructure.Auth;
using TarninaHub.Infrastructure.Assist;
using TarninaHub.Infrastructure.Events;
using TarninaHub.Infrastructure.Llm;
using TarninaHub.Infrastructure.Persistence;
@@ -80,6 +81,10 @@ public static class DependencyInjection
services.AddSingleton<ITrendService, TrendService>();
services.AddSingleton<IEmbeddingProvider, HashingEmbeddingProvider>();
services.AddSingleton<IMatchmakingService, MatchmakingService>();
services.AddSingleton<IIdeaService, IdeaService>();
services.AddSingleton<IGrantCallService, GrantCallService>();
services.AddSingleton<IApplicationService, ApplicationService>();
services.AddSingleton<IIdeaAssistant, IdeaAssistant>();
return services;
}
@@ -0,0 +1,136 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Domain.Rules;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class ApplicationService : IApplicationService
{
private const string SelectFields =
"type::string(id) AS id, type::string(grant_call) AS grant_call, type::string(idea) AS idea, " +
"content, status, created_at";
private readonly ISurrealDbClient _client;
private readonly IGrantCallService _grantCallService;
private readonly IIdeaService _ideaService;
public ApplicationService(
ISurrealDbClient client,
IGrantCallService grantCallService,
IIdeaService ideaService)
{
_client = client;
_grantCallService = grantCallService;
_ideaService = ideaService;
}
public async Task<CreateApplicationResult> CreateAsync(
string userId,
CreateApplicationRequest request,
CancellationToken cancellationToken)
{
var grantCall = await _grantCallService.GetAsync(request.GrantCallId, cancellationToken);
if (grantCall is null)
{
return new CreateApplicationResult(CreateApplicationOutcome.CallNotFound, null);
}
if (!ApplicationWindow.IsOpen(grantCall.Status, grantCall.OpensAt, grantCall.ClosesAt, DateTimeOffset.UtcNow))
{
return new CreateApplicationResult(CreateApplicationOutcome.CallNotOpen, null);
}
var idea = await _ideaService.GetAsync(request.IdeaId, cancellationToken);
if (idea is null)
{
return new CreateApplicationResult(CreateApplicationOutcome.IdeaNotFound, null);
}
var id = Guid.NewGuid().ToString("N");
var (grantTable, grantId) = SurrealRecordId.Split(request.GrantCallId);
var (ideaTable, ideaIdPart) = SurrealRecordId.Split(request.IdeaId);
var response = await _client.RawQuery(
"CREATE type::record('application', $id) SET " +
"grant_call = type::record($grantTable, $grantId), " +
"idea = type::record($ideaTable, $ideaId), " +
"content = $content, status = 'draft'; " +
$"SELECT {SelectFields} FROM type::record('application', $id) LIMIT 1;",
new Dictionary<string, object?>
{
["id"] = id,
["grantTable"] = grantTable,
["grantId"] = grantId,
["ideaTable"] = ideaTable,
["ideaId"] = ideaIdPart,
["content"] = ToStorageContent(request.Content),
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<ApplicationRow>>(1) ?? [];
return new CreateApplicationResult(CreateApplicationOutcome.Created, Map(rows[0]));
}
public async Task<ApplicationResponse?> GetAsync(string applicationId, CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(applicationId);
var response = await _client.RawQuery(
$"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<ApplicationRow>>(0) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<IReadOnlyList<ApplicationResponse>> ListForAuthorAsync(
string authorId,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(authorId);
var response = await _client.RawQuery(
$"SELECT {SelectFields} FROM application WHERE idea.author = type::record($table, $id) " +
"ORDER BY created_at DESC;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<ApplicationRow>>(0) ?? [];
return rows.Select(Map).ToList();
}
private static ApplicationResponse Map(ApplicationRow row) => new(
row.Id,
row.GrantCall,
row.Idea,
row.Content.ToDictionary(pair => pair.Key, pair => (object?)pair.Value),
row.Status,
row.CreatedAt);
private static Dictionary<string, string?> ToStorageContent(IReadOnlyDictionary<string, object?>? content)
{
var result = new Dictionary<string, string?>();
if (content is null)
{
return result;
}
foreach (var (key, value) in content)
{
result[key] = value?.ToString();
}
return result;
}
}
@@ -0,0 +1,65 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class GrantCallService : IGrantCallService
{
private const string SelectFields =
"type::string(id) AS id, name, opens_at, closes_at, status, external_id";
private readonly ISurrealDbClient _client;
public GrantCallService(ISurrealDbClient client)
{
_client = client;
}
public async Task<IReadOnlyList<GrantCallResponse>> ListAsync(
string? status,
CancellationToken cancellationToken)
{
var parameters = new Dictionary<string, object?>();
var where = string.Empty;
if (!string.IsNullOrWhiteSpace(status))
{
where = " WHERE status = $status";
parameters["status"] = status;
}
var response = await _client.RawQuery(
$"SELECT {SelectFields} FROM grant_call{where} ORDER BY opens_at DESC;",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<GrantCallRow>>(0) ?? [];
return rows.Select(Map).ToList();
}
public async Task<GrantCallResponse?> GetAsync(string grantCallId, CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(grantCallId);
var response = await _client.RawQuery(
$"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<GrantCallRow>>(0) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
private static GrantCallResponse Map(GrantCallRow row) => new(
row.Id,
row.Name,
row.OpensAt,
row.ClosesAt,
row.Status,
row.ExternalId);
}
@@ -0,0 +1,218 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Mappings;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class IdeaService : IIdeaService
{
private const string SelectIdeaFields =
"type::string(id) AS id, title, description, type::string(author) AS author, status, updated_at";
private const string SelectCardFields =
"type::string(id) AS id, type::string(idea) AS idea, essence, audience, stage";
private readonly ISurrealDbClient _client;
public IdeaService(ISurrealDbClient client)
{
_client = client;
}
public async Task<IdeaResponse> CreateAsync(
string authorId,
CreateIdeaRequest request,
CancellationToken cancellationToken)
{
var ideaId = Guid.NewGuid().ToString("N");
var cardId = Guid.NewGuid().ToString("N");
var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId);
var card = request.Card ?? new IdeaCardRequest(null, null, null);
var ideaResponse = await _client.RawQuery(
"CREATE type::record('idea', $id) SET title = $title, description = $description, " +
"author = type::record($authorTable, $authorId), status = 'draft';",
new Dictionary<string, object?>
{
["id"] = ideaId,
["title"] = request.Title,
["description"] = request.Description,
["authorTable"] = authorTable,
["authorId"] = authorIdPart,
},
cancellationToken);
ideaResponse.EnsureAllOks();
var cardResponse = await _client.RawQuery(
"CREATE type::record('idea_card', $id) SET idea = type::record('idea', $ideaId), " +
"essence = $essence, audience = $audience, stage = $stage;",
new Dictionary<string, object?>
{
["id"] = cardId,
["ideaId"] = ideaId,
["essence"] = card.Essence ?? string.Empty,
["audience"] = card.Audience ?? string.Empty,
["stage"] = string.IsNullOrWhiteSpace(card.Stage) ? "idea" : card.Stage,
},
cancellationToken);
cardResponse.EnsureAllOks();
return await LoadAsync($"idea:{ideaId}", cancellationToken)
?? throw new InvalidOperationException("Idea could not be loaded after creation.");
}
public async Task<IdeaResponse?> GetAsync(string ideaId, CancellationToken cancellationToken) =>
await LoadAsync(ideaId, cancellationToken);
public async Task<IReadOnlyList<IdeaResponse>> ListForAuthorAsync(
string authorId,
CancellationToken cancellationToken)
{
var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId);
var response = await _client.RawQuery(
$"SELECT {SelectIdeaFields} FROM idea WHERE author = type::record($authorTable, $authorId) " +
"ORDER BY updated_at DESC;",
new Dictionary<string, object?>
{
["authorTable"] = authorTable,
["authorId"] = authorIdPart,
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<IdeaRow>>(0) ?? [];
var result = new List<IdeaResponse>(rows.Count);
foreach (var row in rows)
{
result.Add(Map(row, await LoadCardAsync(row.Id, cancellationToken)));
}
return result;
}
public async Task<IdeaResponse?> UpdateAsync(
string ideaId,
UpdateIdeaRequest request,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(ideaId);
var parameters = new Dictionary<string, object?>
{
["table"] = table,
["id"] = id,
["title"] = request.Title,
["description"] = request.Description,
};
var assignments = new List<string>
{
"title = $title", "description = $description", "updated_at = time::now()",
};
if (!string.IsNullOrWhiteSpace(request.Status) && IdeaStatusWire.Parse(request.Status) is { } status)
{
assignments.Add("status = $status");
parameters["status"] = status.ToWire();
}
var response = await _client.RawQuery(
$"UPDATE type::record($table, $id) SET {string.Join(", ", assignments)}; " +
$"SELECT {SelectIdeaFields} FROM type::record($table, $id) LIMIT 1;",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<IdeaRow>>(1) ?? [];
if (rows.Count == 0)
{
return null;
}
return Map(rows[0], await LoadCardAsync(ideaId, cancellationToken));
}
public async Task<IdeaCardResponse?> GetCardAsync(string ideaId, CancellationToken cancellationToken) =>
await LoadCardAsync(ideaId, cancellationToken);
public async Task<IdeaCardResponse?> UpdateCardAsync(
string ideaId,
IdeaCardRequest request,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(ideaId);
var response = await _client.RawQuery(
"UPDATE idea_card SET essence = $essence, audience = $audience, stage = $stage " +
"WHERE idea = type::record($table, $id); " +
$"SELECT {SelectCardFields} FROM idea_card WHERE idea = type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?>
{
["table"] = table,
["id"] = id,
["essence"] = request.Essence ?? string.Empty,
["audience"] = request.Audience ?? string.Empty,
["stage"] = string.IsNullOrWhiteSpace(request.Stage) ? "idea" : request.Stage,
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<IdeaCardRow>>(1) ?? [];
return rows.Count > 0 ? MapCard(rows[0]) : null;
}
private async Task<IdeaResponse?> LoadAsync(string ideaId, CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(ideaId);
var response = await _client.RawQuery(
$"SELECT {SelectIdeaFields} FROM type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<IdeaRow>>(0) ?? [];
if (rows.Count == 0)
{
return null;
}
return Map(rows[0], await LoadCardAsync(ideaId, cancellationToken));
}
private async Task<IdeaCardResponse?> LoadCardAsync(string ideaId, CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(ideaId);
var response = await _client.RawQuery(
$"SELECT {SelectCardFields} FROM idea_card WHERE idea = type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<IdeaCardRow>>(0) ?? [];
return rows.Count > 0 ? MapCard(rows[0]) : null;
}
private static IdeaResponse Map(IdeaRow row, IdeaCardResponse? card) => new(
row.Id,
row.Title,
row.Description,
row.Author,
row.Status,
card,
row.UpdatedAt);
private static IdeaCardResponse MapCard(IdeaCardRow row) => new(
row.Id,
row.Idea,
row.Essence,
row.Audience,
row.Stage);
}
@@ -155,3 +155,61 @@ internal sealed class IdRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
}
internal sealed class IdeaRow
{
[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("author")] public string Author { get; set; } = string.Empty;
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
}
internal sealed class IdeaCardRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("idea")] public string Idea { get; set; } = string.Empty;
[CborProperty("essence")] public string Essence { get; set; } = string.Empty;
[CborProperty("audience")] public string Audience { get; set; } = string.Empty;
[CborProperty("stage")] public string Stage { get; set; } = string.Empty;
}
internal sealed class GrantCallRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("name")] public string Name { get; set; } = string.Empty;
[CborProperty("opens_at")] public DateTimeOffset OpensAt { get; set; }
[CborProperty("closes_at")] public DateTimeOffset ClosesAt { get; set; }
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("external_id")] public string? ExternalId { get; set; }
}
internal sealed class ApplicationRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("grant_call")] public string GrantCall { get; set; } = string.Empty;
[CborProperty("idea")] public string Idea { get; set; } = string.Empty;
[CborProperty("content")] public Dictionary<string, string?> Content { get; set; } = [];
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
}