From b62a94a7c92feada6f2ae915f33a5d53e88a3909 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 7 Jul 2026 05:59:49 +0530 Subject: [PATCH 1/2] daily recap: filter failed cards and cap prompt size (#285) --- .../Dayflow/Core/AI/DailyRecapGenerator.swift | 38 +++++++++++---- .../DailyRecapCardsTextTests.swift | 46 +++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 Dayflow/DayflowTests/DailyRecapCardsTextTests.swift diff --git a/Dayflow/Dayflow/Core/AI/DailyRecapGenerator.swift b/Dayflow/Dayflow/Core/AI/DailyRecapGenerator.swift index 9652f1bb7..02de6bd33 100644 --- a/Dayflow/Dayflow/Core/AI/DailyRecapGenerator.swift +++ b/Dayflow/Dayflow/Core/AI/DailyRecapGenerator.swift @@ -218,29 +218,51 @@ final class DailyRecapGenerator { } } - static func makeCardsText(day: String, cards: [TimelineCard]) -> String { - let ordered = cards.sorted { lhs, rhs in - if lhs.startTimestamp == rhs.startTimestamp { - return lhs.endTimestamp < rhs.endTimestamp + // Rough ceiling on the activity log's size (~15K tokens). Normal days stay far + // below it; it only bites when something floods the day with cards (#285). + static let recapCardsTextMaxCharacters = 60_000 + + static func makeCardsText( + day: String, cards: [TimelineCard], maxCharacters: Int = recapCardsTextMaxCharacters + ) -> String { + let ordered = cards + .filter { $0.title != "Processing failed" } + .sorted { lhs, rhs in + if lhs.startTimestamp == rhs.startTimestamp { + return lhs.endTimestamp < rhs.endTimestamp + } + return lhs.startTimestamp < rhs.startTimestamp } - return lhs.startTimestamp < rhs.startTimestamp - } guard !ordered.isEmpty else { return "No timeline activities were recorded for \(day)." } var lines: [String] = ["Timeline activities for \(day):", ""] + var remaining = maxCharacters - lines.joined(separator: "\n").count + var includedCount = 0 + for (index, card) in ordered.enumerated() { let title = standupLine(from: card) ?? "Untitled activity" let start = humanReadableClockTime(card.startTimestamp) let end = humanReadableClockTime(card.endTimestamp) - lines.append("\(index + 1). \(start) - \(end): \(title)") + var entryLines = ["\(index + 1). \(start) - \(end): \(title)"] let summary = card.summary.trimmingCharacters(in: .whitespacesAndNewlines) if !summary.isEmpty, summary != title { - lines.append(" \(summary)") + entryLines.append(" \(summary)") } + + let entryLength = entryLines.reduce(0) { $0 + $1.count + 1 } + guard entryLength <= remaining else { break } + lines.append(contentsOf: entryLines) + remaining -= entryLength + includedCount += 1 + } + + if includedCount < ordered.count { + let omitted = ordered.count - includedCount + lines.append("... and \(omitted) more activities omitted to stay within the model's context limit.") } return lines.joined(separator: "\n") diff --git a/Dayflow/DayflowTests/DailyRecapCardsTextTests.swift b/Dayflow/DayflowTests/DailyRecapCardsTextTests.swift new file mode 100644 index 000000000..cbc939b0a --- /dev/null +++ b/Dayflow/DayflowTests/DailyRecapCardsTextTests.swift @@ -0,0 +1,46 @@ +import XCTest + +@testable import Dayflow + +final class DailyRecapCardsTextTests: XCTestCase { + func testExcludesProcessingFailedCardsFromPrompt() { + let text = DailyRecapGenerator.makeCardsText( + day: "2026-06-16", + cards: [card(title: "Processing failed", minute: 0), card(title: "Wrote the report", minute: 30)] + ) + + XCTAssertFalse(text.contains("Processing failed")) + XCTAssertTrue(text.contains("1. 9:30am - 9:31am: Wrote the report")) + + let onlyFailed = DailyRecapGenerator.makeCardsText( + day: "2026-06-16", cards: [card(title: "Processing failed", minute: 0)] + ) + XCTAssertEqual(onlyFailed, "No timeline activities were recorded for 2026-06-16.") + } + + func testTruncatesDeterministicallyWhenOverCharacterBudget() { + let cards = (0..<50).map { card(title: "Activity number \($0)", minute: $0) } + let makeText = { + DailyRecapGenerator.makeCardsText(day: "2026-06-16", cards: cards, maxCharacters: 400) + } + + let text = makeText() + let includedCount = text.split(separator: "\n").filter { $0.contains(": Activity") }.count + XCTAssertTrue((1..<50).contains(includedCount)) + XCTAssertTrue(text.contains("1. 9:00am - 9:01am: Activity number 0")) + XCTAssertTrue(text.contains("... and \(50 - includedCount) more activities omitted")) + XCTAssertLessThan(text.count, 500) + XCTAssertEqual(text, makeText()) + } + + private func card(title: String, minute: Int) -> TimelineCard { + TimelineCard( + recordId: nil, batchId: nil, + startTimestamp: String(format: "9:%02d AM", minute), + endTimestamp: String(format: "9:%02d AM", minute + 1), + category: "Work", subcategory: "Coding", + title: title, summary: "", detailedSummary: "", day: "2026-06-16", + distractions: nil, videoSummaryURL: nil, otherVideoSummaryURLs: nil, appSites: nil + ) + } +} From 0df9ff139cc687b7b1f377b7cf8f2c72c4e9fe00 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 8 Jul 2026 05:50:01 +0530 Subject: [PATCH 2/2] test: add edge-case coverage for recap cards-text filtering/truncation Follow-up hardening pass. Adds 4 tests for uncovered branches: - empty card list -> "no activities" message - summary rendering: distinct summary included, summary==title dropped (no dup) - filter + truncation compose: "Processing failed" cards removed before the budget is measured, so they never consume budget or inflate the omitted count - boundary: no "omitted" notice when all cards fit (off-by-one guard) All 6 tests pass (2 original + 4 new); no production code changed. --- .../DailyRecapCardsTextTests.swift | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/Dayflow/DayflowTests/DailyRecapCardsTextTests.swift b/Dayflow/DayflowTests/DailyRecapCardsTextTests.swift index cbc939b0a..813c17f9a 100644 --- a/Dayflow/DayflowTests/DailyRecapCardsTextTests.swift +++ b/Dayflow/DayflowTests/DailyRecapCardsTextTests.swift @@ -33,13 +33,66 @@ final class DailyRecapCardsTextTests: XCTestCase { XCTAssertEqual(text, makeText()) } - private func card(title: String, minute: Int) -> TimelineCard { + func testEmptyCardListReturnsNoActivitiesMessage() { + let text = DailyRecapGenerator.makeCardsText(day: "2026-06-16", cards: []) + XCTAssertEqual(text, "No timeline activities were recorded for 2026-06-16.") + } + + func testIncludesDistinctSummaryButNotSummaryEqualToTitle() { + let withSummary = DailyRecapGenerator.makeCardsText( + day: "2026-06-16", + cards: [card(title: "Wrote the report", minute: 0, summary: "Drafted Q2 numbers")] + ) + XCTAssertTrue(withSummary.contains("Drafted Q2 numbers")) + + // A summary identical to the title is redundant and must be dropped, not + // printed twice. + let redundant = DailyRecapGenerator.makeCardsText( + day: "2026-06-16", + cards: [card(title: "Wrote the report", minute: 0, summary: "Wrote the report")] + ) + let occurrences = redundant.components(separatedBy: "Wrote the report").count - 1 + XCTAssertEqual(occurrences, 1) + } + + // Filtering "Processing failed" and character-budget truncation must compose: + // failed cards are removed BEFORE the budget is measured, so they never + // consume budget or count toward the "omitted" tally. + func testFilterAndTruncationCompose() { + var cards: [TimelineCard] = [] + for i in 0..<20 { + cards.append(card(title: "Processing failed", minute: i)) + cards.append(card(title: "Real activity \(i)", minute: i)) + } + let text = DailyRecapGenerator.makeCardsText( + day: "2026-06-16", cards: cards, maxCharacters: 300 + ) + XCTAssertFalse(text.contains("Processing failed")) + if text.contains("omitted") { + // The omitted count must reference only the 20 real cards, never the 40 total. + let omittedReal = (1...20).contains { text.contains("... and \($0) more") } + XCTAssertTrue(omittedReal, "omitted count should be <= 20 real activities, not include filtered cards") + } + } + + // Boundary: when everything fits, there must be NO truncation notice + // (off-by-one guard on the `includedCount < ordered.count` check). + func testNoOmissionNoticeWhenAllCardsFit() { + let cards = (0..<3).map { card(title: "Task \($0)", minute: $0) } + let text = DailyRecapGenerator.makeCardsText( + day: "2026-06-16", cards: cards, maxCharacters: 10_000 + ) + XCTAssertFalse(text.contains("omitted")) + XCTAssertTrue(text.contains("3. 9:02am - 9:03am: Task 2")) + } + + private func card(title: String, minute: Int, summary: String = "") -> TimelineCard { TimelineCard( recordId: nil, batchId: nil, startTimestamp: String(format: "9:%02d AM", minute), endTimestamp: String(format: "9:%02d AM", minute + 1), category: "Work", subcategory: "Coding", - title: title, summary: "", detailedSummary: "", day: "2026-06-16", + title: title, summary: summary, detailedSummary: "", day: "2026-06-16", distractions: nil, videoSummaryURL: nil, otherVideoSummaryURLs: nil, appSites: nil ) }