pageta-native/Sources/Core/Domain/Digest.swift
Till JS 7cb9399b4c feat: Lese-Rückblick (Tag/Woche/Gesamt) als nativer Tab
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>
2026-06-01 19:26:43 +02:00

197 lines
6.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
)
}
}