From b737225a556f5a38c7a10082fec3d9332353a32e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 31 Mar 2026 17:48:16 +0100 Subject: [PATCH 1/3] Use first dash segment as context hint in capture triage splitting For dash-separated text like "ACME Ltd - task1 - task2 - task3", the first segment now becomes a context hint stored in each task's evidence/description field rather than being created as a standalone task. Semicolons and structured patterns are unchanged. Requires 3+ dash segments to trigger context-hint behavior (2 segments fall through to single-sentence fallback). Fixes #614 --- .../Services/CaptureTriageService.cs | 76 +++++++++++++++---- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/CaptureTriageService.cs b/backend/src/Taskdeck.Application/Services/CaptureTriageService.cs index fd1fd0636..f21c43580 100644 --- a/backend/src/Taskdeck.Application/Services/CaptureTriageService.cs +++ b/backend/src/Taskdeck.Application/Services/CaptureTriageService.cs @@ -27,8 +27,12 @@ public class CaptureTriageService : ICaptureTriageService @"^\s*\d+[.)]\s+(.+?)\s*$", RegexOptions.Compiled); - private static readonly Regex InlineDelimiterPattern = new( - @"[^\S\n]+-[^\S\n]+|;\s+", + private static readonly Regex DashDelimiterPattern = new( + @"[^\S\n]+-[^\S\n]+", + RegexOptions.Compiled); + + private static readonly Regex SemicolonDelimiterPattern = new( + @";\s*", RegexOptions.Compiled); private readonly IUnitOfWork _unitOfWork; @@ -90,7 +94,7 @@ public async Task> CreateProposalFromCapt "No columns found in board"); } - var taskCandidates = ExtractTaskCandidates(payload.Text); + var (taskCandidates, contextHint) = ExtractTaskCandidates(payload.Text); if (taskCandidates.Count == 0) { return Result.Failure( @@ -98,7 +102,7 @@ public async Task> CreateProposalFromCapt "Capture text did not produce actionable triage items"); } - var outputModel = BuildOutputModel(taskCandidates); + var outputModel = BuildOutputModel(taskCandidates, contextHint); var outputValidation = CaptureTriageOutputContract.Validate(outputModel); if (!outputValidation.IsSuccess) { @@ -227,7 +231,7 @@ private static Guid ResolveTriageRunId(string? correlationId, Guid fallback) } } - private static List ExtractTaskCandidates(string rawText) + private static (List Tasks, string? ContextHint) ExtractTaskCandidates(string rawText) { var candidates = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -253,24 +257,25 @@ private static List ExtractTaskCandidates(string rawText) candidates.Add(normalized); if (candidates.Count >= MaxExtractedTasks) { - return candidates; + return (candidates, null); } } if (candidates.Count > 0) { - return candidates; + return (candidates, null); } - // Try inline delimiters: " - " (space-dash-space) and ";" - var delimiterSegments = InlineDelimiterPattern.Split(rawText) + // Try dash-separated: first segment is context hint, rest are tasks + var dashSegments = DashDelimiterPattern.Split(rawText) .Select(s => s.Trim()) .Where(s => !string.IsNullOrWhiteSpace(s)) .ToList(); - if (delimiterSegments.Count >= 2) + if (dashSegments.Count >= 3) { - foreach (var segment in delimiterSegments) + var contextHint = NormalizeTaskTitle(dashSegments[0]); + foreach (var segment in dashSegments.Skip(1)) { var normalized = NormalizeTaskTitle(segment); if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized)) @@ -278,14 +283,41 @@ private static List ExtractTaskCandidates(string rawText) candidates.Add(normalized); if (candidates.Count >= MaxExtractedTasks) { - return candidates; + return (candidates, contextHint); } } } if (candidates.Count > 0) { - return candidates; + return (candidates, contextHint); + } + } + + // Try semicolons: all segments are equal tasks + var semicolonSegments = SemicolonDelimiterPattern.Split(rawText) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + + if (semicolonSegments.Count >= 2) + { + foreach (var segment in semicolonSegments) + { + var normalized = NormalizeTaskTitle(segment); + if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized)) + { + candidates.Add(normalized); + if (candidates.Count >= MaxExtractedTasks) + { + return (candidates, null); + } + } + } + + if (candidates.Count > 0) + { + return (candidates, null); } } @@ -296,7 +328,7 @@ private static List ExtractTaskCandidates(string rawText) candidates.Add(fallback); } - return candidates; + return (candidates, null); } private static string? TryExtractStructuredTask(string line) @@ -392,11 +424,23 @@ private static Guid BuildDeterministicCardId(Guid captureItemId, int sequence, s return new Guid(bytes); } - private static CaptureTriageOutputV1 BuildOutputModel(IReadOnlyCollection taskCandidates) + private static CaptureTriageOutputV1 BuildOutputModel( + IReadOnlyCollection taskCandidates, + string? contextHint = null) { + var hasContext = !string.IsNullOrWhiteSpace(contextHint); var tasks = taskCandidates .Take(CaptureTriageOutputContract.MaxTasks) - .Select(task => new CaptureTriageTaskV1(task, task)) + .Select(task => + { + var evidence = hasContext ? $"{contextHint}: {task}" : task; + if (evidence.Length > CaptureTriageOutputContract.MaxTaskEvidenceLength) + { + evidence = evidence[..CaptureTriageOutputContract.MaxTaskEvidenceLength].TrimEnd(); + } + + return new CaptureTriageTaskV1(task, evidence); + }) .ToList(); return new CaptureTriageOutputV1( From bfdba428f40e4c7392b7fc60c9f0d97b18f6d417 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 31 Mar 2026 17:48:21 +0100 Subject: [PATCH 2/3] Add tests for dash context hint, two-segment fallback, and bullet priority Update existing dash-separated test to expect 3 tasks (not 4) with context hint in evidence. Add new tests: context hint appears in description JSON, two-segment dash input falls to single-card fallback, structured bullet lines take priority over inline dashes. --- .../Services/CaptureTriageServiceTests.cs | 106 +++++++++++++++++- 1 file changed, 100 insertions(+), 6 deletions(-) diff --git a/backend/tests/Taskdeck.Application.Tests/Services/CaptureTriageServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/CaptureTriageServiceTests.cs index 0c897fde1..c25b235a0 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/CaptureTriageServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/CaptureTriageServiceTests.cs @@ -489,13 +489,13 @@ public async Task CreateProposalFromCaptureAsync_ShouldSplitDashSeparatedText_In var result = await _service.CreateProposalFromCaptureAsync(captureId, userId, boardId, payload); result.IsSuccess.Should().BeTrue(); - result.Value.OperationCount.Should().Be(4); + result.Value.OperationCount.Should().Be(3); createdProposal.Should().NotBeNull(); - createdProposal!.Operations.Should().HaveCount(4); - createdProposal.Operations![0].Parameters.Should().Contain("ACME onboarding"); - createdProposal.Operations[1].Parameters.Should().Contain("request ID documents"); - createdProposal.Operations[2].Parameters.Should().Contain("send engagement letter"); - createdProposal.Operations[3].Parameters.Should().Contain("schedule call"); + createdProposal!.Operations.Should().HaveCount(3); + createdProposal.Operations![0].Parameters.Should().Contain("request ID documents"); + createdProposal.Operations[0].Parameters.Should().Contain("ACME onboarding"); + createdProposal.Operations[1].Parameters.Should().Contain("send engagement letter"); + createdProposal.Operations[2].Parameters.Should().Contain("schedule call"); } [Fact] @@ -561,6 +561,100 @@ public async Task CreateProposalFromCaptureAsync_ShouldCreateSingleCard_ForPlain createdProposal.Operations![0].Parameters.Should().Contain("Remember to check the deployment logs after lunch"); } + [Fact] + public async Task CreateProposalFromCaptureAsync_ShouldIncludeContextHintInEvidence_ForDashSeparatedText() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var captureId = Guid.NewGuid(); + var board = new Board("Capture board", ownerId: userId); + var column = new Column(boardId, "Inbox", 0); + CreateProposalDto? createdProposal = null; + + _boardsMock.Setup(r => r.GetByIdAsync(boardId, default)).ReturnsAsync(board); + _columnsMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { column }); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .Callback((dto, _) => createdProposal = dto) + .ReturnsAsync(Result.Success(BuildProposalDto(userId, boardId, captureId))); + + var payload = new CapturePayloadV1( + CaptureRequestContract.CurrentSchemaVersion, + CaptureSource.Typed, + "ACME Ltd - request documents - send letter - schedule call"); + + var result = await _service.CreateProposalFromCaptureAsync(captureId, userId, boardId, payload); + + result.IsSuccess.Should().BeTrue(); + result.Value.OperationCount.Should().Be(3); + createdProposal.Should().NotBeNull(); + // Title should be just the task, evidence should include context + createdProposal!.Operations![0].Parameters.Should().Contain("\"title\":\"request documents\""); + createdProposal.Operations[0].Parameters.Should().Contain("ACME Ltd: request documents"); + } + + [Fact] + public async Task CreateProposalFromCaptureAsync_ShouldFallToSingleCard_WhenOnlyTwoDashSegments() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var captureId = Guid.NewGuid(); + var board = new Board("Capture board", ownerId: userId); + var column = new Column(boardId, "Inbox", 0); + CreateProposalDto? createdProposal = null; + + _boardsMock.Setup(r => r.GetByIdAsync(boardId, default)).ReturnsAsync(board); + _columnsMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { column }); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .Callback((dto, _) => createdProposal = dto) + .ReturnsAsync(Result.Success(BuildProposalDto(userId, boardId, captureId))); + + var payload = new CapturePayloadV1( + CaptureRequestContract.CurrentSchemaVersion, + CaptureSource.Typed, + "fix the deployment bug - deploy to staging"); + + var result = await _service.CreateProposalFromCaptureAsync(captureId, userId, boardId, payload); + + result.IsSuccess.Should().BeTrue(); + // Two dash segments should not trigger context-hint splitting + // Falls through to single-sentence fallback + result.Value.OperationCount.Should().Be(1); + createdProposal.Should().NotBeNull(); + createdProposal!.Operations.Should().ContainSingle(); + } + + [Fact] + public async Task CreateProposalFromCaptureAsync_ShouldNotSplitDashes_WhenStructuredBulletLinesExist() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var captureId = Guid.NewGuid(); + var board = new Board("Capture board", ownerId: userId); + var column = new Column(boardId, "Inbox", 0); + CreateProposalDto? createdProposal = null; + + _boardsMock.Setup(r => r.GetByIdAsync(boardId, default)).ReturnsAsync(board); + _columnsMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { column }); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .Callback((dto, _) => createdProposal = dto) + .ReturnsAsync(Result.Success(BuildProposalDto(userId, boardId, captureId))); + + // Structured bullets take priority even if text also contains dashes + var payload = new CapturePayloadV1( + CaptureRequestContract.CurrentSchemaVersion, + CaptureSource.Typed, + "- Fix the deployment - it is broken\n- Update docs"); + + var result = await _service.CreateProposalFromCaptureAsync(captureId, userId, boardId, payload); + + result.IsSuccess.Should().BeTrue(); + result.Value.OperationCount.Should().Be(2); + createdProposal.Should().NotBeNull(); + createdProposal!.Operations.Should().HaveCount(2); + createdProposal.Operations![0].Parameters.Should().Contain("Fix the deployment"); + createdProposal.Operations[1].Parameters.Should().Contain("Update docs"); + } + private static ProposalDto BuildProposalDto(Guid userId, Guid boardId, Guid captureId) { return new ProposalDto( From 7593bb521d70f072c9ac670016e466e61bf0cf4b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 31 Mar 2026 18:12:17 +0100 Subject: [PATCH 3/3] Require trailing whitespace for semicolon delimiter pattern Change SemicolonDelimiterPattern from ;\s* to ;\s+ to avoid splitting on semicolons without trailing space (e.g., URLs, code snippets). This preserves the original behavioral contract. --- .../src/Taskdeck.Application/Services/CaptureTriageService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Application/Services/CaptureTriageService.cs b/backend/src/Taskdeck.Application/Services/CaptureTriageService.cs index f21c43580..a04965d8e 100644 --- a/backend/src/Taskdeck.Application/Services/CaptureTriageService.cs +++ b/backend/src/Taskdeck.Application/Services/CaptureTriageService.cs @@ -32,7 +32,7 @@ public class CaptureTriageService : ICaptureTriageService RegexOptions.Compiled); private static readonly Regex SemicolonDelimiterPattern = new( - @";\s*", + @";\s+", RegexOptions.Compiled); private readonly IUnitOfWork _unitOfWork;