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
76 changes: 60 additions & 16 deletions backend/src/Taskdeck.Application/Services/CaptureTriageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,15 +94,15 @@ public async Task<Result<CaptureTriageProposalResultDto>> CreateProposalFromCapt
"No columns found in board");
}

var taskCandidates = ExtractTaskCandidates(payload.Text);
var (taskCandidates, contextHint) = ExtractTaskCandidates(payload.Text);
if (taskCandidates.Count == 0)
{
return Result.Failure<CaptureTriageProposalResultDto>(
ErrorCodes.ValidationError,
"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)
{
Expand Down Expand Up @@ -227,7 +231,7 @@ private static Guid ResolveTriageRunId(string? correlationId, Guid fallback)
}
}

private static List<string> ExtractTaskCandidates(string rawText)
private static (List<string> Tasks, string? ContextHint) ExtractTaskCandidates(string rawText)
{
var candidates = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
Expand All @@ -253,39 +257,67 @@ private static List<string> 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))
{
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();
Comment on lines +270 to +301
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's an opportunity to reduce code duplication. The logic for splitting the raw text by a delimiter, trimming, and filtering empty segments is repeated for both dash and semicolon patterns.

Consider extracting this logic into a private helper method to improve maintainability. For example:

private static List<string> GetSegments(string text, Regex pattern)
{
    return pattern.Split(text)
        .Select(s => s.Trim())
        .Where(s => !string.IsNullOrWhiteSpace(s))
        .ToList();
}

You could then use this helper in both places:

var dashSegments = GetSegments(rawText, DashDelimiterPattern);
// ...
var semicolonSegments = GetSegments(rawText, SemicolonDelimiterPattern);


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);
}
}

Expand All @@ -296,7 +328,7 @@ private static List<string> ExtractTaskCandidates(string rawText)
candidates.Add(fallback);
}

return candidates;
return (candidates, null);
}

private static string? TryExtractStructuredTask(string line)
Expand Down Expand Up @@ -392,11 +424,23 @@ private static Guid BuildDeterministicCardId(Guid captureItemId, int sequence, s
return new Guid(bytes);
}

private static CaptureTriageOutputV1 BuildOutputModel(IReadOnlyCollection<string> taskCandidates)
private static CaptureTriageOutputV1 BuildOutputModel(
IReadOnlyCollection<string> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<CreateProposalDto>(), default))
.Callback<CreateProposalDto, CancellationToken>((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<CreateProposalDto>(), default))
.Callback<CreateProposalDto, CancellationToken>((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<CreateProposalDto>(), default))
.Callback<CreateProposalDto, CancellationToken>((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(
Expand Down
Loading