diff --git a/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs b/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs index 60b03869a..bf7d412e7 100644 --- a/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs +++ b/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs @@ -370,8 +370,8 @@ public async Task> ParseInstructionAsync( } if (!operations.Any()) - return Result.Failure(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(ErrorCodes.ValidationError, + BuildParseHintMessage(instruction)); // Classify risk var operationDtos = operations.Select(o => new ProposalOperationDto( @@ -420,6 +420,110 @@ public async Task> 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) diff --git a/backend/src/Taskdeck.Application/Services/ChatService.cs b/backend/src/Taskdeck.Application/Services/ChatService.cs index 9b2a26a75..c3dd50bc2 100644 --- a/backend/src/Taskdeck.Application/Services/ChatService.cs +++ b/backend/src/Taskdeck.Application/Services/ChatService.cs @@ -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 diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs index fff4f2d65..778b210b0 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs @@ -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] @@ -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."); } /// @@ -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( + 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( + jsonPart, + new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + + hint!.DetectedIntent.Should().BeNull(); + } + + #endregion } diff --git a/frontend/taskdeck-web/src/tests/utils/chat.spec.ts b/frontend/taskdeck-web/src/tests/utils/chat.spec.ts new file mode 100644 index 000000000..c9d044ff8 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/utils/chat.spec.ts @@ -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') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/views/AutomationChatView.spec.ts b/frontend/taskdeck-web/src/tests/views/AutomationChatView.spec.ts index b0af6af71..86b9c44e8 100644 --- a/frontend/taskdeck-web/src/tests/views/AutomationChatView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/AutomationChatView.spec.ts @@ -556,6 +556,118 @@ describe('AutomationChatView', () => { expect(mocks.errorToast).toHaveBeenCalledWith('network down') }) + it('renders a hint card for parse-hint messages with try-this-instead button', async () => { + const now = new Date().toISOString() + const hintPayload = JSON.stringify({ + supportedPatterns: [ + 'create card "title"', + 'move card {id} to column "name"', + 'archive card {id}', + ], + exampleInstruction: 'create card "My new task"', + closestPattern: 'create card "title"', + detectedIntent: 'create', + }) + const hintSession = { + id: 'session-hint', + userId: 'user-1', + boardId: 'board-1', + title: 'Hint test', + status: 'Active', + createdAt: now, + updatedAt: now, + recentMessages: [ + { + id: 'msg-hint', + sessionId: 'session-hint', + role: 'Assistant', + content: `Some LLM response\n\nCould not parse.\nCould not parse instruction into a proposal.[PARSE_HINT]${hintPayload}`, + messageType: 'parse-hint' as const, + proposalId: null, + tokenUsage: 10, + createdAt: now, + }, + ], + } + mocks.getMySessions.mockResolvedValue([hintSession]) + mocks.getSession.mockResolvedValue(hintSession) + + const wrapper = mountView() + await waitForAsyncUi() + + // Should show hint card + expect(wrapper.find('.td-hint-card').exists()).toBe(true) + expect(wrapper.text()).toContain('Detected intent: create') + expect(wrapper.text()).toContain('create card "title"') + + // Should have "Try this instead" button + const tryBtn = wrapper.findAll('button').find((b) => b.text().includes('Try this instead')) + expect(tryBtn).toBeTruthy() + + // Click should pre-fill the message input + await tryBtn!.trigger('click') + const textarea = wrapper.find('textarea') + expect(textarea.element.value).toBe('create card "My new task"') + }) + + it('toggles the supported patterns list in a hint card', async () => { + const now = new Date().toISOString() + const hintPayload = JSON.stringify({ + supportedPatterns: ['create card "title"', 'archive card {id}'], + exampleInstruction: 'create card "My task"', + closestPattern: 'create card "title"', + detectedIntent: null, + }) + const hintSession = { + id: 'session-hint2', + userId: 'user-1', + boardId: 'board-1', + title: 'Toggle test', + status: 'Active', + createdAt: now, + updatedAt: now, + recentMessages: [ + { + id: 'msg-hint2', + sessionId: 'session-hint2', + role: 'Assistant', + content: `Response\nCould not parse instruction into a proposal.[PARSE_HINT]${hintPayload}`, + messageType: 'parse-hint' as const, + proposalId: null, + tokenUsage: 10, + createdAt: now, + }, + ], + } + mocks.getMySessions.mockResolvedValue([hintSession]) + mocks.getSession.mockResolvedValue(hintSession) + + const wrapper = mountView() + await waitForAsyncUi() + + // Patterns list should be hidden initially + expect(wrapper.find('.td-hint-card__patterns').exists()).toBe(false) + expect(wrapper.text()).toContain('Could not detect intent') + + // Click "Show all patterns" + const showBtn = wrapper.findAll('button').find((b) => b.text().includes('Show all patterns')) + expect(showBtn).toBeTruthy() + await showBtn!.trigger('click') + await waitForAsyncUi() + + // Patterns should now be visible + expect(wrapper.find('.td-hint-card__patterns').exists()).toBe(true) + expect(wrapper.text()).toContain('archive card {id}') + + // Click "Hide all patterns" + const hideBtn = wrapper.findAll('button').find((b) => b.text().includes('Hide all patterns')) + expect(hideBtn).toBeTruthy() + await hideBtn!.trigger('click') + await waitForAsyncUi() + + expect(wrapper.find('.td-hint-card__patterns').exists()).toBe(false) + }) + it('surfaces provider-health loading failures explicitly', async () => { mocks.getHealth.mockRejectedValueOnce(new Error('health down')) diff --git a/frontend/taskdeck-web/src/types/chat.ts b/frontend/taskdeck-web/src/types/chat.ts index 16e904b93..3b8974b96 100644 --- a/frontend/taskdeck-web/src/types/chat.ts +++ b/frontend/taskdeck-web/src/types/chat.ts @@ -2,7 +2,14 @@ export type ChatSessionStatus = 'Active' | 'Archived' export type ChatSessionStatusValue = ChatSessionStatus | number export type ChatRole = 'User' | 'Assistant' | 'System' export type ChatRoleValue = ChatRole | number -export type ChatMessageType = 'text' | 'proposal-reference' | 'error' | 'status' | 'degraded' +export type ChatMessageType = 'text' | 'proposal-reference' | 'error' | 'status' | 'degraded' | 'parse-hint' + +export interface ParseHintPayload { + supportedPatterns: string[] + exampleInstruction: string + closestPattern: string + detectedIntent: string | null +} export interface ChatMessage { id: string diff --git a/frontend/taskdeck-web/src/utils/chat.ts b/frontend/taskdeck-web/src/utils/chat.ts index 3f02feedb..e681ebf7b 100644 --- a/frontend/taskdeck-web/src/utils/chat.ts +++ b/frontend/taskdeck-web/src/utils/chat.ts @@ -1,4 +1,4 @@ -import type { ChatRoleValue, ChatSessionStatusValue } from '../types/chat' +import type { ChatRoleValue, ChatSessionStatusValue, ParseHintPayload } from '../types/chat' const chatRoleByIndex = ['User', 'Assistant', 'System'] as const const chatSessionStatusByIndex = ['Active', 'Archived'] as const @@ -12,6 +12,34 @@ export function normalizeChatRole(value: ChatRoleValue): typeof chatRoleByIndex[ return found ?? 'User' } +const PARSE_HINT_MARKER = '[PARSE_HINT]' + +export interface ParsedHintMessage { + textBeforeHint: string + hint: ParseHintPayload +} + +export function extractParseHint(content: string): ParsedHintMessage | null { + const markerIndex = content.lastIndexOf(PARSE_HINT_MARKER) + if (markerIndex === -1) { + return null + } + + const jsonStart = markerIndex + PARSE_HINT_MARKER.length + const jsonStr = content.substring(jsonStart) + const textBeforeHint = content.substring(0, markerIndex).trimEnd() + + try { + const hint = JSON.parse(jsonStr) as ParseHintPayload + if (!hint.supportedPatterns || !Array.isArray(hint.supportedPatterns)) { + return null + } + return { textBeforeHint, hint } + } catch { + return null + } +} + export function normalizeChatSessionStatus(value: ChatSessionStatusValue): typeof chatSessionStatusByIndex[number] { if (typeof value === 'number') { return chatSessionStatusByIndex[value] ?? 'Active' diff --git a/frontend/taskdeck-web/src/views/AutomationChatView.vue b/frontend/taskdeck-web/src/views/AutomationChatView.vue index 799565b7a..33cc09f54 100644 --- a/frontend/taskdeck-web/src/views/AutomationChatView.vue +++ b/frontend/taskdeck-web/src/views/AutomationChatView.vue @@ -8,7 +8,8 @@ import { boardsApi } from '../api/boardsApi' import { useToastStore } from '../store/toastStore' import type { ChatProviderHealth, ChatMessage, ChatSession } from '../types/chat' import type { Board } from '../types/board' -import { normalizeChatRole } from '../utils/chat' +import { normalizeChatRole, extractParseHint } from '../utils/chat' +import type { ParsedHintMessage } from '../utils/chat' import { getErrorDisplay } from '../composables/useErrorMapper' import InputAssistField from '../components/common/InputAssistField.vue' import { buildInputAssistOptions } from '../utils/inputAssist' @@ -48,6 +49,7 @@ const newSessionBoardId = ref('') const selectedNewSessionBoardId = ref(null) const messageContent = ref('') const requestProposal = ref(false) +const expandedHintIds = ref>(new Set()) const boardOptions = computed(() => buildInputAssistOptions( @@ -81,6 +83,19 @@ const sortedMessages = computed(() => { )) }) +const parseHintsByMessageId = computed(() => { + const map = new Map() + for (const message of sortedMessages.value) { + if (message.messageType === 'parse-hint') { + const hint = extractParseHint(message.content) + if (hint) { + map.set(message.id, hint) + } + } + } + return map +}) + const selectedSessionBoardName = computed(() => { const boardId = selectedSession.value?.boardId?.trim() if (!boardId) { @@ -412,6 +427,25 @@ function pushToReview(hash?: string) { }) } +function getParseHint(message: ChatMessage): ParsedHintMessage | null { + return parseHintsByMessageId.value.get(message.id) ?? null +} + +function toggleHintPatterns(messageId: string) { + const updated = new Set(expandedHintIds.value) + if (updated.has(messageId)) { + updated.delete(messageId) + } else { + updated.add(messageId) + } + expandedHintIds.value = updated +} + +function applyHintSuggestion(example: string) { + messageContent.value = example + requestProposal.value = true +} + function openReviewRoute() { pushToReview() } @@ -577,12 +611,62 @@ watch(
Degraded response{{ message.degradedReason ? `: ${message.degradedReason}` : '' }}
-
-
{{ message.content }}
+ +
Proposal: {{ message.proposalId }}