pageta-native/Sources/Core/Domain/Digest.swift
Till JS b1fd37f6d0 feat: Mehrfachauswahl + gezielte KI-Zusammenfassungen (alle/Auswahl)
Spiegel der Web-Funktion: Auswahl-Modus in der Lese-Liste mit Bulk-
Aktionen (Status, Archivieren, Tags, Löschen) und ein Zusammenfassen-
Sheet über alle oder gewählte Artikel — Synthese (verbunden) oder
einzeln je Artikel, lokal via ManaLLM.

- Digest.buildArticlesSynthesisPrompt (Selektions-Synthese)
- SummarizeArticlesView (Toggle Synthese/Einzeln, ShareLink-Export)
- LeseListeTabView: Auswahl-Modus, untere Bulk-Leiste, Sheet-Einstieg

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:44:29 +02:00

240 lines
8.2 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 scopeLabel = scope.label.lowercased()
return synthesisFromBriefs(
briefs: briefs,
highlights: highlights,
maxArticles: maxArticles,
maxHighlights: maxHighlights,
introLine: { n in "Hier ist, was die Person \(scopeLabel) gelesen hat (\(n) Artikel)." },
emptyHighlights: "(keine Markierungen in diesem Zeitraum)"
)
}
/// Synthese-Prompt über eine **freie Auswahl** von Artikeln (nicht zeit-
/// gefenstert) — Backend für „Zusammenfassen aus gewählten/allen Artikeln".
/// Gleiche Struktur wie `buildSynthesisPrompt`, nur der Einleitungssatz
/// spricht von ausgewählten statt gelesenen Artikeln, damit auch
/// ungelesene Auswahlen ehrlich beschrieben werden. Spiegel von
/// `buildArticlesSynthesisPrompt` in `pageta/packages/pageta-domain`.
static func buildArticlesSynthesisPrompt(
briefs: [DigestBrief],
highlights: [DigestHighlightLine] = [],
maxArticles: Int = 40,
maxHighlights: Int = 60
) -> SynthesisPrompt {
synthesisFromBriefs(
briefs: briefs,
highlights: highlights,
maxArticles: maxArticles,
maxHighlights: maxHighlights,
introLine: { n in "Hier sind \(n) Artikel, die die Person zum Zusammenfassen ausgewählt hat." },
emptyHighlights: "(keine Markierungen zu diesen Artikeln)"
)
}
/// Geteilter Rumpf der Synthese-Prompts (Scope- und Selektions-Variante).
/// Nur Einleitungssatz und Leer-Markierungstext sind parametrisiert —
/// der restliche Aufbau lebt an genau dieser Stelle.
private static func synthesisFromBriefs(
briefs: [DigestBrief],
highlights: [DigestHighlightLine],
maxArticles: Int,
maxHighlights: Int,
introLine: (Int) -> String,
emptyHighlights: String
) -> SynthesisPrompt {
let useBriefs = Array(briefs.prefix(maxArticles))
let useHighlights = Array(highlights.prefix(maxHighlights))
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
? emptyHighlights
: 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 = [
introLine(useBriefs.count),
"",
"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
)
}
}