Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 106 additions & 2 deletions backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,8 @@ public async Task<Result<ProposalDto>> ParseInstructionAsync(
}

if (!operations.Any())
return Result.Failure<ProposalDto>(ErrorCodes.ValidationError,
"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}'");
return Result.Failure<ProposalDto>(ErrorCodes.ValidationError,
BuildParseHintMessage(instruction));

// Classify risk
var operationDtos = operations.Select(o => new ProposalOperationDto(
Expand Down Expand Up @@ -420,6 +420,110 @@ public async Task<Result<ProposalDto>> ParseInstructionAsync(
}
}

internal static readonly string ParseHintMarker = "[PARSE_HINT]";

internal static readonly (string Pattern, string Example, string[] Keywords)[] SupportedPatterns = new[]
{
("create card \"title\"", "create card \"My new task\"", new[] { "create", "add", "new", "card", "task" }),
("create card \"title\" in column \"name\"", "create card \"Bug fix\" in column \"In Progress\"", new[] { "create", "add", "new", "card", "column", "in" }),
("move card {id} to column \"name\"", "move card abc-123 to column \"Done\"", new[] { "move", "card", "column", "to" }),
("archive card {id}", "archive card abc-123", new[] { "archive", "card", "remove", "delete" }),
("archive cards matching \"pattern\"", "archive cards matching \"old tasks\"", new[] { "archive", "cards", "matching", "bulk", "batch" }),
("update card {id} title \"value\"", "update card abc-123 title \"New title\"", new[] { "update", "edit", "change", "card", "title", "rename" }),
("update card {id} description \"value\"", "update card abc-123 description \"Updated details\"", new[] { "update", "edit", "change", "card", "description", "desc" }),
("rename board to \"name\"", "rename board to \"Sprint 5\"", new[] { "rename", "board", "name", "title" }),
("update board description \"value\"", "update board description \"Team workspace\"", new[] { "update", "board", "description", "desc" }),
("archive board", "archive board", new[] { "archive", "board" }),
("unarchive board", "unarchive board", new[] { "unarchive", "restore", "board" }),
("move column \"name\" to position {n}", "move column \"Done\" to position 0", new[] { "move", "column", "position", "reorder" }),
};

internal static string BuildParseHintMessage(string instruction)
{
var detectedIntent = DetectIntent(instruction);
var bestMatch = FindClosestPattern(instruction, detectedIntent);

var patterns = SupportedPatterns.Select(p => p.Pattern).ToArray();
var hint = new ParseHintPayload(
patterns,
bestMatch.Example,
bestMatch.Pattern,
detectedIntent);

var hintJson = JsonSerializer.Serialize(hint, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});

return $"Could not parse instruction into a proposal.{ParseHintMarker}{hintJson}";
}

internal static string? DetectIntent(string instruction)
{
var lower = instruction.Trim().ToLowerInvariant();
var words = lower.Split(' ', StringSplitOptions.RemoveEmptyEntries);

// Check more-specific intents before their substrings (e.g. "unarchive" before "archive",
// "rename" before "new"). Use word-level matching to avoid substring false positives
// like "sunset" matching "set" or "address" matching "add".
bool hasWord(string word) => words.Any(w => w == word);

if (lower.Contains("unarchive") || hasWord("restore"))
return "unarchive";
if (lower.Contains("rename") || hasWord("edit") || hasWord("change") || hasWord("update"))
return "update";
if (hasWord("reorder") || hasWord("position"))
return "reorder";
if (hasWord("create") || hasWord("add") || hasWord("new"))
return "create";
if (hasWord("move") || hasWord("drag") || hasWord("transfer"))
return "move";
if (hasWord("archive") || hasWord("remove") || hasWord("delete"))
return "archive";

return null;
}

