Skip to content

Commit 09d73d2

Browse files
authored
Merge branch 'main' into enhance/571-intent-classifier-nlp
2 parents 9b9f142 + b948742 commit 09d73d2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+3987
-2333
lines changed

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
3030
services.AddScoped<ICaptureService, CaptureService>();
3131
services.AddScoped<ICaptureTriageService, CaptureTriageService>();
3232
services.AddScoped<HistoryService>();
33+
services.AddScoped<IHistoryService>(sp => sp.GetRequiredService<HistoryService>());
3334
services.AddScoped<IAutomationProposalService, AutomationProposalService>();
3435
services.AddScoped<IAutomationPolicyEngine, AutomationPolicyEngine>();
3536
services.AddScoped<IAutomationPlannerService, AutomationPlannerService>();

backend/src/Taskdeck.Api/Taskdeck.Api.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717

1818
<ItemGroup>
1919
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.25" />
20-
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.25" />
21-
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.25">
20+
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14" />
21+
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.14">
2222
<PrivateAssets>all</PrivateAssets>
2323
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2424
</PackageReference>

backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,8 @@ public async Task<Result<ProposalDto>> ParseInstructionAsync(
370370
}
371371

372372
if (!operations.Any())
373-
return Result.Failure<ProposalDto>(ErrorCodes.ValidationError,
374-
"Could not parse instruction. Supported patterns: 'create card \"title\"', 'move card {id} to column \"name\"', 'archive card {id}', 'archive cards matching \"pattern\"', 'update card {id} title/description \"value\"', 'rename board to \"name\"', 'update board description \"value\"', 'archive board', 'unarchive board', 'move column \"name\" to position {n}'");
373+
return Result.Failure<ProposalDto>(ErrorCodes.ValidationError,
374+
BuildParseHintMessage(instruction));
375375

