@audiofool/moondog 0.1.0 → 0.2.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -6,28 +6,58 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { readSpotifyHistoryArchive } from "../integrations/spotify/history-archive.mjs";
|
|
7
7
|
import { readListenBrainzHistoryFile } from "../integrations/listenbrainz/history-file.mjs";
|
|
8
8
|
import { projectSpotifyRecentActivity } from "../integrations/spotify/recent-activity.mjs";
|
|
9
|
+
import { projectSpotifyLibrarySnapshot } from "../integrations/spotify/library-snapshot.mjs";
|
|
9
10
|
import { buildAppleMusicLibraryImport } from "../importers/apple-music-library/index.mjs";
|
|
10
11
|
import { readYouTubeMusicTakeout } from "../integrations/youtube-music/takeout.mjs";
|
|
11
12
|
import { readPublicPlaylist } from "../integrations/public-playlists.mjs";
|
|
13
|
+
import { readLastfmHistory } from "../integrations/lastfm/history.mjs";
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Spotify quick start: the latest plays plus the listener's library (saves,
|
|
17
|
+
* follows, own playlists and Spotify's top artists and tracks) in one preview.
|
|
18
|
+
* The library is optional so an older recent-plays-only sign-in still works.
|
|
19
|
+
*/
|
|
20
|
+
export function prepareSpotifyQuickImport({ page, librarySnapshot = null, libraryUnavailable = null, subjectId, capturedAt = new Date().toISOString() }) {
|
|
14
21
|
const bundle = projectSpotifyRecentActivity({ page, subjectId, capturedAt });
|
|
22
|
+
const library = librarySnapshot ? projectSpotifyLibrarySnapshot({ subjectId, snapshot: librarySnapshot, capturedAt }) : null;
|
|
15
23
|
const dates = bundle.listening_events.map((event) => event.occurred_at).sort();
|
|
16
24
|
return {
|
|
17
|
-
provider: "spotify-
|
|
25
|
+
provider: "spotify-quick",
|
|
18
26
|
bundle,
|
|
27
|
+
libraryBundle: library?.bundle ?? null,
|
|
19
28
|
preview: {
|
|
20
|
-
|
|
29
|
+
kind: "spotify-quick",
|
|
30
|
+
source: library ? "spotify-quick-library" : "spotify-quick-recent",
|
|
31
|
+
sourceLabel: library ? "Your Spotify library and recent plays" : "Spotify recent listening",
|
|
32
|
+
capturedAt,
|
|
21
33
|
listeningEvents: bundle.listening_events.length,
|
|
22
|
-
tracks: bundle.track_refs.
|
|
34
|
+
tracks: new Set([...bundle.track_refs, ...(library?.bundle.track_refs ?? [])].map((ref) => ref.track_ref_id)).size,
|
|
23
35
|
earliestListeningAt: dates[0],
|
|
24
36
|
latestListeningAt: dates.at(-1),
|
|
25
37
|
eventsWithPlayedMs: 0,
|
|
26
|
-
|
|
38
|
+
...(library ? { library: library.preview } : {}),
|
|
39
|
+
...(libraryUnavailable ? { libraryUnavailable } : {}),
|
|
40
|
+
scopeNote: library
|
|
41
|
+
? "Your library shows what you keep and what Spotify ranks highest for you. Recent plays are your latest 50. A history ZIP can add years of listening later."
|
|
42
|
+
: "Your latest plays, up to 50. Spotify doesn't say how long you listened here. A history ZIP can add the older ones later.",
|
|
27
43
|
},
|
|
28
44
|
};
|
|
29
45
|
}
|
|
30
46
|
|
|
47
|
+
/** Save both parts of an inspected quick import; returns one combined receipt. */
|
|
48
|
+
// Both writes are idempotent, so retrying after a partial failure is safe.
|
|
49
|
+
export function commitSpotifyQuickImport(store, prepared) {
|
|
50
|
+
const library = prepared.libraryBundle ? store.ingestImport(prepared.libraryBundle) : null;
|
|
51
|
+
const recent = store.ingest(prepared.bundle);
|
|
52
|
+
if (!library) return { ...recent, provider: "spotify-quick" };
|
|
53
|
+
return {
|
|
54
|
+
...recent, provider: "spotify-quick",
|
|
55
|
+
inserted_track_refs: (recent.inserted_track_refs ?? 0) + (library.inserted_track_refs ?? 0),
|
|
56
|
+
inserted_profile_evidence: library.inserted_profile_evidence ?? 0,
|
|
57
|
+
library_already_imported: library.already_imported === true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
31
61
|
function fail(code, message) {
|
|
32
62
|
const error = new Error(message);
|
|
33
63
|
error.code = code;
|
|
@@ -85,7 +115,11 @@ function literalPath(input) {
|
|
|
85
115
|
return path.resolve(decoded);
|
|
86
116
|
}
|
|
87
117
|
|
|
88
|
-
export async function prepareHistoryImport({ filePath, subjectId, provider: selectedProvider, capturedAt = new Date().toISOString(), fetchImpl } = {}) {
|
|
118
|
+
export async function prepareHistoryImport({ filePath, subjectId, provider: selectedProvider, capturedAt = new Date().toISOString(), fetchImpl, onProgress, signal } = {}) {
|
|
119
|
+
// Last.fm is read by username (or profile link) rather than from a file.
|
|
120
|
+
if (selectedProvider === "lastfm") {
|
|
121
|
+
return readLastfmHistory({ username: filePath, subjectId, capturedAt, onProgress, signal, ...(fetchImpl ? { fetchImpl } : {}) });
|
|
122
|
+
}
|
|
89
123
|
if (/https?:\/\//iu.test(filePath ?? "") && !String(filePath).startsWith("file:")) {
|
|
90
124
|
return readPublicPlaylist({ input: filePath, subjectId, capturedAt, provider: selectedProvider, fetchImpl });
|
|
91
125
|
}
|
|
@@ -115,6 +149,7 @@ export async function prepareHistoryImport({ filePath, subjectId, provider: sele
|
|
|
115
149
|
bundle,
|
|
116
150
|
preview: {
|
|
117
151
|
kind: "library",
|
|
152
|
+
source: "apple-library",
|
|
118
153
|
sourceLabel: "Apple Music library",
|
|
119
154
|
fileName: path.basename(resolvedPath),
|
|
120
155
|
tracks: bundle.trackRefs.length,
|
|
@@ -143,6 +178,7 @@ export async function prepareHistoryImport({ filePath, subjectId, provider: sele
|
|
|
143
178
|
provider,
|
|
144
179
|
bundle,
|
|
145
180
|
preview: {
|
|
181
|
+
source: provider === "listenbrainz" ? "listenbrainz" : extended ? "spotify-extended" : "spotify-account",
|
|
146
182
|
sourceLabel: provider === "listenbrainz" ? "ListenBrainz history" : extended ? "Spotify Extended Streaming History" : "Spotify Account Data",
|
|
147
183
|
fileName: path.basename(resolvedPath),
|
|
148
184
|
sourceFormat: bundle.import_batch.source_format,
|
|
@@ -194,6 +194,28 @@ export function createListenerCorrectionRetraction({
|
|
|
194
194
|
};
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Records a Like or Avoid the way the profile command does, or retracts the active choice for
|
|
199
|
+
* the same artist or track. A retraction with no such choice changes nothing.
|
|
200
|
+
*/
|
|
201
|
+
export function recordListenerChoice(store, {
|
|
202
|
+
subjectId, entityType, label, artistCredit, stance, occurredAt = new Date().toISOString(),
|
|
203
|
+
} = {}) {
|
|
204
|
+
if (stance !== "retract") {
|
|
205
|
+
return store.recordListenerCorrection({ subjectId, entityType, label, artistCredit, stance, occurredAt, recordedAt: occurredAt });
|
|
206
|
+
}
|
|
207
|
+
const key = (value) => typeof value === "string" ? normalizedIdentity(value.replace(/\s+/gu, " ").trim()) : "";
|
|
208
|
+
const active = store.listListenerCorrections({ subjectId, limit: 100 }).find((choice) =>
|
|
209
|
+
choice.entity_type === entityType && key(choice.label) === key(label) &&
|
|
210
|
+
(entityType !== "track" || key(choice.artist_credit) === key(artistCredit)));
|
|
211
|
+
if (!active) return { state: "not_found", entity_type: entityType, label };
|
|
212
|
+
return {
|
|
213
|
+
...store.retractListenerCorrection({ subjectId, correctionId: active.correction_id, occurredAt, recordedAt: occurredAt }),
|
|
214
|
+
stance: active.stance,
|
|
215
|
+
...(active.artist_credit ? { artist_credit: active.artist_credit } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
197
219
|
export function listenerCorrectionTargetKey(record) {
|
|
198
220
|
if (
|
|
199
221
|
!record ||
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { avoidKey } from "../core/listener-avoids.mjs";
|
|
4
|
+
import { recordedShare } from "./recorded-measures.mjs";
|
|
5
|
+
|
|
6
|
+
export const LISTENER_PROFILE_ANALYSIS_VERSION = "listener-profile/2";
|
|
7
|
+
export const LISTENER_PROFILE_SYNTHESIS_VERSION = "listener-synthesis/5";
|
|
8
|
+
// A saved reading is current only after its whole-profile check has run.
|
|
9
|
+
export const CHECKED_PROFILE_STATES = new Set(["passed", "revised", "flagged"]);
|
|
10
|
+
|
|
11
|
+
const LISTENER_SECTIONS = new Set(["listener_preferences", "listener_avoids"]);
|
|
12
|
+
// Curation already appears in its own sections; these provider rows only repeat it.
|
|
13
|
+
const SPOTIFY_TOP_KINDS = new Set(["top_artist_ranked", "top_track_ranked"]);
|
|
14
|
+
const DUPLICATED_PROVIDER_KINDS = new Set([
|
|
15
|
+
"playlist_track_added", "library_track_saved", "library_album_saved", "library_artist_followed",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const RANKED_LISTENING_SECTIONS = new Set(["history_artists", "history_tracks", "recent_artists", "recent_tracks"]);
|
|
19
|
+
const PLAY_COUNT_SECTIONS = new Set([...RANKED_LISTENING_SECTIONS, "history_releases"]);
|
|
20
|
+
// Below this share of plays with a recorded duration, minutes can't compare artists, years or
|
|
21
|
+
// sources (Last.fm and many ListenBrainz files record none), so the build compares by plays.
|
|
22
|
+
const TIMED_PLAY_SHARE = 0.95;
|
|
23
|
+
|
|
24
|
+
function measurement(listening) {
|
|
25
|
+
const plays = listening?.coverage?.effective_listening_events ?? 0;
|
|
26
|
+
const timed = listening?.coverage?.events_with_played_duration;
|
|
27
|
+
const time = timed === undefined ? "all" : recordedShare(plays, timed, TIMED_PLAY_SHARE);
|
|
28
|
+
// Listening rows state skips only where some play recorded them, so no row with skips means
|
|
29
|
+
// no play in the history recorded one.
|
|
30
|
+
const rows = Object.entries(listening?.sections ?? {})
|
|
31
|
+
.filter(([section]) => RANKED_LISTENING_SECTIONS.has(section)).flatMap(([, rows]) => rows);
|
|
32
|
+
const unskipped = rows.length > 0 && !rows.some(row => row.explicit_skips !== undefined);
|
|
33
|
+
return { plays, timed, time, unskipped };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const without = (row, keys) => Object.fromEntries(Object.entries(row).filter(([key]) => !keys.includes(key)));
|
|
37
|
+
|
|
38
|
+
// Listening rows already leave out what their plays never recorded. Here a row also drops minutes
|
|
39
|
+
// that can't compare it with others, and an Apple facet drops a play count none of its tracks
|
|
40
|
+
// carry. Rows that lose a measure are marked, because the host's prose explanations still state it.
|
|
41
|
+
function measuredRows(section, rows, { time }, withheld) {
|
|
42
|
+
const unrecordedKeys = row => section.startsWith("apple_")
|
|
43
|
+
? (recordedShare(row.library_tracks, row.tracks_with_play_count) === "none" ? ["play_count"] : [])
|
|
44
|
+
: time !== "all" ? ["listening_minutes"] : [];
|
|
45
|
+
const next = rows.map(row => {
|
|
46
|
+
const keys = unrecordedKeys(row).filter(key => key in row);
|
|
47
|
+
const topArtist = time !== "all" && row.top_artist && "listening_minutes" in row.top_artist;
|
|
48
|
+
if (!keys.length && !topArtist) return row;
|
|
49
|
+
const measured = { ...without(row, keys), ...(topArtist ? { top_artist: without(row.top_artist, ["listening_minutes"]) } : {}) };
|
|
50
|
+
withheld.add(measured);
|
|
51
|
+
return measured;
|
|
52
|
+
});
|
|
53
|
+
if (time === "all" || !PLAY_COUNT_SECTIONS.has(section)) return next;
|
|
54
|
+
// A row without skip data counts every play as engaged.
|
|
55
|
+
const engaged = row => row.engaged_play_count ?? row.play_count ?? 0;
|
|
56
|
+
return next.sort((left, right) => engaged(right) - engaged(left) || (right.play_count ?? 0) - (left.play_count ?? 0));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sectionSemantics(section, { time = "all" } = {}) {
|
|
60
|
+
const common = {
|
|
61
|
+
ordering: "Catalog order, not chronology or preference strength unless explicitly stated.",
|
|
62
|
+
limitations: "Row position and repeated rows are not independent evidence of importance. Use the row's dated fields for time claims.",
|
|
63
|
+
};
|
|
64
|
+
if (RANKED_LISTENING_SECTIONS.has(section)) {
|
|
65
|
+
return { ordering: time === "all" ? "Ranked by measured listening duration, descending."
|
|
66
|
+
: "Ranked by plays, descending, because listening time is not known for every play.",
|
|
67
|
+
limitations: "Attention in the imported history is not a direct statement of liking. Recent and lifetime views overlap; do not add their totals." };
|
|
68
|
+
}
|
|
69
|
+
if (section === "history_years") return { ...common,
|
|
70
|
+
ordering: time === "all" ? "top_artist is selected by measured listening duration, not play count. Row position is not a preference ranking."
|
|
71
|
+
: "top_artist is selected by measured listening duration where the year records it, otherwise by plays. Row position is not a preference ranking.",
|
|
72
|
+
limitations: "Years have unequal coverage. Sparse or missing imported records do not establish a gap in the listener's actual listening." };
|
|
73
|
+
if (section === "saved_tracks") return {
|
|
74
|
+
ordering: time === "all" ? "Measured listening duration descending, then playlist membership count descending, then title."
|
|
75
|
+
: "Not a preference ranking; listening time is not known for every play.",
|
|
76
|
+
limitations: "Row order is not save or addition chronology. Missing listening minutes mean no matched duration in this import, not proof the listener never played a saved track.",
|
|
77
|
+
};
|
|
78
|
+
if (section === "moondog_suggestions") return {
|
|
79
|
+
ordering: "Plays descending, then most recent.",
|
|
80
|
+
limitations: "Moondog queued or played these, so they are left out of every listening ranking and are not the listener's taste. adopted means the listener later played the song again on their own, which is a reaction to a suggestion, not a preference by itself.",
|
|
81
|
+
};
|
|
82
|
+
if (section === "playlist_tracks") return { ...common,
|
|
83
|
+
ordering: "Playlist membership count descending, then measured listening duration descending, then title." };
|
|
84
|
+
if (section.startsWith("apple_")) return {
|
|
85
|
+
ordering: section === "apple_tracks" ? "Imported preference strength descending, then snapshot play count descending, then internal stable identity."
|
|
86
|
+
: "Imported preference strength descending, then library track count descending, then name.",
|
|
87
|
+
limitations: "A library snapshot is not dated listening history or addition chronology. Missing play counts are unknown, not zero; aggregate counts cover only tracks_with_play_count. Versions and reissues are not independent evidence of attachment.",
|
|
88
|
+
};
|
|
89
|
+
return common;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function stableJson(value) {
|
|
93
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
94
|
+
if (value && typeof value === "object") return `{${Object.keys(value).sort()
|
|
95
|
+
.filter(key => value[key] !== undefined).map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
96
|
+
return JSON.stringify(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function profileDigest(value) {
|
|
100
|
+
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function text(value, maximum, field) {
|
|
104
|
+
if (typeof value !== "string" || !value.trim() || Array.from(value).length > maximum ||
|
|
105
|
+
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f--]/u.test(value)) {
|
|
106
|
+
throw new TypeError(`Listener profile ${field} is invalid`);
|
|
107
|
+
}
|
|
108
|
+
return value.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createListenerProfileInput({ subjectId, evidenceRevision, listening, apple }) {
|
|
112
|
+
const measured = measurement(listening);
|
|
113
|
+
const withheld = new Set();
|
|
114
|
+
const sections = Object.fromEntries(Object.entries({ ...listening?.sections, ...apple?.sections })
|
|
115
|
+
.map(([section, rows]) => [section, measuredRows(section, rows, measured, withheld)]));
|
|
116
|
+
const context = listening?.context ?? {};
|
|
117
|
+
const coverage = { ...listening?.coverage, ...apple?.coverage };
|
|
118
|
+
const evidence = new Map();
|
|
119
|
+
const unrecorded = new Set();
|
|
120
|
+
for (const [section, rows] of Object.entries(sections).sort(([a], [b]) => a.localeCompare(b))) {
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
const reference_id = `ev_${profileDigest([section, row]).slice(0, 32)}`;
|
|
123
|
+
evidence.set(reference_id, { reference_id, section, data: structuredClone(row) });
|
|
124
|
+
if (withheld.has(row)) unrecorded.add(reference_id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const manifest = {
|
|
128
|
+
analysis_version: LISTENER_PROFILE_ANALYSIS_VERSION,
|
|
129
|
+
coverage,
|
|
130
|
+
sources: listening?.sources ?? [], context,
|
|
131
|
+
sections: Object.entries(sections).map(([section, rows]) => ({ section, rows: rows.length, ...sectionSemantics(section, measured) })),
|
|
132
|
+
evidence_rows: evidence.size,
|
|
133
|
+
limitations: [
|
|
134
|
+
"Every supported record contributes to local analysis before the dossier selects rows. Rows are aggregates or observations, not a count of distinct plays.",
|
|
135
|
+
"History measures attention. Apple snapshot counts are separate from dated plays; private-session plays and superseded overlaps do not become behavioral evidence.",
|
|
136
|
+
"Provider labels are not listener assertions or measured audio features.",
|
|
137
|
+
...(measured.time === "none" ? ["No play in this history records how long it lasted, so listening time is unknown, not zero; rows rank by plays."] : []),
|
|
138
|
+
...(measured.time === "some" ? [`Listening time is known for only ${measured.timed} of ${measured.plays} plays, so minutes and hours cannot compare artists, years or sources; rows rank by plays, and coverage.listening_hours covers only the timed plays.`] : []),
|
|
139
|
+
...(measured.unskipped ? ["No play in this history is marked as skipped. These sources may not record skips, so skips and engaged plays are unknown, and having no skips says nothing about approval."] : []),
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
// Rows are content-addressed, so the ordered references cover every evidence value.
|
|
143
|
+
const input_digest = profileDigest({ subjectId, evidenceRevision, manifest, evidence: [...evidence.keys()] });
|
|
144
|
+
// Unrecorded measures change the evidence rows, so the digest already covers this set.
|
|
145
|
+
return { subjectId, input_digest, manifest, evidence, unrecorded };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function modelEvidence(entry, offset = 0, limit = 12) {
|
|
149
|
+
// Only music observations cross the model boundary. Local identities and
|
|
150
|
+
// provider handles remain in the private frozen evidence snapshot.
|
|
151
|
+
const fields = new Set([
|
|
152
|
+
"name", "label", "title", "artist_credit", "release", "genre", "entity_type", "stance", "asserted_at",
|
|
153
|
+
"first_played_at", "last_played_at", "source_label", "identity_status", "evidence_kind", "period",
|
|
154
|
+
"text", "playlist_name", "unit", "observed_at", "last_added_at", "play_count", "engaged_play_count",
|
|
155
|
+
"explicit_skips", "listening_minutes", "distinct_tracks", "year", "event_count", "first_observed_tracks",
|
|
156
|
+
"playlist_count", "library_tracks", "preferred_tracks", "tracks_with_play_count", "preference_strength",
|
|
157
|
+
"loved", "favorited", "rating_value", "rating_computed", "rank", "playlist_position", "value",
|
|
158
|
+
"stream_count", "played_seconds", "playlist_names", "top_artist", "first_year", "last_year", "active_years", "adopted",
|
|
159
|
+
"yearly_plays", "activation", "trajectory",
|
|
160
|
+
]);
|
|
161
|
+
const data = Object.fromEntries(Object.entries(entry.data).filter(([key]) => fields.has(key)));
|
|
162
|
+
if (data.top_artist) data.top_artist = Object.fromEntries(Object.entries(data.top_artist)
|
|
163
|
+
.filter(([key]) => ["name", "play_count", "listening_minutes"].includes(key)));
|
|
164
|
+
const continuations = {};
|
|
165
|
+
for (const [key, value] of Object.entries(data)) {
|
|
166
|
+
// A yearly series is already capped, and a page of it would hide the latest years.
|
|
167
|
+
if (Array.isArray(value) && key !== "yearly_plays") {
|
|
168
|
+
data[key] = value.slice(offset, offset + limit);
|
|
169
|
+
if (value.length > offset + limit) continuations[key] = { total: value.length, next_offset: offset + limit };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { ...entry, data, ...(Object.keys(continuations).length ? { continuations } : {}) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const artistKey = value => typeof value === "string" ? value.normalize("NFKC").trim().toLocaleLowerCase() : null;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A compact, deterministic view of the complete evidence for one synthesis
|
|
179
|
+
* session. Every row keeps its reference so the model can cite it; the full
|
|
180
|
+
* catalog stays searchable for anything the dossier leaves out.
|
|
181
|
+
*/
|
|
182
|
+
export function listenerProfileDossier(input) {
|
|
183
|
+
const rows = section => [...input.evidence.values()].filter(entry => entry.section === section);
|
|
184
|
+
const row = entry => ({ ref: entry.reference_id, ...modelEvidence(entry, 0, 6).data });
|
|
185
|
+
const take = (section, limit) => rows(section).slice(0, limit).map(row);
|
|
186
|
+
const lifetime = rows("history_artists");
|
|
187
|
+
const lifetimeRank = new Map(lifetime.map((entry, index) => [artistKey(entry.data.name), index + 1]));
|
|
188
|
+
const listened = new Set(lifetime.slice(0, 80).map(entry => artistKey(entry.data.name)));
|
|
189
|
+
const curated = new Map();
|
|
190
|
+
const curate = (name, entry, signal) => {
|
|
191
|
+
const key = artistKey(name);
|
|
192
|
+
if (!key || listened.has(key)) return;
|
|
193
|
+
const item = curated.get(key) ?? { artist: name, signals: [], refs: [] };
|
|
194
|
+
if (item.refs.length < 4) item.refs.push(entry.reference_id);
|
|
195
|
+
item.signals.push(signal);
|
|
196
|
+
curated.set(key, item);
|
|
197
|
+
};
|
|
198
|
+
for (const entry of rows("followed_artists")) curate(entry.data.name, entry, "followed");
|
|
199
|
+
for (const entry of rows("saved_albums")) curate(entry.data.artist_credit, entry, `saved album ${entry.data.label}`);
|
|
200
|
+
for (const entry of rows("apple_artists")) {
|
|
201
|
+
if ((entry.data.preferred_tracks ?? 0) >= 2 || (entry.data.library_tracks ?? 0) >= 20) {
|
|
202
|
+
curate(entry.data.name, entry, `Apple library: ${entry.data.library_tracks} tracks, ${entry.data.preferred_tracks ?? 0} preferred`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const entry of rows("playlist_tracks").slice(0, 300)) curate(entry.data.artist_credit, entry, `playlist track ${entry.data.label}`);
|
|
206
|
+
const provider = rows("provider_evidence").filter(entry => !DUPLICATED_PROVIDER_KINDS.has(entry.data.evidence_kind) && !SPOTIFY_TOP_KINDS.has(entry.data.evidence_kind));
|
|
207
|
+
// Spotify ranks the same names across three periods; one row per name keeps them comparable.
|
|
208
|
+
const spotifyTop = (kind, limit) => {
|
|
209
|
+
const grouped = new Map();
|
|
210
|
+
for (const entry of rows("provider_evidence").filter(entry => entry.data.evidence_kind === kind)) {
|
|
211
|
+
const key = artistKey(`${entry.data.label}\0${entry.data.artist_credit ?? ""}`);
|
|
212
|
+
const item = grouped.get(key) ?? { ref: entry.reference_id, label: entry.data.label,
|
|
213
|
+
...(entry.data.artist_credit ? { artist_credit: entry.data.artist_credit } : {}), ranks: {}, best: Infinity };
|
|
214
|
+
item.ranks[entry.data.period] = entry.data.rank;
|
|
215
|
+
if (entry.data.rank < item.best) Object.assign(item, { best: entry.data.rank, ref: entry.reference_id });
|
|
216
|
+
grouped.set(key, item);
|
|
217
|
+
}
|
|
218
|
+
return [...grouped.values()].sort((a, b) => a.best - b.best || Object.keys(b.ranks).length - Object.keys(a.ranks).length)
|
|
219
|
+
.slice(0, limit).map(({ best: _best, ...item }) => item);
|
|
220
|
+
};
|
|
221
|
+
const sections = Object.fromEntries(input.manifest.sections.map(({ section, rows: total, ordering, limitations }) =>
|
|
222
|
+
[section, { total, ordering, limitations }]));
|
|
223
|
+
return {
|
|
224
|
+
coverage: input.manifest.coverage, sources: input.manifest.sources, context: input.manifest.context,
|
|
225
|
+
sections,
|
|
226
|
+
lifetime_artists: lifetime.slice(0, 40).map(row),
|
|
227
|
+
next_lifetime_artists: lifetime.slice(40, 80).map(entry => ({ ref: entry.reference_id, name: entry.data.name,
|
|
228
|
+
listening_minutes: entry.data.listening_minutes, play_count: entry.data.play_count, distinct_tracks: entry.data.distinct_tracks,
|
|
229
|
+
trajectory: entry.data.trajectory })),
|
|
230
|
+
movement: {
|
|
231
|
+
note: "Trajectory labels are computed from each artist's share of yearly plays: fading and rising compare the latest counted year with the years before it, returning means whole years without plays, or a decline, and then plays again, burst means one earlier year holds most of the plays and the next year drops off at once. They describe attention over time, not liking.",
|
|
232
|
+
...Object.fromEntries(["fading", "returning", "rising", "burst"].map(label => [label, lifetime
|
|
233
|
+
.filter(entry => entry.data.trajectory === label).slice(0, 12)
|
|
234
|
+
.map(entry => ({ ref: entry.reference_id, name: entry.data.name, play_count: entry.data.play_count,
|
|
235
|
+
first_played_at: entry.data.first_played_at, last_played_at: entry.data.last_played_at,
|
|
236
|
+
yearly_plays: entry.data.yearly_plays }))])),
|
|
237
|
+
},
|
|
238
|
+
recent_artists: rows("recent_artists").slice(0, 25).map(entry => ({ ...row(entry),
|
|
239
|
+
lifetime_rank: lifetimeRank.get(artistKey(entry.data.name)) ?? null })),
|
|
240
|
+
years: take("history_years", 64),
|
|
241
|
+
lifetime_tracks: take("history_tracks", 25),
|
|
242
|
+
recent_tracks: take("recent_tracks", 15),
|
|
243
|
+
releases_in_depth: take("history_releases", 15),
|
|
244
|
+
curated_beyond_listening: [...curated.values()].sort((a, b) => b.signals.length - a.signals.length).slice(0, 20)
|
|
245
|
+
.map(item => ({ ...item, signals: item.signals.slice(0, 4) })),
|
|
246
|
+
saved_albums: take("saved_albums", 20),
|
|
247
|
+
saved_tracks: take("saved_tracks", 15),
|
|
248
|
+
playlist_tracks: take("playlist_tracks", 15),
|
|
249
|
+
followed_artists: rows("followed_artists").map(entry => ({ ref: entry.reference_id, name: entry.data.name,
|
|
250
|
+
listening_minutes: entry.data.listening_minutes ?? null })),
|
|
251
|
+
apple_artists: take("apple_artists", 20),
|
|
252
|
+
apple_genres: take("apple_genres", 20),
|
|
253
|
+
apple_tracks: take("apple_tracks", 15),
|
|
254
|
+
provider_genres: take("provider_genres", 15),
|
|
255
|
+
spotify_top: {
|
|
256
|
+
note: "Spotify's own affinity ranks (1 is highest) for the last four weeks, six months and about a year, read from the live account. Short-term ranks reflect recent attention; ranks held across all periods suggest something more enduring. Not play counts.",
|
|
257
|
+
artists: spotifyTop("top_artist_ranked", 40),
|
|
258
|
+
tracks: spotifyTop("top_track_ranked", 25),
|
|
259
|
+
},
|
|
260
|
+
provider_signals: provider.slice(0, 50).map(row),
|
|
261
|
+
explicit_choices: [...rows("listener_preferences"), ...rows("listener_avoids")].slice(0, 100).map(row),
|
|
262
|
+
...(rows("moondog_suggestions").length ? { played_from_moondog: {
|
|
263
|
+
note: "Plays Moondog itself queued or played. They are not in any ranking above and are not taste. adopted means the listener played the song again later on their own.",
|
|
264
|
+
plays: input.manifest.context.moondog_started_events_excluded ?? null,
|
|
265
|
+
tracks: rows("moondog_suggestions").length,
|
|
266
|
+
adopted_tracks: rows("moondog_suggestions").filter(entry => entry.data.adopted === true).length,
|
|
267
|
+
examples: take("moondog_suggestions", 8),
|
|
268
|
+
} } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validateClaim(input, raw) {
|
|
273
|
+
if (!["observation", "hypothesis"].includes(raw?.kind)) {
|
|
274
|
+
throw new TypeError("Only the listener can establish a direct preference");
|
|
275
|
+
}
|
|
276
|
+
const references = (values, minimum, role) => {
|
|
277
|
+
if (!Array.isArray(values) || values.length < minimum || values.length > 8 || new Set(values).size !== values.length) {
|
|
278
|
+
throw new TypeError(`Listener profile evidence reference is invalid: ${role} needs ${minimum} to 8 unique references`);
|
|
279
|
+
}
|
|
280
|
+
const unknown = values.filter(id => !input.evidence.has(id));
|
|
281
|
+
if (unknown.length) throw new TypeError(`Listener profile evidence reference is invalid: ${role} contains unknown IDs ${JSON.stringify(unknown)}. Copy exact refs from the dossier or the evidence tools.`);
|
|
282
|
+
return [...values];
|
|
283
|
+
};
|
|
284
|
+
const supporting_refs = references(raw.supporting_refs, 1, "supporting_refs");
|
|
285
|
+
const contradicting_refs = references(raw.contradicting_refs ?? [], 0, "contradicting_refs");
|
|
286
|
+
if (contradicting_refs.some(id => supporting_refs.includes(id))) throw new TypeError("Supporting and conflicting evidence must differ");
|
|
287
|
+
const claim = {
|
|
288
|
+
kind: raw.kind, statement: text(raw.statement, 700, "statement"),
|
|
289
|
+
scope: text(raw.scope, 300, "scope"), uncertainty: text(raw.uncertainty, 500, "uncertainty"),
|
|
290
|
+
supporting_refs, contradicting_refs,
|
|
291
|
+
};
|
|
292
|
+
return { claim_id: `claim_${profileDigest(claim).slice(0, 32)}`, ...claim };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Direct choices come from the listener's own records, never from the model. */
|
|
296
|
+
export function listenerAssertionClaims(input) {
|
|
297
|
+
return [...input.evidence.values()].filter(entry => LISTENER_SECTIONS.has(entry.section)).map(({ reference_id, section, data }) => {
|
|
298
|
+
const stance = section === "listener_avoids" ? "avoid" : "like";
|
|
299
|
+
const direct = Boolean(data.correction_id);
|
|
300
|
+
const claim = {
|
|
301
|
+
kind: direct ? "listener_assertion" : "observation",
|
|
302
|
+
statement: `${data.label ?? data.name}${data.artist_credit ? ` by ${data.artist_credit}` : ""}: ${stance === "avoid" ? "keep out of suggestions" : "explicit positive choice"}.`,
|
|
303
|
+
scope: data.entity_type ?? "listener choice",
|
|
304
|
+
uncertainty: direct ? "An explicit listener choice; it does not rewrite past listening."
|
|
305
|
+
: "An exclusion or choice in imported source data; it is not a new statement by the listener.",
|
|
306
|
+
stance, supporting_refs: [reference_id], contradicting_refs: [],
|
|
307
|
+
};
|
|
308
|
+
return { claim_id: `claim_${profileDigest(claim).slice(0, 32)}`, ...claim };
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function compileListenerProfile(input, submission) {
|
|
313
|
+
const needsInsights = [...input.evidence.values()].some(entry => !LISTENER_SECTIONS.has(entry.section));
|
|
314
|
+
if (!Array.isArray(submission?.insights) || submission.insights.length > 12 || (needsInsights && !submission.insights.length)) {
|
|
315
|
+
throw new TypeError("Listener profile synthesis needs one to twelve evidence-linked insights");
|
|
316
|
+
}
|
|
317
|
+
const insights = submission.insights.map(raw => validateClaim(input, raw));
|
|
318
|
+
const claims = [...new Map([...insights, ...listenerAssertionClaims(input)].map(claim => [claim.claim_id, claim])).values()];
|
|
319
|
+
const referenced = new Set(claims.flatMap(claim => [...claim.supporting_refs, ...claim.contradicting_refs]));
|
|
320
|
+
return {
|
|
321
|
+
analysis_version: LISTENER_PROFILE_ANALYSIS_VERSION,
|
|
322
|
+
synthesis_version: LISTENER_PROFILE_SYNTHESIS_VERSION,
|
|
323
|
+
input_digest: input.input_digest,
|
|
324
|
+
summary: text(submission.summary, 1_600, "summary"),
|
|
325
|
+
coverage: input.manifest,
|
|
326
|
+
claims, highlight_claim_ids: [...new Set(insights.map(claim => claim.claim_id))],
|
|
327
|
+
evidence: Object.fromEntries([...referenced].map(id => [id, input.evidence.get(id)])),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Whether two evidence maps hold the same Likes and Avoids. New plays change neither.
|
|
332
|
+
export function sameListenerChoices(evidence, otherEvidence) {
|
|
333
|
+
const choices = (entries) => listenerAssertionClaims({ evidence: entries }).map(claim => claim.claim_id).sort().join("\n");
|
|
334
|
+
return choices(evidence) === choices(otherEvidence);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const CHOICES_SHOWN = 30;
|
|
338
|
+
|
|
339
|
+
// A choice as the model reads it: no correction or evidence IDs.
|
|
340
|
+
function choiceItems(evidence) {
|
|
341
|
+
return [...evidence.values()].filter(entry => LISTENER_SECTIONS.has(entry.section)).map(({ section, data }) => ({
|
|
342
|
+
key: [section, data.entity_type, avoidKey(data.label ?? data.name), avoidKey(data.artist_credit)].join("\0"),
|
|
343
|
+
choice: {
|
|
344
|
+
stance: section === "listener_avoids" ? "avoid" : "like",
|
|
345
|
+
subject: data.entity_type ?? "artist", name: data.label ?? data.name,
|
|
346
|
+
...(data.artist_credit ? { artist: data.artist_credit } : {}),
|
|
347
|
+
...(data.asserted_at ? { chosen_at: data.asserted_at } : {}),
|
|
348
|
+
...(data.correction_id ? {} : { from_import: true }),
|
|
349
|
+
},
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The listener's choices now, newest first, and how they differ from the ones a reading was built with.
|
|
354
|
+
function listenerChoices(current, previous) {
|
|
355
|
+
const now = choiceItems(current);
|
|
356
|
+
const then = choiceItems(previous);
|
|
357
|
+
const direct = now.filter(item => !item.choice.from_import)
|
|
358
|
+
.sort((left, right) => (right.choice.chosen_at ?? "").localeCompare(left.choice.chosen_at ?? ""));
|
|
359
|
+
const missing = (items, others) => {
|
|
360
|
+
const keys = new Set(others.map(item => item.key));
|
|
361
|
+
return items.filter(item => !keys.has(item.key)).slice(0, CHOICES_SHOWN).map(item => item.choice);
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
listener_choices: { total: direct.length, items: direct.slice(0, CHOICES_SHOWN).map(item => item.choice) },
|
|
365
|
+
choices_since_reading: { added: missing(now, then), retracted: missing(then, now) },
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The listener's current choices read straight from their saved corrections, newest first, for
|
|
370
|
+
// when there is no saved reading to compare them with.
|
|
371
|
+
export function savedListenerChoices(corrections, total) {
|
|
372
|
+
return {
|
|
373
|
+
total,
|
|
374
|
+
items: corrections.slice(0, CHOICES_SHOWN).map(choice => ({
|
|
375
|
+
stance: choice.stance, subject: choice.entity_type, name: choice.label,
|
|
376
|
+
...(choice.artist_credit ? { artist: choice.artist_credit } : {}),
|
|
377
|
+
...(choice.occurred_at ? { chosen_at: choice.occurred_at } : {}),
|
|
378
|
+
})),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function readListenerProfile({ input, getInput, store, getChoices }, { offset = 0, limit = 12, claimId, revisionId } = {}) {
|
|
383
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20) {
|
|
384
|
+
throw new TypeError("Saved profile page is invalid");
|
|
385
|
+
}
|
|
386
|
+
const current = store.current();
|
|
387
|
+
const revision = revisionId ? store.revision(revisionId) : current;
|
|
388
|
+
if (revisionId && !revision) throw new Error("That saved profile version is unavailable");
|
|
389
|
+
if (!revision) {
|
|
390
|
+
const choices = getChoices?.();
|
|
391
|
+
return { state: "missing", total_claims: 0, claims: [], next_offset: null, ...(choices ? { listener_choices: choices } : {}) };
|
|
392
|
+
}
|
|
393
|
+
// Most new listeners have no saved reading. Do not scan their source archive
|
|
394
|
+
// merely to tell an ordinary conversation that the reading is missing.
|
|
395
|
+
input ??= getInput();
|
|
396
|
+
const analysisHolds = revision.synthesis_version === LISTENER_PROFILE_SYNTHESIS_VERSION &&
|
|
397
|
+
CHECKED_PROFILE_STATES.has(revision.verification?.state);
|
|
398
|
+
const state = analysisHolds && revision.input_digest === input.input_digest ? "current" : "stale";
|
|
399
|
+
const result = {
|
|
400
|
+
state,
|
|
401
|
+
// New plays alone change the input. A reading whose analysis and listener choices
|
|
402
|
+
// still hold stays usable as taste up to the listening it was built from.
|
|
403
|
+
...(state === "stale" && analysisHolds && sameListenerChoices(new Map(Object.entries(revision.evidence ?? {})), input.evidence)
|
|
404
|
+
? { newer_listening_only: true } : {}),
|
|
405
|
+
revision_id: revision.revision_id, sequence: revision.sequence,
|
|
406
|
+
parent_revision_id: revision.parent_revision_id, built_at: revision.built_at, model: revision.model,
|
|
407
|
+
synthesis_version: revision.synthesis_version,
|
|
408
|
+
verification: revision.verification,
|
|
409
|
+
summary: revision.summary, coverage: revision.coverage,
|
|
410
|
+
total_claims: revision.claims.length,
|
|
411
|
+
claims: revision.claims.slice(offset, offset + limit), offset,
|
|
412
|
+
next_offset: offset + limit < revision.claims.length ? offset + limit : null,
|
|
413
|
+
highlights: revision.highlight_claim_ids.map(id => {
|
|
414
|
+
const index = revision.claims.findIndex(claim => claim.claim_id === id);
|
|
415
|
+
return { ...revision.claims[index], finding_number: index + 1 };
|
|
416
|
+
}),
|
|
417
|
+
listener_assertions: revision.claims.flatMap((claim, index) => claim.kind === "listener_assertion"
|
|
418
|
+
? [{ ...claim, finding_number: index + 1 }] : []),
|
|
419
|
+
...listenerChoices(input.evidence ?? new Map(), new Map(Object.entries(revision.evidence ?? {}))),
|
|
420
|
+
};
|
|
421
|
+
if (claimId !== undefined) {
|
|
422
|
+
const claim = revision.claims.find(item => item.claim_id === claimId);
|
|
423
|
+
if (!claim) throw new Error("That finding is not part of this saved profile");
|
|
424
|
+
result.claim = claim;
|
|
425
|
+
result.supporting_evidence = claim.supporting_refs.map(ref => modelEvidence(revision.evidence[ref]));
|
|
426
|
+
result.contradicting_evidence = claim.contradicting_refs.map(ref => modelEvidence(revision.evidence[ref]));
|
|
427
|
+
}
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function compactListenerProfile(value) {
|
|
432
|
+
// With no reading, the listener's own choices still hold.
|
|
433
|
+
if (!value || value.state === "missing") {
|
|
434
|
+
return { state: "missing", ...(value?.listener_choices?.total ? { listener_choices: value.listener_choices } : {}) };
|
|
435
|
+
}
|
|
436
|
+
const coverage = value.coverage?.coverage ?? {};
|
|
437
|
+
const flagged = value.verification?.state === "flagged"
|
|
438
|
+
? value.verification.checks.at(-1).issues.slice(0, 3).map(issue => issue.problem) : [];
|
|
439
|
+
const behind = value.state === "stale" && value.newer_listening_only === true;
|
|
440
|
+
// A stale reading still shows what it said, marked as the previous one, under the current choices.
|
|
441
|
+
const previous = value.state === "stale" && !behind && typeof value.summary === "string";
|
|
442
|
+
const since = value.choices_since_reading;
|
|
443
|
+
return {
|
|
444
|
+
state: behind ? "current_through" : value.state, revision_id: value.revision_id, sequence: value.sequence, built_at: value.built_at,
|
|
445
|
+
...(value.state === "current" || behind || previous ? {
|
|
446
|
+
...(behind ? { note: "Built from listening up to coverage.last_played_at. Plays stored since then are not in this reading; listening coverage shows them. Listener choices and analysis are unchanged." } : {}),
|
|
447
|
+
...(previous ? { previous_reading: true,
|
|
448
|
+
note: "The previous reading. It predates listener_choices and choices_since_reading, or the analysis has changed since. Use it only as background: where they differ, the listener's choices win. Update the reading before treating it as current taste." } : {}),
|
|
449
|
+
summary: value.summary,
|
|
450
|
+
claims: value.highlights,
|
|
451
|
+
// A previous reading's own choices may be out of date; listener_choices holds the current ones.
|
|
452
|
+
...(previous ? {} : { listener_assertions: value.listener_assertions }),
|
|
453
|
+
coverage: { listening_events: coverage.effective_listening_events ?? null,
|
|
454
|
+
first_played_at: coverage.earliest_played_at ?? null, last_played_at: coverage.latest_played_at ?? null,
|
|
455
|
+
library_tracks: coverage.apple_library_tracks ?? null },
|
|
456
|
+
...(flagged.length ? { unresolved_check_notes: flagged } : {}),
|
|
457
|
+
} : { message: "Source evidence, listener choices or analysis changed. Update the reading before using the previous interpretation as current taste." }),
|
|
458
|
+
...(value.listener_choices ? { listener_choices: value.listener_choices } : {}),
|
|
459
|
+
...(since?.added?.length || since?.retracted?.length ? { choices_since_reading: since } : {}),
|
|
460
|
+
};
|
|
461
|
+
}
|