Skip to content

Commit ecc7ef7

Browse files
authored
Merge pull request #582 from Chris0Jeky/enhance/572-chat-error-ux
Improve error UX when chat proposal parsing fails
2 parents 5e064d4 + 37f3f49 commit ecc7ef7

File tree

8 files changed

+597
-13
lines changed

8 files changed

+597
-13
lines changed

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/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/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,7 @@ public async Task ParseInstruction_ShouldReturnFailure_ForUnrecognizedPattern()
714714
// Assert
715715
result.IsSuccess.Should().BeFalse();
716716
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
717-
result.ErrorMessage.Should().Contain("Could not parse instruction");
717+
result.ErrorMessage.Should().Contain("Could not parse instruction into a proposal.");
718718
}
719719

720720
[Fact]
@@ -790,7 +790,7 @@ public async Task ParseInstruction_NaturalLanguage_ShouldFailWithParseError(stri
790790

791791
result.IsSuccess.Should().BeFalse();
792792
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
793-
result.ErrorMessage.Should().Contain("Could not parse instruction");
793+
result.ErrorMessage.Should().Contain("Could not parse instruction into a proposal.");
794794
}
795795

796796
/// <summary>
@@ -832,4 +832,101 @@ public async Task ParseInstruction_StructuredSyntax_ShouldSucceedOrProgressPastP
832832
}
833833