376376
// Classify risk
377377
var operationDtos = operations.Select(o => new ProposalOperationDto(
@@ -420,6 +420,110 @@ public async Task<Result<ProposalDto>> ParseInstructionAsync(
420420
}
421421
}
422422

423+
internal static readonly string ParseHintMarker = "[PARSE_HINT]";
424+
425+
internal static readonly (string Pattern, string Example, string[] Keywords)[] SupportedPatterns = new[]
426+
{
427+
("create card \"title\"", "create card \"My new task\"", new[] { "create", "add", "new", "card", "task" }),
428+
("create card \"title\" in column \"name\"", "create card \"Bug fix\" in column \"In Progress\"", new[] { "create", "add", "new", "card", "column", "in" }),
429+
("move card {id} to column \"name\"", "move card abc-123 to column \"Done\"", new[] { "move", "card", "column", "to" }),
430+
("archive card {id}", "archive card abc-123", new[] { "archive", "card", "remove", "delete" }),
431+
("archive cards matching \"pattern\"", "archive cards matching \"old tasks\"", new[] { "archive", "cards", "matching", "bulk", "batch" }),
432+
("update card {id} title \"value\"", "update card abc-123 title \"New title\"", new[] { "update", "edit", "change", "card", "title", "rename" }),
433+
("update card {id} description \"value\"", "update card abc-123 description \"Updated details\"", new[] { "update", "edit", "change", "card", "description", "desc" }),
434+
("rename board to \"name\"", "rename board to \"Sprint 5\"", new[] { "rename", "board", "name", "title" }),
435+
("update board description \"value\"", "update board description \"Team workspace\"", new[] { "update", "board", "description", "desc" }),
436+
("archive board", "archive board", new[] { "archive", "board" }),
437+
("unarchive board", "unarchive board", new[] { "unarchive", "restore", "board" }),
438+
("move column \"name\" to position {n}", "move column \"Done\" to position 0", new[] { "move", "column", "position", "reorder" }),
439+
};
440+
441+
internal static string BuildParseHintMessage(string instruction)
442+
{
443+
var detectedIntent = DetectIntent(instruction);
444+
var bestMatch = FindClosestPattern(instruction, detectedIntent);
445+
446+
var patterns = SupportedPatterns.Select(p => p.Pattern).ToArray();
447+
var hint = new ParseHintPayload(
448+
patterns,
449+
bestMatch.Example,
450+
bestMatch.Pattern,
451+
detectedIntent);
452+
453+
var hintJson = JsonSerializer.Serialize(hint, new JsonSerializerOptions
454+
{
455+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
456+
});
457+
458+
return $"Could not parse instruction into a proposal.{ParseHintMarker}{hintJson}";
459+
}
460+
461+
internal static string? DetectIntent(string instruction)
462+
{
463+
var lower = instruction.Trim().ToLowerInvariant();
464+
var words = lower.Split(' ', StringSplitOptions.RemoveEmptyEntries);
465+
466+
// Check more-specific intents before their substrings (e.g. "unarchive" before "archive",
467+
// "rename" before "new"). Use word-level matching to avoid substring false positives
468+
// like "sunset" matching "set" or "address" matching "add".
469+
bool hasWord(string word) => words.Any(w => w == word);
470+
471+
if (lower.Contains("unarchive") || hasWord("restore"))
472+
return "unarchive";
473+
if (lower.Contains("rename") || hasWord("edit") || hasWord("change") || hasWord("update"))
474+
return "update";
475+
if (hasWord("reorder") || hasWord("position"))
476+
return "reorder";
477+
if (hasWord("create") || hasWord("add") || hasWord("new"))
478+
return "create";
479+
if (hasWord("move") || hasWord("drag") || hasWord("transfer"))
480+
return "move";
481+
if (hasWord("archive") || hasWord("remove") || hasWord("delete"))
482+
return "archive";
483+
484+
return null;
485+
}
486+
487+
internal static (string Pattern, string Example) FindClosestPattern(string instruction, string? detectedIntent)
488+
{
489+
var lower = instruction.Trim().ToLowerInvariant();
490+
var words = lower.Split(' ', StringSplitOptions.RemoveEmptyEntries);
491+
492+
var bestScore = -1;
493+
var bestPattern = SupportedPatterns[0];
494+
495+
foreach (var entry in SupportedPatterns)
496+
{
497+
var score = 0;
498+
499+
// Boost patterns whose keywords match whole words in the instruction
500+
foreach (var keyword in entry.Keywords)
501+
{
502+
if (words.Any(w => w == keyword))
503+
score += 2;
504+
}
505+
506+
// Extra boost if the detected intent matches the first keyword
507+
if (detectedIntent != null && entry.Keywords.Length > 0 &&
508+
entry.Keywords[0].Equals(detectedIntent, StringComparison.OrdinalIgnoreCase))
509+
score += 5;
510+
511+
if (score > bestScore)
512+
{
513+
bestScore = score;
514+
bestPattern = entry;
515+
}
516+
}
517+
518+
return (bestPattern.Pattern, bestPattern.Example);
519+
}
520+
521+
internal record ParseHintPayload(
522+
string[] SupportedPatterns,
523+
string ExampleInstruction,
524+
string ClosestPattern,
525+
string? DetectedIntent);
526+
423527
private static bool TryResolveCorrelationId(string? correlationId, out string resolvedCorrelationId, out string error)
424528
{
425529
if (correlationId == null)

backend/src/Taskdeck.Application/Services/BoardService.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Taskdeck.Application.Interfaces;
33
using Taskdeck.Domain.Common;
44
using Taskdeck.Domain.Entities;
5+
using Taskdeck.Domain.Enums;
56
using Taskdeck.Domain.Exceptions;
67

78
namespace Taskdeck.Application.Services;
@@ -11,20 +12,30 @@ public class BoardService
1112
private readonly IUnitOfWork _unitOfWork;
1213
private readonly IAuthorizationService? _authorizationService;
1314
private readonly IBoardRealtimeNotifier _realtimeNotifier;
15+
private readonly IHistoryService? _historyService;
1416

1517
public BoardService(IUnitOfWork unitOfWork)
16-
: this(unitOfWork, authorizationService: null, realtimeNotifier: null)
18+
: this(unitOfWork, authorizationService: null, realtimeNotifier: null, historyService: null)
1719
{
1820
}
1921

2022
public BoardService(
2123
IUnitOfWork unitOfWork,
2224
IAuthorizationService? authorizationService,
23-
IBoardRealtimeNotifier? realtimeNotifier = null)
25+
IBoardRealtimeNotifier? realtimeNotifier = null,
26+
IHistoryService? historyService = null)
2427
{
2528
_unitOfWork = unitOfWork;
2629
_authorizationService = authorizationService;
2730
_realtimeNotifier = realtimeNotifier ?? NoOpBoardRealtimeNotifier.Instance;
31+
_historyService = historyService;
32+
}
33+
34+
private async Task SafeLogAsync(string entityType, Guid entityId, AuditAction action, Guid? userId = null, string? changes = null)
35+
{
36+
if (_historyService == null) return;
37+
try { await _historyService.LogActionAsync(entityType, entityId, action, userId, changes); }
38+
catch (Exception) { /* Audit is secondary — never crash the mutation */ }
2839
}
2940

3041
public async Task<Result<BoardDto>> CreateBoardAsync(CreateBoardDto dto, Guid actingUserId, CancellationToken cancellationToken = default)
@@ -139,6 +150,7 @@ private async Task<Result<BoardDto>> CreateBoardInternalAsync(CreateBoardDto dto
139150
await _realtimeNotifier.NotifyBoardMutationAsync(
140151
new BoardRealtimeEvent(board.Id, "board", "created", board.Id, DateTimeOffset.UtcNow),
141152
cancellationToken);
153+
await SafeLogAsync("board", board.Id, AuditAction.Created, ownerId, $"name={board.Name}");
142154

143155
return Result.Success(MapToDto(board));
144156
}
@@ -171,6 +183,12 @@ private async Task<Result<BoardDto>> UpdateBoardInternalAsync(Guid id, UpdateBoa
171183
await _realtimeNotifier.NotifyBoardMutationAsync(
172184
new BoardRealtimeEvent(board.Id, "board", "updated", board.Id, DateTimeOffset.UtcNow),
173185
cancellationToken);
186+
if (dto.IsArchived == true)
187+
await SafeLogAsync("board", board.Id, AuditAction.Archived, changes: $"name={board.Name}");
188+
else if (dto.IsArchived == false)
189+
await SafeLogAsync("board", board.Id, AuditAction.Updated, changes: $"unarchived; name={board.Name}");
190+
else
191+
await SafeLogAsync("board", board.Id, AuditAction.Updated, changes: $"name={board.Name}");
174192
return Result.Success(MapToDto(board));
175193
}
176194
catch (DomainException ex)
@@ -199,6 +217,7 @@ private async Task<Result> DeleteBoardInternalAsync(Guid id, CancellationToken c
199217
await _realtimeNotifier.NotifyBoardMutationAsync(
200218
new BoardRealtimeEvent(board.Id, "board", "archived", board.Id, DateTimeOffset.UtcNow),
201219
cancellationToken);
220+
await SafeLogAsync("board", board.Id, AuditAction.Archived, changes: $"name={board.Name}");
202221
return Result.Success();
203222
}
204223

backend/src/Taskdeck.Application/Services/CardService.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,25 @@ public class CardService
1111
{
1212
private readonly IUnitOfWork _unitOfWork;
1313
private readonly IBoardRealtimeNotifier _realtimeNotifier;
14+
private readonly IHistoryService? _historyService;
1415

1516
public CardService(IUnitOfWork unitOfWork)
16-
: this(unitOfWork, realtimeNotifier: null)
17+
: this(unitOfWork, realtimeNotifier: null, historyService: null)
1718
{
1819
}
1920

20-
public CardService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null)
21+
public CardService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null, IHistoryService? historyService = null)
2122
{
2223
_unitOfWork = unitOfWork;
2324
_realtimeNotifier = realtimeNotifier ?? NoOpBoardRealtimeNotifier.Instance;
25+
_historyService = historyService;
26+
}
27+
28+
private async Task SafeLogAsync(string entityType, Guid entityId, AuditAction action, Guid? userId = null, string? changes = null)
29+
{
30+
if (_historyService == null) return;
31+
try { await _historyService.LogActionAsync(entityType, entityId, action, userId, changes); }
32+
catch (Exception) { /* Audit is secondary — never crash the mutation */ }
2433
}
2534