internal static (string Pattern, string Example) FindClosestPattern(string instruction, string? detectedIntent)
{
var lower = instruction.Trim().ToLowerInvariant();
var words = lower.Split(' ', StringSplitOptions.RemoveEmptyEntries);

var bestScore = -1;
var bestPattern = SupportedPatterns[0];

foreach (var entry in SupportedPatterns)
{
var score = 0;

// Boost patterns whose keywords match whole words in the instruction
foreach (var keyword in entry.Keywords)
{
if (words.Any(w => w == keyword))
score += 2;
}

// Extra boost if the detected intent matches the first keyword
if (detectedIntent != null && entry.Keywords.Length > 0 &&
entry.Keywords[0].Equals(detectedIntent, StringComparison.OrdinalIgnoreCase))
score += 5;

if (score > bestScore)
{
bestScore = score;
bestPattern = entry;
}
}

return (bestPattern.Pattern, bestPattern.Example);
}

internal record ParseHintPayload(
string[] SupportedPatterns,
string ExampleInstruction,
string ClosestPattern,
string? DetectedIntent);

private static bool TryResolveCorrelationId(string? correlationId, out string resolvedCorrelationId, out string error)
{
if (correlationId == null)
Expand Down
9 changes: 9 additions & 0 deletions backend/src/Taskdeck.Application/Services/ChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,15 @@ await _quotaService.RecordUsageAsync(
proposalId = proposalResult.Value.Id;
assistantContent = $"{llmResult.Content}\n\nProposal created for review: {proposalResult.Value.Id}";
}
else if (proposalResult.ErrorMessage?.Contains(AutomationPlannerService.ParseHintMarker) == true)
{
// Structured parse hint — use parse-hint message type so frontend can render a hint card
var hintContext = llmResult.IsActionable
? "I detected a task request but could not parse it into a proposal."
: "Could not create the requested proposal.";
assistantContent = $"{llmResult.Content}\n\n{hintContext}\n{proposalResult.ErrorMessage}";
messageType = "parse-hint";
}
else if (llmResult.IsActionable)
{
// Planner could not parse an auto-detected actionable message — hint the user
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ public async Task ParseInstruction_ShouldReturnFailure_ForUnrecognizedPattern()
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
result.ErrorMessage.Should().Contain("Could not parse instruction");
result.ErrorMessage.Should().Contain("Could not parse instruction into a proposal.");
}

[Fact]
Expand Down Expand Up @@ -790,7 +790,7 @@ public async Task ParseInstruction_NaturalLanguage_ShouldFailWithParseError(stri

result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
result.ErrorMessage.Should().Contain("Could not parse instruction");
result.ErrorMessage.Should().Contain("Could not parse instruction into a proposal.");
}

/// <summary>
Expand Down Expand Up @@ -832,4 +832,101 @@ public async Task ParseInstruction_StructuredSyntax_ShouldSucceedOrProgressPastP
}

#endregion

#region Parse Hint Tests

[Fact]
public async Task ParseInstruction_ShouldReturnStructuredParseHint_ForUnrecognizedInstruction()
{
// Arrange
var userId = Guid.NewGuid();
var boardId = Guid.NewGuid();

// Act
var result = await _service.ParseInstructionAsync("please do something nice", userId, boardId);

// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
result.ErrorMessage.Should().Contain(AutomationPlannerService.ParseHintMarker);
result.ErrorMessage.Should().Contain("supportedPatterns");
result.ErrorMessage.Should().Contain("exampleInstruction");
result.ErrorMessage.Should().Contain("closestPattern");
}

[Theory]
[InlineData("add a new task for tomorrow", "create")]
[InlineData("move this card somewhere", "move")]
[InlineData("delete old stuff", "archive")]
[InlineData("change the card name", "update")]
[InlineData("restore the board", "unarchive")]
[InlineData("unarchive my board", "unarchive")]
[InlineData("rename board to Sprint 5", "update")]
public void DetectIntent_ShouldIdentifyIntent_FromNaturalLanguage(string instruction, string expectedIntent)
{
// Act
var intent = AutomationPlannerService.DetectIntent(instruction);

// Assert
intent.Should().Be(expectedIntent);
}

[Fact]
public void DetectIntent_ShouldReturnNull_WhenNoIntentDetected()
{
var intent = AutomationPlannerService.DetectIntent("hello world");
intent.Should().BeNull();
}

[Theory]
[InlineData("create", "create card")]
[InlineData("move", "move card")]
[InlineData("archive", "archive card")]
[InlineData("update", "update card")]
public void FindClosestPattern_ShouldReturnRelevantPattern_ForDetectedIntent(string intent, string expectedPatternPrefix)
{
// Act
var (pattern, _) = AutomationPlannerService.FindClosestPattern("some instruction text", intent);

// Assert
pattern.Should().StartWith(expectedPatternPrefix);
}

