Skip to content

Commit 1b52f7a

Browse files
authored
Merge pull request #638 from Chris0Jeky/feature/617-board-context-card-ids
LLM-02: Expand board context with card IDs and structured reference
2 parents 477681e + 97d19f7 commit 1b52f7a

File tree

2 files changed

+133
-34
lines changed

2 files changed

+133
-34
lines changed

backend/src/Taskdeck.Application/Services/BoardContextBuilder.cs

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,28 @@ namespace Taskdeck.Application.Services;
55

66
/// <summary>
77
/// Builds a bounded board context string suitable for inclusion in LLM system prompts.
8-
/// The context includes the board name, column names and positions, recent card titles
9-
/// per column, and label names. Enforces a token budget to prevent prompt bloat.
8+
/// The context includes the board name, column names and positions, card IDs and titles
9+
/// per column (with labels), and board-level label names.
10+
/// Enforces a token budget to prevent prompt bloat.
1011
/// </summary>
1112
public class BoardContextBuilder : IBoardContextBuilder
1213
{
1314
/// <summary>
1415
/// Approximate maximum character count for the board context string.
15-
/// At ~4 chars per token, 500 tokens ≈ 2000 characters.
16+
/// At ~4 chars per token, 1000 tokens ≈ 4000 characters.
1617
/// </summary>
17-
internal const int MaxContextCharacters = 2000;
18+
internal const int MaxContextCharacters = 4000;
1819

1920
/// <summary>
20-
/// Maximum number of card titles to include per column.
21+
/// Maximum number of cards to include per column.
2122
/// </summary>
2223
internal const int MaxCardsPerColumn = 5;
2324

25+
/// <summary>
26+
/// Number of characters used for the short card ID prefix (first N hex chars of GUID).
27+
/// </summary>
28+
internal const int ShortIdLength = 8;
29+
2430
private readonly IUnitOfWork _unitOfWork;
2531

2632
public BoardContextBuilder(IUnitOfWork unitOfWork)
@@ -41,26 +47,49 @@ public BoardContextBuilder(IUnitOfWork unitOfWork)
4147
var labels = (await _unitOfWork.Labels.GetByBoardIdAsync(boardId, ct))
4248
.ToList();
4349

50+
// Build a label lookup for card-level label display
51+
var labelLookup = labels.ToDictionary(l => l.Id, l => l.Name);
52+
4453
var sb = new StringBuilder();
4554
sb.AppendLine("## Current Board Context");
4655
sb.Append("Board: ").AppendLine(board.Name);
4756

4857
if (columns.Count > 0)
4958
{
50-
sb.AppendLine("Columns (in order):");
59+
sb.Append("Columns: ").AppendLine(
60+
string.Join(" → ", columns.Select(c => c.Name)));
61+
62+
// Single query for all board cards, grouped by column in memory (avoids N+1)
63+
var cardsByColumn = (await _unitOfWork.Cards.GetByBoardIdAsync(boardId, ct))
64+
.GroupBy(c => c.ColumnId)
65+
.ToDictionary(
66+
g => g.Key,
67+
g => g.OrderByDescending(c => c.UpdatedAt).Take(MaxCardsPerColumn).ToList());
68+
5169
foreach (var column in columns)
5270
{
53-
sb.Append(" - ").Append(column.Name).Append(" (position ").Append(column.Position).AppendLine(")");
71+
if (!cardsByColumn.TryGetValue(column.Id, out var columnCards) || columnCards.Count == 0)
72+
continue;
5473

55-
// Fetch cards per column from DB with limit applied at the query level
56-
// to avoid loading all board cards into memory.
57-
var columnCards = (await _unitOfWork.Cards.GetByColumnIdAsync(column.Id, ct))
58-
.OrderByDescending(c => c.UpdatedAt)
59-
.Take(MaxCardsPerColumn);
74+
sb.Append("Cards in \"").Append(column.Name).AppendLine("\":");
6075

6176
foreach (var card in columnCards)
6277
{
63-
sb.Append(" * ").AppendLine(card.Title);
78+
var shortId = FormatShortId(card.Id);
79+
sb.Append(" [").Append(shortId).Append("] ").Append(card.Title);
80+
81+
// Append card-level labels if any
82+
var cardLabelNames = card.CardLabels
83+
.Where(cl => labelLookup.ContainsKey(cl.LabelId))
84+
.Select(cl => labelLookup[cl.LabelId])
85+
.ToList();
86+
87+
if (cardLabelNames.Count > 0)
88+
{
89+
sb.Append(" [").Append(string.Join(", ", cardLabelNames)).Append(']');
90+
}
91+
92+
sb.AppendLine();
6493

6594
if (sb.Length >= MaxContextCharacters)
6695
break;
@@ -86,4 +115,13 @@ public BoardContextBuilder(IUnitOfWork unitOfWork)
86115

87116
return sb.ToString();
88117
}
118+
119+
/// <summary>
120+
/// Returns the first <see cref="ShortIdLength"/> hex characters of a GUID,
121+
/// without hyphens, for compact card identification in the context.
122+
/// </summary>
123+
internal static string FormatShortId(Guid id)
124+
{
125+
return id.ToString("N")[..ShortIdLength];
126+
}
89127
}

backend/tests/Taskdeck.Application.Tests/Services/BoardContextBuilderTests.cs

Lines changed: 82 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public async Task BuildContextAsync_IncludesBoardName()
5454
}
5555

5656
[Fact]
57-
public async Task BuildContextAsync_IncludesColumnNamesAndPositions()
57+
public async Task BuildContextAsync_IncludesColumnFlowLine()
5858
{
5959
var board = new Board("Dev Board", ownerId: Guid.NewGuid());
6060
var boardId = board.Id;
@@ -65,23 +65,16 @@ public async Task BuildContextAsync_IncludesColumnNamesAndPositions()
6565

6666
_boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)).ReturnsAsync(board);
6767
_columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { col1, col2, col3 });
68-
_cardRepoMock.Setup(r => r.GetByColumnIdAsync(col1.Id, default)).ReturnsAsync(Array.Empty<Card>());
69-
_cardRepoMock.Setup(r => r.GetByColumnIdAsync(col2.Id, default)).ReturnsAsync(Array.Empty<Card>());
70-
_cardRepoMock.Setup(r => r.GetByColumnIdAsync(col3.Id, default)).ReturnsAsync(Array.Empty<Card>());
68+
_cardRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(Array.Empty<Card>());
7169
_labelRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(Array.Empty<Label>());
7270