2635
public async Task<Result<CardDto>> CreateCardAsync(CreateCardDto dto, CancellationToken cancellationToken = default)
@@ -74,6 +83,7 @@ public async Task<Result<CardDto>> CreateCardAsync(
7483
await _realtimeNotifier.NotifyBoardMutationAsync(
7584
new BoardRealtimeEvent(card.BoardId, "card", "created", card.Id, DateTimeOffset.UtcNow),
7685
cancellationToken);
86+
await SafeLogAsync("card", card.Id, AuditAction.Created, changes: $"title={card.Title}");
7787

7888
var createdCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(card.Id, cancellationToken);
7989
return Result.Success(MapToDto(createdCard!));
@@ -134,6 +144,7 @@ public async Task<Result<CardDto>> UpdateCardAsync(
134144
await _realtimeNotifier.NotifyBoardMutationAsync(
135145
new BoardRealtimeEvent(card.BoardId, "card", "updated", card.Id, DateTimeOffset.UtcNow),
136146
cancellationToken);
147+
await SafeLogAsync("card", card.Id, AuditAction.Updated, actorUserId);
137148

138149
var updatedCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(id, cancellationToken);
139150
return Result.Success(MapToDto(updatedCard!));
@@ -213,6 +224,7 @@ public async Task<Result<CardDto>> MoveCardAsync(Guid id, MoveCardDto dto, Cance
213224
await _realtimeNotifier.NotifyBoardMutationAsync(
214225
new BoardRealtimeEvent(card.BoardId, "card", "moved", card.Id, DateTimeOffset.UtcNow),
215226
cancellationToken);
227+
await SafeLogAsync("card", card.Id, AuditAction.Moved, changes: $"target_column={dto.TargetColumnId}; position={dto.TargetPosition}");
216228

217229
var movedCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(id, cancellationToken);
218230
return Result.Success(MapToDto(movedCard!));
@@ -299,6 +311,7 @@ public async Task<Result> DeleteCardAsync(Guid id, CancellationToken cancellatio
299311
await _realtimeNotifier.NotifyBoardMutationAsync(
300312
new BoardRealtimeEvent(card.BoardId, "card", "deleted", card.Id, DateTimeOffset.UtcNow),
301313
cancellationToken);
314+
await SafeLogAsync("card", card.Id, AuditAction.Deleted, changes: $"title={card.Title}");
302315

303316
return Result.Success();
304317
}

backend/src/Taskdeck.Application/Services/ChatService.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,15 @@ await _quotaService.RecordUsageAsync(
243243
proposalId = proposalResult.Value.Id;
244244
assistantContent = $"{llmResult.Content}\n\nProposal created for review: {proposalResult.Value.Id}";
245245
}
246+
else if (proposalResult.ErrorMessage?.Contains(AutomationPlannerService.ParseHintMarker) == true)
247+
{
248+
// Structured parse hint — use parse-hint message type so frontend can render a hint card
249+
var hintContext = llmResult.IsActionable
250+
? "I detected a task request but could not parse it into a proposal."
251+
: "Could not create the requested proposal.";
252+
assistantContent = $"{llmResult.Content}\n\n{hintContext}\n{proposalResult.ErrorMessage}";
253+
messageType = "parse-hint";
254+
}
246255
else if (llmResult.IsActionable)
247256
{
248257
// Planner could not parse an auto-detected actionable message — hint the user

backend/src/Taskdeck.Application/Services/ColumnService.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Taskdeck.Application.Interfaces;
33
using Taskdeck.Domain.Common;
44
using Taskdeck.Domain.Entities;
5+
using Taskdeck.Domain.Enums;
56
using Taskdeck.Domain.Exceptions;
67

78
namespace Taskdeck.Application.Services;
@@ -10,16 +11,25 @@ public class ColumnService
1011
{
1112
private readonly IUnitOfWork _unitOfWork;
1213
private readonly IBoardRealtimeNotifier _realtimeNotifier;
14+
private readonly IHistoryService? _historyService;
1315

1416
public ColumnService(IUnitOfWork unitOfWork)
15-
: this(unitOfWork, realtimeNotifier: null)
17+
: this(unitOfWork, realtimeNotifier: null, historyService: null)
1618
{
1719
}
1820

19-
public ColumnService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null)
21+
public ColumnService(IUnitOfWork unitOfWork, IBoardRealtimeNotifier? realtimeNotifier = null, IHistoryService? historyService = null)
2022
{
2123
_unitOfWork = unitOfWork;
2224
_realtimeNotifier = realtimeNotifier ?? NoOpBoardRealtimeNotifier.Instance;
25+
_historyService = historyService;
26+
}
27+
28+
private async Task SafeLogAsync(string entityType, Guid entityId, AuditAction action, Guid? userId = null, string? changes = null)
29+
{
30+
if (_historyService == null) return;
31+
try { await _historyService.LogActionAsync(entityType, entityId, action, userId, changes); }
32+
catch (Exception) { /* Audit is secondary — never crash the mutation */ }
2333
}
2434

2535
public async Task<Result<ColumnDto>> CreateColumnAsync(CreateColumnDto dto, CancellationToken cancellationToken = default)
@@ -45,6 +55,7 @@ public async Task<Result<ColumnDto>> CreateColumnAsync(CreateColumnDto dto, Canc
4555
await _realtimeNotifier.NotifyBoardMutationAsync(
4656
new BoardRealtimeEvent(column.BoardId, "column", "created", column.Id, DateTimeOffset.UtcNow),
4757
cancellationToken);
58+
await SafeLogAsync("column", column.Id, AuditAction.Created, changes: $"name={column.Name}");
4859

4960
return Result.Success(MapToDto(column));
5061
}
@@ -67,6 +78,7 @@ public async Task<Result<ColumnDto>> UpdateColumnAsync(Guid id, UpdateColumnDto
6778
await _realtimeNotifier.NotifyBoardMutationAsync(
6879
new BoardRealtimeEvent(column.BoardId, "column", "updated", column.Id, DateTimeOffset.UtcNow),
6980
cancellationToken);
81+
await SafeLogAsync("column", column.Id, AuditAction.Updated);
7082

7183
return Result.Success(MapToDto(column));
7284
}
@@ -105,6 +117,7 @@ public async Task<Result> DeleteColumnAsync(Guid id, CancellationToken cancellat
105117
await _realtimeNotifier.NotifyBoardMutationAsync(
106118
new BoardRealtimeEvent(column.BoardId, "column", "deleted", column.Id, DateTimeOffset.UtcNow),
107119
cancellationToken);
120+
await SafeLogAsync("column", column.Id, AuditAction.Deleted, changes: $"name={column.Name}");
108121

109122
return Result.Success();
110123
}
@@ -166,6 +179,7 @@ public async Task<Result<IEnumerable<ColumnDto>>> ReorderColumnsAsync(Guid board
166179
await _realtimeNotifier.NotifyBoardMutationAsync(
167180
new BoardRealtimeEvent(boardId, "column", "reordered", null, DateTimeOffset.UtcNow),
168181
cancellationToken);
182+
await SafeLogAsync("column", boardId, AuditAction.Updated, changes: $"reordered; count={dto.ColumnIds.Count}");
169183

170184
// Return reordered columns
171185
var reorderedColumns = dto.ColumnIds.Select(id => MapToDto(columnDict[id]));

backend/src/Taskdeck.Application/Services/HistoryService.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ public async Task<Result> LogActionAsync(string entityType, Guid entityId, Audit
7070
{
7171
return Result.Failure(ex.ErrorCode, ex.Message);
7272
}
73+
catch (Exception)
74+
{
75+
// Audit logging is secondary to the mutation — never let infrastructure
76+
// failures (e.g. DB full, concurrency) crash the calling operation.
77+
return Result.Failure(ErrorCodes.UnexpectedError, $"Failed to persist audit log for {entityType}/{entityId}/{action}");
78+
}
7379
}
7480

7581
private static AuditLogDto MapToDto(AuditLog log)

backend/src/Taskdeck.Application/Services/IHistoryService.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ namespace Taskdeck.Application.Services;
66

77
/// <summary>
88
/// Service interface for audit log and history operations.
9-
/// SCAFFOLDING: Implementation pending.
109
/// </summary>
1110
public interface IHistoryService
1211
{

0 commit comments

Comments
 (0)