[Fact]
public void BuildParseHintMessage_ShouldContainMarkerAndValidJson()
{
// Act
var message = AutomationPlannerService.BuildParseHintMessage("create something");

// Assert
message.Should().Contain(AutomationPlannerService.ParseHintMarker);
var markerIndex = message.IndexOf(AutomationPlannerService.ParseHintMarker);
var jsonPart = message.Substring(markerIndex + AutomationPlannerService.ParseHintMarker.Length);

var hint = System.Text.Json.JsonSerializer.Deserialize<AutomationPlannerService.ParseHintPayload>(
jsonPart,
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });

hint.Should().NotBeNull();
hint!.SupportedPatterns.Should().NotBeEmpty();
hint.ExampleInstruction.Should().NotBeNullOrWhiteSpace();
hint.ClosestPattern.Should().NotBeNullOrWhiteSpace();
hint.DetectedIntent.Should().Be("create");
}

[Fact]
public void BuildParseHintMessage_ShouldHaveNullIntent_WhenNoIntentDetected()
{
var message = AutomationPlannerService.BuildParseHintMessage("hello world");
var markerIndex = message.IndexOf(AutomationPlannerService.ParseHintMarker);
var jsonPart = message.Substring(markerIndex + AutomationPlannerService.ParseHintMarker.Length);

var hint = System.Text.Json.JsonSerializer.Deserialize<AutomationPlannerService.ParseHintPayload>(
jsonPart,
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });

hint!.DetectedIntent.Should().BeNull();
}

#endregion
}
67 changes: 67 additions & 0 deletions frontend/taskdeck-web/src/tests/utils/chat.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { extractParseHint } from '../../utils/chat'

describe('extractParseHint', () => {
it('returns null when content has no parse hint marker', () => {
expect(extractParseHint('Just a normal message')).toBeNull()
})

it('extracts hint payload from content with valid marker and JSON', () => {
const payload = {
supportedPatterns: ['create card "title"', 'archive card {id}'],
exampleInstruction: 'create card "My task"',
closestPattern: 'create card "title"',
detectedIntent: 'create',
}
const content = `Some context text.\nCould not parse instruction into a proposal.[PARSE_HINT]${JSON.stringify(payload)}`

const result = extractParseHint(content)

expect(result).not.toBeNull()
expect(result!.textBeforeHint).toBe('Some context text.\nCould not parse instruction into a proposal.')
expect(result!.hint.supportedPatterns).toEqual(payload.supportedPatterns)
expect(result!.hint.exampleInstruction).toBe(payload.exampleInstruction)
expect(result!.hint.closestPattern).toBe(payload.closestPattern)
expect(result!.hint.detectedIntent).toBe('create')
})

it('handles null detectedIntent', () => {
const payload = {
supportedPatterns: ['create card "title"'],
exampleInstruction: 'create card "My task"',
closestPattern: 'create card "title"',
detectedIntent: null,
}
const content = `Text[PARSE_HINT]${JSON.stringify(payload)}`

const result = extractParseHint(content)

expect(result).not.toBeNull()
expect(result!.hint.detectedIntent).toBeNull()
})

it('returns null when JSON after marker is invalid', () => {
const content = 'Text[PARSE_HINT]{invalid json'
expect(extractParseHint(content)).toBeNull()
})

it('returns null when JSON is valid but missing supportedPatterns array', () => {
const content = 'Text[PARSE_HINT]{"exampleInstruction":"test"}'
expect(extractParseHint(content)).toBeNull()
})

it('trims trailing whitespace from text before hint', () => {
const payload = {
supportedPatterns: ['create card "title"'],
exampleInstruction: 'create card "test"',
closestPattern: 'create card "title"',
detectedIntent: null,
}
const content = `Some text \n [PARSE_HINT]${JSON.stringify(payload)}`

const result = extractParseHint(content)

expect(result).not.toBeNull()
expect(result!.textBeforeHint).toBe('Some text')
})
})
Loading
Loading