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>
240 lines
8.2 KiB
Swift
240 lines
8.2 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 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
|
||
)
|
||
}
|
||
}
|