diff --git a/brain-bar/Sources/BrainBar/BrainDatabase.swift b/brain-bar/Sources/BrainBar/BrainDatabase.swift index 9e30ede..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 + 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 \(orderByClause) LIMIT ? """ @@ -274,6 +277,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 +290,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..5d7469d 100644 --- a/brain-bar/Tests/BrainBarTests/FormattersTests.swift +++ b/brain-bar/Tests/BrainBarTests/FormattersTests.swift @@ -294,4 +294,46 @@ 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) + // 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") + } }