From 27b68303016e24dc76791f0b4baa3bb4f52161b0 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Sun, 29 Mar 2026 21:23:16 +0300 Subject: [PATCH 1/2] fix: search ranking, layout gaps, and content truncation in BrainBar formatters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. FTS5 ranking: Add f.rank to search query, ORDER BY f.rank instead of c.rowid ASC. Results now sorted by BM25 relevance score. Score exposed as positive number in results (negated from FTS5's negative convention). 2. Layout: Remove trailing empty │ line after last search result. The └─ closer now follows directly after the last result's tags/content line. Separator │ lines still appear between results, just not after the last. 3. Content truncation: Increase summary/snippet display from 72 to 150 chars so results are actually readable in the terminal. 67/67 tests green (5 new: 2 layout, 1 truncation, 2 ranking). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/BrainBar/BrainDatabase.swift | 10 +++-- brain-bar/Sources/BrainBar/Formatters.swift | 7 ++- .../Tests/BrainBarTests/DatabaseTests.swift | 23 ++++++++++ .../Tests/BrainBarTests/FormattersTests.swift | 44 +++++++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/brain-bar/Sources/BrainBar/BrainDatabase.swift b/brain-bar/Sources/BrainBar/BrainDatabase.swift index 9e30ede..222072c 100644 --- a/brain-bar/Sources/BrainBar/BrainDatabase.swift +++ b/brain-bar/Sources/BrainBar/BrainDatabase.swift @@ -230,11 +230,11 @@ final class BrainDatabase: @unchecked Sendable { let sql = """ SELECT c.rowid, c.id, c.content, c.project, c.content_type, c.importance, - c.created_at, c.summary, c.tags, c.conversation_id + c.created_at, c.summary, c.tags, c.conversation_id, f.rank FROM chunks_fts f JOIN chunks c ON c.id = f.chunk_id WHERE \(conditions.joined(separator: " AND ")) - ORDER BY c.rowid ASC + ORDER BY f.rank LIMIT ? """ @@ -274,6 +274,9 @@ final class BrainDatabase: @unchecked Sendable { while sqlite3_step(stmt) == SQLITE_ROW { let rowID = sqlite3_column_int64(stmt, 0) maxRowID = max(maxRowID, rowID) + // FTS5 rank is negative (lower = better match). Negate for a positive score. + let rawRank = sqlite3_column_double(stmt, 10) + let score = max(0, -rawRank) results.append([ "rowid": Int(rowID), "chunk_id": columnText(stmt, 1) as Any, @@ -284,7 +287,8 @@ final class BrainDatabase: @unchecked Sendable { "created_at": columnText(stmt, 6) as Any, "summary": columnText(stmt, 7) as Any, "tags": columnText(stmt, 8) as Any, - "session_id": columnText(stmt, 9) as Any + "session_id": columnText(stmt, 9) as Any, + "score": score ]) } diff --git a/brain-bar/Sources/BrainBar/Formatters.swift b/brain-bar/Sources/BrainBar/Formatters.swift index d9d59f3..947af4f 100644 --- a/brain-bar/Sources/BrainBar/Formatters.swift +++ b/brain-bar/Sources/BrainBar/Formatters.swift @@ -108,7 +108,7 @@ enum Formatters { let importance = r["importance"] let summary = r["summary"] as? String ?? "" let content = r["content"] as? String ?? "" - let displayText = truncate(summary.isEmpty ? content : summary, maxLen: 72) + let displayText = truncate(summary.isEmpty ? content : summary, maxLen: 150) let impStr: String if let imp = importance as? Double { @@ -129,7 +129,10 @@ enum Formatters { let tagStr = tags.prefix(4).joined(separator: ", ") lines.append("\u{2502} \(key("tags:", useColor)) \(tagStr)") } - lines.append("\u{2502}") + // Separator between results, but not after the last one + if i < results.count - 1 { + lines.append("\u{2502}") + } } lines.append("\u{2514}\u{2500}") diff --git a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift index a67e556..affe752 100644 --- a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift +++ b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift @@ -312,4 +312,27 @@ final class DatabaseTests: XCTestCase { wait(for: [expectation], timeout: 5.0) } + + // MARK: - Search Ranking (FTS5 BM25) + + func testSearchResultsHaveNonZeroScore() throws { + try db.insertChunk(id: "score-1", content: "BrainBar Swift daemon formatting", sessionId: "s1", project: "test", contentType: "assistant_text", importance: 5) + + let results = try db.search(query: "BrainBar daemon", limit: 5) + XCTAssertFalse(results.isEmpty) + let score = results.first?["score"] as? Double ?? 0 + XCTAssertGreaterThan(score, 0, "Search results should have a non-zero relevance score") + } + + func testSearchResultsOrderedByRelevance() throws { + // "sprint" appears in content of both, but the first has it more prominently + try db.insertChunk(id: "rel-1", content: "The overnight hardening sprint was a success. Sprint results show improvements.", sessionId: "s1", project: "test", contentType: "assistant_text", importance: 5) + try db.insertChunk(id: "rel-2", content: "We discussed various topics including weather and sprint planning briefly.", sessionId: "s2", project: "test", contentType: "assistant_text", importance: 5) + + let results = try db.search(query: "sprint", limit: 5) + XCTAssertEqual(results.count, 2) + let score1 = results[0]["score"] as? Double ?? 0 + let score2 = results[1]["score"] as? Double ?? 0 + XCTAssertGreaterThanOrEqual(score1, score2, "Results should be ordered by relevance (highest score first)") + } } diff --git a/brain-bar/Tests/BrainBarTests/FormattersTests.swift b/brain-bar/Tests/BrainBarTests/FormattersTests.swift index 9daf20c..bc0643d 100644 --- a/brain-bar/Tests/BrainBarTests/FormattersTests.swift +++ b/brain-bar/Tests/BrainBarTests/FormattersTests.swift @@ -294,4 +294,48 @@ final class FormattersTests: XCTestCase { XCTAssertTrue(entityOut.contains("\u{250c}")) XCTAssertTrue(entityOut.contains("\u{2514}")) } + + // MARK: - Layout: No trailing empty │ lines + + func testSearchResultsNoTrailingEmptyLine() { + let result: [String: Any] = [ + "chunk_id": "x1", "score": 0.5, "project": "test", + "created_at": "2026-01-01", "summary": "Some result", "importance": 5 + ] + let out = Formatters.formatSearchResults(query: "q", results: [result], total: 1, useColor: false) + let lines = out.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + // The line before └─ should NOT be a bare │ + let closerIdx = lines.lastIndex(where: { $0.hasPrefix("\u{2514}") })! + let beforeCloser = lines[closerIdx - 1] + XCTAssertNotEqual(beforeCloser, "\u{2502}", "Should not have a trailing empty │ line before └─") + } + + func testSearchResultsMultipleNoTrailingGap() { + let results: [[String: Any]] = [ + ["chunk_id": "a", "score": 0.9, "project": "p", "created_at": "d", "summary": "First", "importance": 7], + ["chunk_id": "b", "score": 0.5, "project": "p", "created_at": "d", "summary": "Second", "importance": 3], + ] + let out = Formatters.formatSearchResults(query: "q", results: results, total: 2, useColor: false) + let lines = out.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let closerIdx = lines.lastIndex(where: { $0.hasPrefix("\u{2514}") })! + let beforeCloser = lines[closerIdx - 1] + XCTAssertNotEqual(beforeCloser, "\u{2502}", "Last result should not have trailing │ gap") + } + + // MARK: - Content truncation: 150 chars + + func testSearchResultsSummaryTruncatesAt150() { + let longSummary = String(repeating: "x", count: 200) + let result: [String: Any] = [ + "chunk_id": "t1", "score": 0.5, "project": "test", + "created_at": "2026-01-01", "summary": longSummary, "importance": 5 + ] + let out = Formatters.formatSearchResults(query: "q", results: [result], total: 1, useColor: false) + // The display text line should be truncated to 150 chars (149 + ellipsis) + // Find the line with the project pad + let contentLine = out.split(separator: "\n").first(where: { $0.contains("test") && $0.contains("\u{2502}") && $0.contains("xxx") }) + XCTAssertNotNil(contentLine, "Should have a content line with the summary") + // The full 200-char string should NOT appear + XCTAssertFalse(out.contains(longSummary), "200-char summary should be truncated") + } } From 805af1b4add4b70f40fedf843a07ab56d0f8ad8e Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Sun, 29 Mar 2026 21:44:55 +0300 Subject: [PATCH 2/2] fix: preserve rowid ordering for unread queries, strengthen truncation test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review findings: 1. CRITICAL: ORDER BY f.rank broke unread delivery watermark. When unreadOnly=true, maxRowID from rank-sorted results could skip rowids and mark unseen chunks as delivered. Fix: use rowid ASC for unread queries, f.rank for normal search. 2. MAJOR: Truncation test was weak — only checked 200-char string absent. Now asserts exact 149 chars + ellipsis (150 total) is present. 67/67 tests green. Co-Authored-By: Claude Opus 4.6 (1M context) --- brain-bar/Sources/BrainBar/BrainDatabase.swift | 5 ++++- brain-bar/Tests/BrainBarTests/FormattersTests.swift | 8 +++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/brain-bar/Sources/BrainBar/BrainDatabase.swift b/brain-bar/Sources/BrainBar/BrainDatabase.swift index 222072c..ae755d5 100644 --- a/brain-bar/Sources/BrainBar/BrainDatabase.swift +++ b/brain-bar/Sources/BrainBar/BrainDatabase.swift @@ -228,13 +228,16 @@ final class BrainDatabase: @unchecked Sendable { if importanceMin != nil { conditions.append("c.importance >= ?") } if unreadOnly { conditions.append("c.rowid > ?") } + // Unread mode needs contiguous rowid ordering for watermark semantics. + // Normal search uses FTS5 BM25 rank for relevance ordering. + let orderByClause = unreadOnly ? "c.rowid ASC" : "f.rank" let sql = """ SELECT c.rowid, c.id, c.content, c.project, c.content_type, c.importance, c.created_at, c.summary, c.tags, c.conversation_id, f.rank FROM chunks_fts f JOIN chunks c ON c.id = f.chunk_id WHERE \(conditions.joined(separator: " AND ")) - ORDER BY f.rank + ORDER BY \(orderByClause) LIMIT ? """ diff --git a/brain-bar/Tests/BrainBarTests/FormattersTests.swift b/brain-bar/Tests/BrainBarTests/FormattersTests.swift index bc0643d..5d7469d 100644 --- a/brain-bar/Tests/BrainBarTests/FormattersTests.swift +++ b/brain-bar/Tests/BrainBarTests/FormattersTests.swift @@ -331,11 +331,9 @@ final class FormattersTests: XCTestCase { "created_at": "2026-01-01", "summary": longSummary, "importance": 5 ] let out = Formatters.formatSearchResults(query: "q", results: [result], total: 1, useColor: false) - // The display text line should be truncated to 150 chars (149 + ellipsis) - // Find the line with the project pad - let contentLine = out.split(separator: "\n").first(where: { $0.contains("test") && $0.contains("\u{2502}") && $0.contains("xxx") }) - XCTAssertNotNil(contentLine, "Should have a content line with the summary") - // The full 200-char string should NOT appear + // Exact truncation: 149 chars + ellipsis = 150 total + let expected = String(repeating: "x", count: 149) + "\u{2026}" + XCTAssertTrue(out.contains(expected), "Summary should truncate to 149 chars + ellipsis (150 total)") XCTAssertFalse(out.contains(longSummary), "200-char summary should be truncated") } }