7371
var result = await _builder.BuildContextAsync(boardId);
7472

75-
result.Should().Contain("To Do");
76-
result.Should().Contain("In Progress");
77-
result.Should().Contain("Done");
78-
result.Should().Contain("position 0");
79-
result.Should().Contain("position 1");
80-
result.Should().Contain("position 2");
73+
result.Should().Contain("Columns: To Do → In Progress → Done");
8174
}
8275

8376
[Fact]
84-
public async Task BuildContextAsync_IncludesCardTitlesUnderColumns()
77+
public async Task BuildContextAsync_IncludesCardIdsAndTitlesUnderColumns()
8578
{
8679
var board = new Board("Dev Board", ownerId: Guid.NewGuid());
8780
var boardId = board.Id;
@@ -94,13 +87,22 @@ public async Task BuildContextAsync_IncludesCardTitlesUnderColumns()
9487

9588
_boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)).ReturnsAsync(board);
9689
_columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { col });
97-
_cardRepoMock.Setup(r => r.GetByColumnIdAsync(colId, default)).ReturnsAsync(new[] { card1, card2 });
90+
_cardRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { card1, card2 });
9891
_labelRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(Array.Empty<Label>());
9992

10093
var result = await _builder.BuildContextAsync(boardId);
10194

10295
result.Should().Contain("Fix login bug");
10396
result.Should().Contain("Update README");
97+
98+
// Card IDs should appear as short hex prefixes in brackets
99+
var shortId1 = BoardContextBuilder.FormatShortId(card1.Id);
100+
var shortId2 = BoardContextBuilder.FormatShortId(card2.Id);
101+
result.Should().Contain($"[{shortId1}]");
102+
result.Should().Contain($"[{shortId2}]");
103+
104+
// Cards should appear under column heading
105+
result.Should().Contain("Cards in \"To Do\":");
104106
}
105107

106108
[Fact]
@@ -137,7 +139,7 @@ public async Task BuildContextAsync_LimitsCardsPerColumn()
137139

