Pendant zur Web-Route /rueckblick. Synthetisiert, was man WIRKLICH gelesen hat (status=finished oder readingProgress>=0.5 im Fenster). - Core/Domain/Digest.swift: reine, getestete Logik — Swift-Spiegel von pageta-domain/digest.ts (Auswahl nach Zeitfenster + Lese-Signal, Theme-Gruppierung nach Tags, Prompt-Builder). 8 XCTest-Fälle, grün. - Features/RueckblickTabView.swift: deterministisches Gerüst steht IMMER (gratis, offline) — Kennzahlen, Themencluster, eigene Markierungen wörtlich. Darüber optionale Synthese via ManaLLM.generate (lokal-first; liefert sie nichts, bleibt das Gerüst nutzbar). Tag/Woche/Gesamt-Picker, Markdown-ShareLink, Tap → ArticleDetailView. - RootView: neuer Tab „Rückblick" (iOS TabView + macOS Sidebar), Icon clock.arrow.circlepath, nach Markierungen. - Motivations-Leitplanke (Wert 8): ruhiger Spiegel, keine Streaks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
197 lines
6.6 KiB
Swift
197 lines
6.6 KiB
Swift
import Foundation
|
||
|
||
/// Lese-Rückschau ("Rückblick") — reine, testbare Logik. Swift-Spiegel von
|
||
/// `pageta/packages/pageta-domain/src/digest.ts`. Frei von SwiftUI/LLM/Netz,
|
||
/// damit die View dünn bleibt und die Auswahl-/Gruppierungs-Regeln getestet
|
||
/// werden können — identisch zur Web-App.
|
||
///
|
||
/// Das deterministische Gerüst (Auswahl + Gruppen + Markierungen) steht IMMER
|
||
/// — auch ohne LLM, offline. Die Synthese (`buildSynthesisPrompt`) ist die
|
||
/// optionale Veredelung obendrauf.
|
||
|
||
enum DigestScope: String, CaseIterable, Identifiable, Sendable {
|
||
case day
|
||
case week
|
||
case all
|
||
|
||
var id: String { rawValue }
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .day: "Heute"
|
||
case .week: "Diese Woche"
|
||
case .all: "Gesamt"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Ein Artikel-Brief fürs Gerüst + die Synthese (das LLM bekommt nur das).
|
||
struct DigestBrief: Identifiable, Sendable {
|
||
let id: String
|
||
let title: String
|
||
let source: String?
|
||
/// In-App-Link bzw. Original-URL — der Caller entscheidet.
|
||
let url: String
|
||
let brief: String
|
||
let tags: [String]
|
||
}
|
||
|
||
/// Eine Markierung in Digest-Form (wörtlich, gruppiert nach Artikel/Quelle).
|
||
struct DigestHighlightLine: Sendable {
|
||
let articleId: String
|
||
let source: String?
|
||
let title: String
|
||
let text: String
|
||
let note: String?
|
||
}
|
||
|
||
struct ThemeGroup: Identifiable, Sendable {
|
||
var id: String { theme }
|
||
let theme: String
|
||
let articles: [DigestBrief]
|
||
}
|
||
|
||
struct SynthesisPrompt: Sendable {
|
||
let prompt: String
|
||
let instructions: String
|
||
let usedArticles: Int
|
||
let usedHighlights: Int
|
||
}
|
||
|
||
enum Digest {
|
||
static let untaggedTheme = "Ohne Thema"
|
||
|
||
static func scopeLabel(_ scope: DigestScope) -> String { scope.label }
|
||
|
||
/// ISO-8601 mit ODER ohne Sekundenbruchteile (die Events tragen `.000Z`).
|
||
static func parseISO(_ value: String?) -> Date? {
|
||
guard let value, !value.isEmpty else { return nil }
|
||
let withFractional = ISO8601DateFormatter()
|
||
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
if let date = withFractional.date(from: value) { return date }
|
||
let plain = ISO8601DateFormatter()
|
||
plain.formatOptions = [.withInternetDateTime]
|
||
return plain.date(from: value)
|
||
}
|
||
|
||
/// Zeitpunkt, an dem der Artikel zuletzt „gelesen" wurde: readAt →
|
||
/// updatedAt → savedAt. Fallback: ferne Vergangenheit.
|
||
static func engagedAt(_ article: Article) -> Date {
|
||
for iso in [article.readAt, article.updatedAt, article.savedAt] {
|
||
if let date = parseISO(iso) { return date }
|
||
}
|
||
return Date(timeIntervalSince1970: 0)
|
||
}
|
||
|
||
/// Fenster-Start (rollend). `all` → nil (kein unteres Limit).
|
||
static func windowStart(scope: DigestScope, now: Date) -> Date? {
|
||
switch scope {
|
||
case .day: now.addingTimeInterval(-86_400)
|
||
case .week: now.addingTimeInterval(-7 * 86_400)
|
||
case .all: nil
|
||
}
|
||
}
|
||
|
||
/// „Wirklich gelesen": fertig ODER substanziell gescrollt (>= 0.5).
|
||
/// Archivierte raus. Im Fenster, neueste zuerst.
|
||
static func selectArticles(_ articles: [Article], scope: DigestScope, now: Date) -> [Article] {
|
||
let start = windowStart(scope: scope, now: now)
|
||
return articles
|
||
.filter { $0.status != .archived }
|
||
.filter { $0.status == .finished || $0.readingProgress >= 0.5 }
|
||
.filter { start == nil ? true : engagedAt($0) >= start! }
|
||
.sorted { engagedAt($0) > engagedAt($1) }
|
||
}
|
||
|
||
/// Gruppiert nach **primärem** Thema (erster Tag); untaggte → "Ohne Thema",
|
||
/// die zuletzt. Gruppen nach Größe absteigend.
|
||
static func groupByTheme(_ briefs: [DigestBrief]) -> [ThemeGroup] {
|
||
var order: [String] = []
|
||
var buckets: [String: [DigestBrief]] = [:]
|
||
for brief in briefs {
|
||
let raw = brief.tags.first?.trimmingCharacters(in: .whitespaces) ?? ""
|
||
let theme = raw.isEmpty ? untaggedTheme : raw
|
||
if buckets[theme] == nil {
|
||
order.append(theme)
|
||
buckets[theme] = []
|
||
}
|
||
buckets[theme]?.append(brief)
|
||
}
|
||
return order
|
||
.map { ThemeGroup(theme: $0, articles: buckets[$0] ?? []) }
|
||
.sorted { lhs, rhs in
|
||
if lhs.theme == untaggedTheme { return false }
|
||
if rhs.theme == untaggedTheme { return true }
|
||
return lhs.articles.count > rhs.articles.count
|
||
}
|
||
}
|
||
|
||
/// Baut den Prompt für die LLM-Synthese: Artikel-Briefs + die wörtlichen
|
||
/// Markierungen → strukturierte deutsche Rückschau. Kürzt auf
|
||
/// `maxArticles`/`maxHighlights`; der Caller zeigt an, wenn gekürzt wurde.
|
||
static func buildSynthesisPrompt(
|
||
scope: DigestScope,
|
||
briefs: [DigestBrief],
|
||
highlights: [DigestHighlightLine],
|
||
maxArticles: Int = 40,
|
||
maxHighlights: Int = 60
|
||
) -> SynthesisPrompt {
|
||
let useBriefs = Array(briefs.prefix(maxArticles))
|
||
let useHighlights = Array(highlights.prefix(maxHighlights))
|
||
let scopeLabel = scope.label.lowercased()
|
||
|
||
let articleBlock = useBriefs.enumerated().map { index, b -> String in
|
||
let src = b.source.map { " (\($0))" } ?? ""
|
||
let brief = b.brief
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.replacingOccurrences(of: "\n", with: " ")
|
||
let trimmed = String(brief.prefix(400))
|
||
return "\(index + 1). \u{201E}\(b.title)\u{201C}\(src)\n \(trimmed.isEmpty ? "(kein Kurztext verfügbar)" : trimmed)"
|
||
}.joined(separator: "\n")
|
||
|
||
let highlightBlock: String = useHighlights.isEmpty
|
||
? "(keine Markierungen in diesem Zeitraum)"
|
||
: useHighlights.map { h in
|
||
let src = h.source.map { " — \($0)" } ?? ""
|
||
let note = h.note.map { "\n Notiz: \($0.trimmingCharacters(in: .whitespaces))" } ?? ""
|
||
return "• \u{201E}\(h.text.trimmingCharacters(in: .whitespaces))\u{201C}\(src)\(note)"
|
||
}.joined(separator: "\n")
|
||
|
||
let instructions = [
|
||
"Du bist ein ruhiger Lese-Begleiter. Fasse zusammen, was die Person gelesen hat —",
|
||
"als ehrlicher Spiegel, nicht als Werbung oder Bewertung. Schreibe auf Deutsch,",
|
||
"nüchtern und konkret. KEINE Floskeln, kein Lob, keine Motivationssprüche.",
|
||
"Erfinde nichts dazu, was nicht in den Briefs/Markierungen steht.",
|
||
].joined(separator: " ")
|
||
|
||
let prompt = [
|
||
"Hier ist, was die Person \(scopeLabel) gelesen hat (\(useBriefs.count) Artikel).",
|
||
"",
|
||
"ARTIKEL:",
|
||
articleBlock,
|
||
"",
|
||
"EIGENE MARKIERUNGEN DER PERSON (wörtlich, das war ihr wichtig):",
|
||
highlightBlock,
|
||
"",
|
||
"Erstelle daraus eine knappe Rückschau in genau dieser Struktur:",
|
||
"",
|
||
"## Überblick",
|
||
"(1–2 Sätze: Umfang und thematischer Schwerpunkt.)",
|
||
"",
|
||
"## Themen",
|
||
"(2–4 Cluster. Pro Cluster eine Zwischenüberschrift und 2–4 Stichpunkte,",
|
||
"die Artikel VERBINDEN statt einzeln nachzuerzählen.)",
|
||
"",
|
||
"## Roter Faden",
|
||
"(Wo ergänzen oder widersprechen sich die Artikel? 1–3 Sätze. Weglassen,",
|
||
"wenn es keine erkennbare Verbindung gibt.)",
|
||
].joined(separator: "\n")
|
||
|
||
return SynthesisPrompt(
|
||
prompt: prompt,
|
||
instructions: instructions,
|
||
usedArticles: useBriefs.count,
|
||
usedHighlights: useHighlights.count
|
||
)
|
||
}
|
||
}
|