diff --git a/backend/src/Taskdeck.Application/Services/GeminiLlmProvider.cs b/backend/src/Taskdeck.Application/Services/GeminiLlmProvider.cs index 652630d31..bf5d194c0 100644 --- a/backend/src/Taskdeck.Application/Services/GeminiLlmProvider.cs +++ b/backend/src/Taskdeck.Application/Services/GeminiLlmProvider.cs @@ -104,7 +104,14 @@ public async Task CompleteAsync(ChatCompletionRequest reque // Fallback to static classifier when structured parse fails _logger.LogDebug("Gemini response was not structured JSON; falling back to static classifier."); var (isActionable, actionIntent) = LlmIntentClassifier.Classify(lastUserMessage); - return new LlmCompletionResult(content, tokensUsed, isActionable, actionIntent, "Gemini", GetConfiguredModelOrDefault()); + List? fallbackInstructions = null; + if (isActionable) + { + var extracted = NaturalLanguageInstructionExtractor.Extract(lastUserMessage, actionIntent); + if (extracted.Count > 0) + fallbackInstructions = extracted; + } + return new LlmCompletionResult(content, tokensUsed, isActionable, actionIntent, "Gemini", GetConfiguredModelOrDefault(), Instructions: fallbackInstructions); } catch (OperationCanceledException) { @@ -287,6 +294,17 @@ private static bool TryParseResponse(string responseBody, out string content, ou private static LlmCompletionResult BuildFallbackResult(string userMessage, string reason, string model) { var (isActionable, actionIntent) = LlmIntentClassifier.Classify(userMessage); + + // When falling back to the static classifier, also extract structured + // instructions so the parser can handle natural language input. + List? instructions = null; + if (isActionable) + { + var extracted = NaturalLanguageInstructionExtractor.Extract(userMessage, actionIntent); + if (extracted.Count > 0) + instructions = extracted; + } + var content = isActionable ? $"I can help with that. I'll create a proposal to {actionIntent}. ({reason})" : $"I can help with that request. ({reason})"; @@ -299,7 +317,8 @@ private static LlmCompletionResult BuildFallbackResult(string userMessage, strin Provider: "Gemini", Model: string.IsNullOrWhiteSpace(model) ? "gemini-unknown-model" : model.Trim(), IsDegraded: true, - DegradedReason: reason); + DegradedReason: reason, + Instructions: instructions); } private static int EstimateTokens(string text) diff --git a/backend/src/Taskdeck.Application/Services/MockLlmProvider.cs b/backend/src/Taskdeck.Application/Services/MockLlmProvider.cs index 98be99b14..72da299b9 100644 --- a/backend/src/Taskdeck.Application/Services/MockLlmProvider.cs +++ b/backend/src/Taskdeck.Application/Services/MockLlmProvider.cs @@ -12,6 +12,19 @@ public Task CompleteAsync(ChatCompletionRequest request, Ca var (isActionable, actionIntent) = LlmIntentClassifier.Classify(lastUserMessage); + // When actionable intent is detected, attempt to extract structured + // instructions from the natural language message. This bridges the gap + // between classification ("this is a card.create request") and parsing + // ("create card 'title'"). Without this, natural language like "create + // new onboarding tasks" would be passed raw to the regex parser and fail. + List? instructions = null; + if (isActionable) + { + var extracted = NaturalLanguageInstructionExtractor.Extract(lastUserMessage, actionIntent); + if (extracted.Count > 0) + instructions = extracted; + } + var content = isActionable ? $"I can help with that. I'll create a proposal to {actionIntent}." : $"Here's information about your request: {lastUserMessage.Trim()}"; @@ -22,7 +35,8 @@ public Task CompleteAsync(ChatCompletionRequest request, Ca IsActionable: isActionable, ActionIntent: actionIntent, Provider: "Mock", - Model: "mock-default" + Model: "mock-default", + Instructions: instructions )); } diff --git a/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs b/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs new file mode 100644 index 000000000..da24246e7 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs @@ -0,0 +1,220 @@ +using System.Text.RegularExpressions; + +namespace Taskdeck.Application.Services; + +/// +/// Bridges the gap between intent classification (which detects that a message is +/// actionable) and instruction parsing (which requires structured syntax). +/// +/// When the LLM classifier detects actionable intent but the raw message is natural +/// language, this extractor attempts to produce structured instructions that the +/// parser can consume. +/// +/// Used by MockLlmProvider and as a fallback for real providers when LLM-based +/// structured extraction fails. +/// +public static class NaturalLanguageInstructionExtractor +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(200); + + // Patterns to extract a quoted title from user input + private static readonly Regex QuotedTitlePattern = new( + @"['""]([^'""]+)['""]", + RegexOptions.Compiled, + RegexTimeout); + + // Patterns to extract a title phrase after creation verbs + // e.g., "create new onboarding tasks for non-technical people" -> "onboarding tasks for non-technical people" + // Greedy capture: takes everything after the verb + optional fillers to end of string, + // then we clean up trailing noise in CleanExtractedTitle. + private static readonly Regex CreateTitlePattern = new( + @"\b(?:create|add|make|generate|build|prepare|set\s+up)\b(?:\s+(?:a|an|new|some|the|my|few|several|three|two|four|five))?\s+(.+?)\s*[.!?]?\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase, + RegexTimeout); + + // Fallback: extract title after "new" keyword + // e.g., "I need three new cards for the sprint" -> "cards for the sprint" + private static readonly Regex NewTitlePattern = new( + @"\bnew\b\s+(.+?)\s*[.!?]?\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase, + RegexTimeout); + + // Pattern to detect card IDs in the message (for move/archive/update) + private static readonly Regex CardIdPattern = new( + @"\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase, + RegexTimeout); + + // Pattern to detect column references + private static readonly Regex ColumnNamePattern = new( + @"(?:to|into|in)\s+(?:column\s+)?['""]([^'""]+)['""]", + RegexOptions.Compiled | RegexOptions.IgnoreCase, + RegexTimeout); + + /// + /// Attempts to extract structured instructions from a natural language message + /// given a detected action intent. + /// + /// The raw user message. + /// The intent detected by . + /// A list of structured instructions in parser-compatible format, or empty if extraction fails. + public static List Extract(string message, string? actionIntent) + { + if (string.IsNullOrWhiteSpace(message) || string.IsNullOrWhiteSpace(actionIntent)) + return new List(); + + try + { + return actionIntent switch + { + "card.create" => ExtractCardCreateInstructions(message), + "card.move" => ExtractCardMoveInstructions(message), + "card.archive" => ExtractCardArchiveInstructions(message), + "card.update" => ExtractCardUpdateInstructions(message), + "board.create" => ExtractBoardCreateInstructions(message), + "board.update" => ExtractBoardRenameInstructions(message), + _ => new List() + }; + } + catch (RegexMatchTimeoutException) + { + return new List(); + } + } + + private static List ExtractCardCreateInstructions(string message) + { + // First, check for a quoted title — this is the most reliable signal + var quotedMatch = QuotedTitlePattern.Match(message); + if (quotedMatch.Success) + { + var title = quotedMatch.Groups[1].Value.Trim(); + if (!string.IsNullOrWhiteSpace(title)) + return new List { $"create card \"{title}\"" }; + } + + // Try to extract a meaningful title from the natural language + var createMatch = CreateTitlePattern.Match(message); + if (createMatch.Success) + { + var rawTitle = createMatch.Groups[1].Value.Trim(); + var title = CleanExtractedTitle(rawTitle); + if (!string.IsNullOrWhiteSpace(title)) + return new List { $"create card \"{title}\"" }; + } + + // Fallback: try the "new X" pattern + var newMatch = NewTitlePattern.Match(message); + if (newMatch.Success) + { + var rawTitle = newMatch.Groups[1].Value.Trim(); + var title = CleanExtractedTitle(rawTitle); + if (!string.IsNullOrWhiteSpace(title)) + return new List { $"create card \"{title}\"" }; + } + + return new List(); + } + + private static List ExtractCardMoveInstructions(string message) + { + var cardIdMatch = CardIdPattern.Match(message); + if (!cardIdMatch.Success) + return new List(); + + var cardId = cardIdMatch.Groups[1].Value; + + // Try quoted column name first + var columnMatch = ColumnNamePattern.Match(message); + if (columnMatch.Success) + { + var columnName = columnMatch.Groups[1].Value.Trim(); + return new List { $"move card {cardId} to column \"{columnName}\"" }; + } + + return new List(); + } + + private static List ExtractCardArchiveInstructions(string message) + { + var cardIdMatch = CardIdPattern.Match(message); + if (cardIdMatch.Success) + { + var cardId = cardIdMatch.Groups[1].Value; + return new List { $"archive card {cardId}" }; + } + + return new List(); + } + + private static List ExtractCardUpdateInstructions(string message) + { + var cardIdMatch = CardIdPattern.Match(message); + if (!cardIdMatch.Success) + return new List(); + + var cardId = cardIdMatch.Groups[1].Value; + + // Try to find a quoted value for the update + var quotedMatch = QuotedTitlePattern.Match(message); + if (quotedMatch.Success) + { + var value = quotedMatch.Groups[1].Value.Trim(); + // Determine if updating title or description + var lower = message.ToLowerInvariant(); + var field = lower.Contains("description") || lower.Contains("desc") ? "description" : "title"; + return new List { $"update card {cardId} {field} \"{value}\"" }; + } + + return new List(); + } + + private static List ExtractBoardCreateInstructions(string message) + { + // Board creation doesn't have a direct parser pattern in AutomationPlannerService, + // so we can't produce a structured instruction for it yet. + return new List(); + } + + private static List ExtractBoardRenameInstructions(string message) + { + var quotedMatch = QuotedTitlePattern.Match(message); + if (quotedMatch.Success) + { + var name = quotedMatch.Groups[1].Value.Trim(); + if (!string.IsNullOrWhiteSpace(name)) + return new List { $"rename board to \"{name}\"" }; + } + + return new List(); + } + + /// + /// Cleans an extracted title phrase by removing common filler words and + /// normalizing to title case. + /// + internal static string CleanExtractedTitle(string rawTitle) + { + if (string.IsNullOrWhiteSpace(rawTitle)) + return string.Empty; + + var title = rawTitle.Trim(); + + // Remove trailing noise words (common sentence-end patterns) + title = Regex.Replace(title, @"\s+(please|plz|pls|thanks|thx|asap)\s*$", "", RegexOptions.IgnoreCase); + + // Remove leading filler: "a ", "an ", "some ", "the " + title = Regex.Replace(title, @"^(a|an|some|the|my|our)\s+", "", RegexOptions.IgnoreCase); + + // Remove generic noun suffixes when they ARE the entire remaining text + // e.g., "cards" alone is not a useful title, but "onboarding cards" is + if (Regex.IsMatch(title, @"^(cards?|tasks?|items?)$", RegexOptions.IgnoreCase)) + return string.Empty; + + // Capitalize first letter + if (title.Length > 0) + title = char.ToUpperInvariant(title[0]) + title[1..]; + + return title; + } +} diff --git a/backend/src/Taskdeck.Application/Services/OpenAiLlmProvider.cs b/backend/src/Taskdeck.Application/Services/OpenAiLlmProvider.cs index 5220a044d..9e4b6839a 100644 --- a/backend/src/Taskdeck.Application/Services/OpenAiLlmProvider.cs +++ b/backend/src/Taskdeck.Application/Services/OpenAiLlmProvider.cs @@ -78,7 +78,14 @@ public async Task CompleteAsync(ChatCompletionRequest reque // Fallback to static classifier when structured parse fails _logger.LogDebug("OpenAI response was not structured JSON; falling back to static classifier."); var (isActionable, actionIntent) = LlmIntentClassifier.Classify(lastUserMessage); - return new LlmCompletionResult(content, tokensUsed, isActionable, actionIntent, "OpenAI", GetConfiguredModelOrDefault()); + List? fallbackInstructions = null; + if (isActionable) + { + var extracted = NaturalLanguageInstructionExtractor.Extract(lastUserMessage, actionIntent); + if (extracted.Count > 0) + fallbackInstructions = extracted; + } + return new LlmCompletionResult(content, tokensUsed, isActionable, actionIntent, "OpenAI", GetConfiguredModelOrDefault(), Instructions: fallbackInstructions); } catch (OperationCanceledException) { @@ -271,6 +278,17 @@ private static bool TryParseResponse(string responseBody, out string content, ou private static LlmCompletionResult BuildFallbackResult(string userMessage, string reason, string model) { var (isActionable, actionIntent) = LlmIntentClassifier.Classify(userMessage); + + // When falling back to the static classifier, also extract structured + // instructions so the parser can handle natural language input. + List? instructions = null; + if (isActionable) + { + var extracted = NaturalLanguageInstructionExtractor.Extract(userMessage, actionIntent); + if (extracted.Count > 0) + instructions = extracted; + } + var content = isActionable ? $"I can help with that. I'll create a proposal to {actionIntent}. ({reason})" : $"I can help with that request. ({reason})"; @@ -283,7 +301,8 @@ private static LlmCompletionResult BuildFallbackResult(string userMessage, strin Provider: "OpenAI", Model: string.IsNullOrWhiteSpace(model) ? "openai-unknown-model" : model.Trim(), IsDegraded: true, - DegradedReason: reason); + DegradedReason: reason, + Instructions: instructions); } private static int EstimateTokens(string text) diff --git a/backend/tests/Taskdeck.Application.Tests/Services/NaturalLanguageInstructionExtractorTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/NaturalLanguageInstructionExtractorTests.cs new file mode 100644 index 000000000..34506d148 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/NaturalLanguageInstructionExtractorTests.cs @@ -0,0 +1,291 @@ +using FluentAssertions; +using Taskdeck.Application.Services; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class NaturalLanguageInstructionExtractorTests +{ + #region Card Create — Quoted Titles + + [Theory] + [InlineData("create card \"My task\"", "create card \"My task\"")] + [InlineData("add card 'Bug fix for login'", "create card \"Bug fix for login\"")] + [InlineData("make a card \"Deploy to staging\"", "create card \"Deploy to staging\"")] + public void Extract_CardCreate_WithQuotedTitle_ReturnsStructuredInstruction(string message, string expected) + { + var result = NaturalLanguageInstructionExtractor.Extract(message, "card.create"); + + result.Should().ContainSingle(); + result[0].Should().Be(expected); + } + + #endregion + + #region Card Create — Natural Language Title Extraction + + [Fact] + public void Extract_CardCreate_NaturalLanguage_ExtractsMeaningfulTitle() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "create new onboarding tasks for people who aren't technical", + "card.create"); + + result.Should().ContainSingle(); + result[0].Should().StartWith("create card \""); + result[0].Should().ContainEquivalentOf("onboarding"); + } + + [Fact] + public void Extract_CardCreate_SimplePhrase_ExtractsMeaningfulTitle() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "create tasks for the release checklist", + "card.create"); + + result.Should().ContainSingle(); + result[0].Should().StartWith("create card \""); + } + + [Fact] + public void Extract_CardCreate_WithSetUp_ExtractsTitle() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "set up a few cards for sprint planning", + "card.create"); + + result.Should().ContainSingle(); + result[0].Should().StartWith("create card \""); + } + + [Fact] + public void Extract_CardCreate_GenerateVerb_ExtractsTitle() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "generate a card for this feature", + "card.create"); + + result.Should().ContainSingle(); + result[0].Should().StartWith("create card \""); + } + + [Fact] + public void Extract_CardCreate_BuildVerb_ExtractsTitle() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "build out some tasks for the release", + "card.create"); + + result.Should().ContainSingle(); + result[0].Should().StartWith("create card \""); + } + + [Fact] + public void Extract_CardCreate_NewKeyword_ExtractsTitle() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "I need three new cards for the sprint", + "card.create"); + + result.Should().ContainSingle(); + result[0].Should().StartWith("create card \""); + } + + #endregion + + #region Card Move + + [Fact] + public void Extract_CardMove_WithIdAndQuotedColumn_ReturnsInstruction() + { + var cardId = Guid.NewGuid().ToString(); + var result = NaturalLanguageInstructionExtractor.Extract( + $"move card {cardId} to column \"Done\"", + "card.move"); + + result.Should().ContainSingle(); + result[0].Should().Be($"move card {cardId} to column \"Done\""); + } + + [Fact] + public void Extract_CardMove_WithoutId_ReturnsEmpty() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "move this card to done", + "card.move"); + + result.Should().BeEmpty(); + } + + #endregion + + #region Card Archive + + [Fact] + public void Extract_CardArchive_WithId_ReturnsInstruction() + { + var cardId = Guid.NewGuid().ToString(); + var result = NaturalLanguageInstructionExtractor.Extract( + $"archive card {cardId}", + "card.archive"); + + result.Should().ContainSingle(); + result[0].Should().Be($"archive card {cardId}"); + } + + [Fact] + public void Extract_CardArchive_NaturalLanguageWithId_ExtractsId() + { + var cardId = Guid.NewGuid().ToString(); + var result = NaturalLanguageInstructionExtractor.Extract( + $"please remove card {cardId} from the board", + "card.archive"); + + result.Should().ContainSingle(); + result[0].Should().Be($"archive card {cardId}"); + } + + [Fact] + public void Extract_CardArchive_WithoutId_ReturnsEmpty() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "delete all the old cards", + "card.archive"); + + result.Should().BeEmpty(); + } + + #endregion + + #region Card Update + + [Fact] + public void Extract_CardUpdate_TitleWithQuotedValue_ReturnsInstruction() + { + var cardId = Guid.NewGuid().ToString(); + var result = NaturalLanguageInstructionExtractor.Extract( + $"update card {cardId} title \"New title\"", + "card.update"); + + result.Should().ContainSingle(); + result[0].Should().Be($"update card {cardId} title \"New title\""); + } + + [Fact] + public void Extract_CardUpdate_DescriptionWithQuotedValue_ReturnsInstruction() + { + var cardId = Guid.NewGuid().ToString(); + var result = NaturalLanguageInstructionExtractor.Extract( + $"update card {cardId} description \"New description text\"", + "card.update"); + + result.Should().ContainSingle(); + result[0].Should().Be($"update card {cardId} description \"New description text\""); + } + + [Fact] + public void Extract_CardUpdate_WithoutQuotedValue_ReturnsEmpty() + { + var cardId = Guid.NewGuid().ToString(); + var result = NaturalLanguageInstructionExtractor.Extract( + $"update card {cardId} with a better title", + "card.update"); + + result.Should().BeEmpty(); + } + + #endregion + + #region Board Rename + + [Fact] + public void Extract_BoardRename_WithQuotedName_ReturnsInstruction() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "rename board to \"Sprint 5\"", + "board.update"); + + result.Should().ContainSingle(); + result[0].Should().Be("rename board to \"Sprint 5\""); + } + + [Fact] + public void Extract_BoardRename_WithoutQuotedName_ReturnsEmpty() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "rename the board to something better", + "board.update"); + + result.Should().BeEmpty(); + } + + #endregion + + #region Edge Cases + + [Theory] + [InlineData(null, "card.create")] + [InlineData("", "card.create")] + [InlineData(" ", "card.create")] + public void Extract_NullOrEmptyMessage_ReturnsEmpty(string? message, string intent) + { + var result = NaturalLanguageInstructionExtractor.Extract(message!, intent); + + result.Should().BeEmpty(); + } + + [Theory] + [InlineData("create a card", null)] + [InlineData("create a card", "")] + [InlineData("create a card", " ")] + public void Extract_NullOrEmptyIntent_ReturnsEmpty(string message, string? intent) + { + var result = NaturalLanguageInstructionExtractor.Extract(message, intent); + + result.Should().BeEmpty(); + } + + [Fact] + public void Extract_UnknownIntent_ReturnsEmpty() + { + var result = NaturalLanguageInstructionExtractor.Extract( + "do something weird", + "unknown.intent"); + + result.Should().BeEmpty(); + } + + #endregion + + #region CleanExtractedTitle + + [Theory] + [InlineData("onboarding tasks for non-technical people", "Onboarding tasks for non-technical people")] + [InlineData("a task for deployment", "Task for deployment")] + [InlineData("some cards for sprint", "Cards for sprint")] + [InlineData("the release checklist", "Release checklist")] + [InlineData("cards", "")] + [InlineData("task", "")] + [InlineData("items", "")] + [InlineData("feature request please", "Feature request")] + [InlineData("bug fix asap", "Bug fix")] + public void CleanExtractedTitle_ShouldCleanCorrectly(string input, string expected) + { + var result = NaturalLanguageInstructionExtractor.CleanExtractedTitle(input); + + result.Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CleanExtractedTitle_NullOrEmpty_ReturnsEmpty(string? input) + { + var result = NaturalLanguageInstructionExtractor.CleanExtractedTitle(input!); + + result.Should().BeEmpty(); + } + + #endregion +}