138140
_boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)).ReturnsAsync(board);
139141
_columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { col });
140-
_cardRepoMock.Setup(r => r.GetByColumnIdAsync(colId, default)).ReturnsAsync(cards);
142+
_cardRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(cards);
141143
_labelRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(Array.Empty<Label>());
142144

143145
var result = await _builder.BuildContextAsync(boardId);
@@ -164,15 +166,14 @@ public async Task BuildContextAsync_RespectsTokenBudget()
164166
.Select(i => new Label(boardId, $"Label-{i}", "#FF0000"))
165167
.ToList();
166168

169+
var allCards = columns.SelectMany(col =>
170+
Enumerable.Range(0, 10)
171+
.Select(j => new Card(boardId, col.Id, $"A card with a fairly long title in column {col.Name} number {j}"))
172+
).ToList();
173+
167174
_boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)).ReturnsAsync(board);
168175
_columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(columns);
169-
foreach (var col in columns)
170-
{
171-
var colCards = Enumerable.Range(0, 10)
172-
.Select(j => new Card(boardId, col.Id, $"A card with a fairly long title in column {col.Name} number {j}"))
173-
.ToList();
174-
_cardRepoMock.Setup(r => r.GetByColumnIdAsync(col.Id, default)).ReturnsAsync(colCards);
175-
}
176+
_cardRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(allCards);
176177
_labelRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(labels);
177178

178179
var result = await _builder.BuildContextAsync(boardId);
@@ -208,6 +209,66 @@ public async Task BuildContextAsync_OmitsColumnsSection_WhenNoColumns()
208209

209210
var result = await _builder.BuildContextAsync(boardId);
210211

211-
result.Should().NotContain("Columns (in order):");
212+
result.Should().NotContain("Columns:");
213+
result.Should().NotContain("Cards in");
214+
}
215+
216+
[Fact]
217+
public async Task BuildContextAsync_SkipsEmptyColumns()
218+
{
219+
var board = new Board("Dev Board", ownerId: Guid.NewGuid());
220+
var boardId = board.Id;
221+
222+
var col1 = new Column(boardId, "Empty Column", 0);
223+
var col2 = new Column(boardId, "Has Cards", 1);
224+
var card = new Card(boardId, col2.Id, "A card");
225+
226+
_boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)).ReturnsAsync(board);
227+
_columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { col1, col2 });
228+
_cardRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { card });
229+
_labelRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(Array.Empty<Label>());
230+
231+
var result = await _builder.BuildContextAsync(boardId);
232+
233+
result.Should().NotContain("Cards in \"Empty Column\"");
234+
result.Should().Contain("Cards in \"Has Cards\"");
235+
}
236+
237+
[Fact]
238+
public void FormatShortId_ReturnsFirst8HexChars()
239+
{
240+
var id = Guid.Parse("abcdef12-3456-7890-abcd-ef1234567890");
241+
var shortId = BoardContextBuilder.FormatShortId(id);
242+
243+
shortId.Should().Be("abcdef12");
244+
shortId.Should().HaveLength(BoardContextBuilder.ShortIdLength);
245+
}
246+
247+
[Fact]
248+
public async Task BuildContextAsync_IncludesCardLabels()
249+
{
250+
var board = new Board("Dev Board", ownerId: Guid.NewGuid());
251+
var boardId = board.Id;
252+
253+
var label = new Label(boardId, "Bug", "#FF0000");
254+
var col = new Column(boardId, "To Do", 0);
255+
var card = new Card(boardId, col.Id, "Fix crash");
256+
card.AddLabel(new CardLabel(card.Id, label.Id));
257+
258+
_boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)).ReturnsAsync(board);
259+
_columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { col });
260+
_cardRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { card });
261+
_labelRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)).ReturnsAsync(new[] { label });
262+
263+
var result = await _builder.BuildContextAsync(boardId);
264+
265+
result.Should().Contain("Fix crash [Bug]");
266+
}
267+
268+
[Fact]
269+
public async Task BuildContextAsync_BudgetIs4000()
270+
{
271+
// Verify the budget constant is 4000 chars
272+
BoardContextBuilder.MaxContextCharacters.Should().Be(4000);
212273
}
213274
}

0 commit comments

Comments
 (0)