From bb65e304d618f43f005a3da0a1e7b8e56e1c200d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:42:05 +0100 Subject: [PATCH 1/6] Add NaturalLanguageInstructionExtractor to bridge classification-to-parsing gap When the intent classifier detects actionable intent in natural language (e.g., "create new onboarding tasks"), this extractor produces structured instructions (e.g., create card "Onboarding tasks") that the regex-based parser can consume. This is the core fix for the #570 NLP gap. --- .../NaturalLanguageInstructionExtractor.cs | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs diff --git a/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs b/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs new file mode 100644 index 000000000..8d626adc5 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs @@ -0,0 +1,235 @@ +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); + + // Pattern to detect column reference without quotes (common phrasing) + private static readonly Regex ColumnNameUnquotedPattern = new( + @"(?:to|into)\s+(?:column\s+)(\w[\w\s]*\w)", + 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) + { + // Try quoted board name + var quotedMatch = QuotedTitlePattern.Match(message); + if (quotedMatch.Success) + { + var name = quotedMatch.Groups[1].Value.Trim(); + if (!string.IsNullOrWhiteSpace(name)) + return new List { $"create card \"{name}\"" }; + } + + // 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; + } +} From 98c77d61501357f77aff3e0e5a5d57ad5d78bdd3 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:42:10 +0100 Subject: [PATCH 2/6] Wire MockLlmProvider to extract structured instructions from natural language The Mock provider now uses NaturalLanguageInstructionExtractor when the classifier detects actionable intent, producing Instructions that ChatService can pass to the parser instead of the raw user message. Fixes the core #570 scenario where natural language fails to produce proposals. --- .../Services/MockLlmProvider.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 )); } From b492baea09e1fc2ae07076cd88ce889b14ce869c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:42:14 +0100 Subject: [PATCH 3/6] Wire OpenAI provider fallback paths to extract structured instructions When OpenAI structured JSON parsing fails and the provider falls back to the static classifier, also extract structured instructions so natural language can still produce parseable instructions for the planner. --- .../Services/OpenAiLlmProvider.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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) From 01a4017ca6ad6c14a8315805b2530c93c36db680 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:42:19 +0100 Subject: [PATCH 4/6] Wire Gemini provider fallback paths to extract structured instructions When Gemini structured JSON parsing fails and the provider falls back to the static classifier, also extract structured instructions so natural language can still produce parseable instructions for the planner. --- .../Services/GeminiLlmProvider.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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) From dc810f5234dc2667885fe9d399f7dca2583d22d5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:42:25 +0100 Subject: [PATCH 5/6] Add 38 unit tests for NaturalLanguageInstructionExtractor Cover card create (quoted titles, natural language, various verbs), card move/archive/update with IDs, board rename, edge cases (null, empty, unknown intent), and title cleaning logic. --- ...aturalLanguageInstructionExtractorTests.cs | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/NaturalLanguageInstructionExtractorTests.cs 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 +} From 0087725922f054ff105d3850aa3eb720a6940b07 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:54:29 +0100 Subject: [PATCH 6/6] Fix board create extraction bug and remove unused regex field ExtractBoardCreateInstructions incorrectly emitted create card instead of returning empty (AutomationPlannerService has no board create parser pattern). Also remove unused ColumnNameUnquotedPattern field. --- .../NaturalLanguageInstructionExtractor.cs | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs b/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs index 8d626adc5..da24246e7 100644 --- a/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs +++ b/backend/src/Taskdeck.Application/Services/NaturalLanguageInstructionExtractor.cs @@ -51,12 +51,6 @@ public static class NaturalLanguageInstructionExtractor RegexOptions.Compiled | RegexOptions.IgnoreCase, RegexTimeout); - // Pattern to detect column reference without quotes (common phrasing) - private static readonly Regex ColumnNameUnquotedPattern = new( - @"(?:to|into)\s+(?:column\s+)(\w[\w\s]*\w)", - RegexOptions.Compiled | RegexOptions.IgnoreCase, - RegexTimeout); - /// /// Attempts to extract structured instructions from a natural language message /// given a detected action intent. @@ -177,17 +171,8 @@ private static List ExtractCardUpdateInstructions(string message) private static List ExtractBoardCreateInstructions(string message) { - // Try quoted board name - var quotedMatch = QuotedTitlePattern.Match(message); - if (quotedMatch.Success) - { - var name = quotedMatch.Groups[1].Value.Trim(); - if (!string.IsNullOrWhiteSpace(name)) - return new List { $"create card \"{name}\"" }; - } - // Board creation doesn't have a direct parser pattern in AutomationPlannerService, - // so we can't produce a structured instruction for it yet + // so we can't produce a structured instruction for it yet. return new List(); }