Skip to content
Open
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
38 changes: 30 additions & 8 deletions Dayflow/Dayflow/Core/AI/DailyRecapGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
99 changes: 99 additions & 0 deletions Dayflow/DayflowTests/DailyRecapCardsTextTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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())
}

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: summary, detailedSummary: "", day: "2026-06-16",
distractions: nil, videoSummaryURL: nil, otherVideoSummaryURLs: nil, appSites: nil
)
}
}