834834
#endregion
835+
836+
#region Parse Hint Tests
837+
838+
[Fact]
839+
public async Task ParseInstruction_ShouldReturnStructuredParseHint_ForUnrecognizedInstruction()
840+
{
841+
// Arrange
842+
var userId = Guid.NewGuid();
843+
var boardId = Guid.NewGuid();
844+
845+
// Act
846+
var result = await _service.ParseInstructionAsync("please do something nice", userId, boardId);
847+
848+
// Assert
849+
result.IsSuccess.Should().BeFalse();
850+
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
851+
result.ErrorMessage.Should().Contain(AutomationPlannerService.ParseHintMarker);
852+
result.ErrorMessage.Should().Contain("supportedPatterns");
853+
result.ErrorMessage.Should().Contain("exampleInstruction");
854+
result.ErrorMessage.Should().Contain("closestPattern");
855+
}
856+
857+
[Theory]
858+
[InlineData("add a new task for tomorrow", "create")]
859+
[InlineData("move this card somewhere", "move")]
860+
[InlineData("delete old stuff", "archive")]
861+
[InlineData("change the card name", "update")]
862+
[InlineData("restore the board", "unarchive")]
863+
[InlineData("unarchive my board", "unarchive")]
864+
[InlineData("rename board to Sprint 5", "update")]
865+
public void DetectIntent_ShouldIdentifyIntent_FromNaturalLanguage(string instruction, string expectedIntent)
866+
{
867+
// Act
868+
var intent = AutomationPlannerService.DetectIntent(instruction);
869+
870+
// Assert
871+
intent.Should().Be(expectedIntent);
872+
}
873+
874+
[Fact]
875+
public void DetectIntent_ShouldReturnNull_WhenNoIntentDetected()
876+
{
877+
var intent = AutomationPlannerService.DetectIntent("hello world");
878+
intent.Should().BeNull();
879+
}
880+
881+
[Theory]
882+
[InlineData("create", "create card")]
883+
[InlineData("move", "move card")]
884+
[InlineData("archive", "archive card")]
885+
[InlineData("update", "update card")]
886+
public void FindClosestPattern_ShouldReturnRelevantPattern_ForDetectedIntent(string intent, string expectedPatternPrefix)
887+
{
888+
// Act
889+
var (pattern, _) = AutomationPlannerService.FindClosestPattern("some instruction text", intent);
890+
891+
// Assert
892+
pattern.Should().StartWith(expectedPatternPrefix);
893+
}
894+
895+
[Fact]
896+
public void BuildParseHintMessage_ShouldContainMarkerAndValidJson()
897+
{
898+
// Act
899+
var message = AutomationPlannerService.BuildParseHintMessage("create something");
900+
901+
// Assert
902+
message.Should().Contain(AutomationPlannerService.ParseHintMarker);
903+
var markerIndex = message.IndexOf(AutomationPlannerService.ParseHintMarker);
904+
var jsonPart = message.Substring(markerIndex + AutomationPlannerService.ParseHintMarker.Length);
905+
906+
var hint = System.Text.Json.JsonSerializer.Deserialize<AutomationPlannerService.ParseHintPayload>(
907+
jsonPart,
908+
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
909+
910+
hint.Should().NotBeNull();
911+
hint!.SupportedPatterns.Should().NotBeEmpty();
912+
hint.ExampleInstruction.Should().NotBeNullOrWhiteSpace();
913+
hint.ClosestPattern.Should().NotBeNullOrWhiteSpace();
914+
hint.DetectedIntent.Should().Be("create");
915+
}
916+
917+
[Fact]
918+
public void BuildParseHintMessage_ShouldHaveNullIntent_WhenNoIntentDetected()
919+
{
920+
var message = AutomationPlannerService.BuildParseHintMessage("hello world");
921+
var markerIndex = message.IndexOf(AutomationPlannerService.ParseHintMarker);
922+
var jsonPart = message.Substring(markerIndex + AutomationPlannerService.ParseHintMarker.Length);
923+
924+
var hint = System.Text.Json.JsonSerializer.Deserialize<AutomationPlannerService.ParseHintPayload>(
925+
jsonPart,
926+
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
927+
928+
hint!.DetectedIntent.Should().BeNull();
929+
}
930+
931+
#endregion
835932
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { extractParseHint } from '../../utils/chat'
3+
4+
describe('extractParseHint', () => {
5+
it('returns null when content has no parse hint marker', () => {
6+
expect(extractParseHint('Just a normal message')).toBeNull()
7+
})
8+
9+
it('extracts hint payload from content with valid marker and JSON', () => {
10+
const payload = {
11+
supportedPatterns: ['create card "title"', 'archive card {id}'],
12+
exampleInstruction: 'create card "My task"',
13+
closestPattern: 'create card "title"',
14+
detectedIntent: 'create',
15+
}
16+
const content = `Some context text.\nCould not parse instruction into a proposal.[PARSE_HINT]${JSON.stringify(payload)}`
17+
18+
const result = extractParseHint(content)
19+
20+
expect(result).not.toBeNull()
21+
expect(result!.textBeforeHint).toBe('Some context text.\nCould not parse instruction into a proposal.')
22+
expect(result!.hint.supportedPatterns).toEqual(payload.supportedPatterns)
23+
expect(result!.hint.exampleInstruction).toBe(payload.exampleInstruction)
24+
expect(result!.hint.closestPattern).toBe(payload.closestPattern)
25+
expect(result!.hint.detectedIntent).toBe('create')
26+
})
27+
28+
it('handles null detectedIntent', () => {
29+
const payload = {
30+
supportedPatterns: ['create card "title"'],
31+
exampleInstruction: 'create card "My task"',
32+
closestPattern: 'create card "title"',
33+
detectedIntent: null,
34+
}
35+
const content = `Text[PARSE_HINT]${JSON.stringify(payload)}`
36+
37+
const result = extractParseHint(content)
38+
39+
expect(result).not.toBeNull()
40+
expect(result!.hint.detectedIntent).toBeNull()
41+
})
42+
43+
it('returns null when JSON after marker is invalid', () => {
44+
const content = 'Text[PARSE_HINT]{invalid json'
45+
expect(extractParseHint(content)).toBeNull()
46+
})
47+
48+
it('returns null when JSON is valid but missing supportedPatterns array', () => {
49+
const content = 'Text[PARSE_HINT]{"exampleInstruction":"test"}'
50+
expect(extractParseHint(content)).toBeNull()
51+
})
52+
53+
it('trims trailing whitespace from text before hint', () => {
54+
const payload = {
55+
supportedPatterns: ['create card "title"'],
56+
exampleInstruction: 'create card "test"',
57+
closestPattern: 'create card "title"',
58+
detectedIntent: null,
59+
}
60+
const content = `Some text \n [PARSE_HINT]${JSON.stringify(payload)}`
61+
62+
const result = extractParseHint(content)
63+
64+
expect(result).not.toBeNull()
65+
expect(result!.textBeforeHint).toBe('Some text')
66+
})
67+
})

0 commit comments

Comments
 (0)