Add opt-in Daily Digest (daily email of last 24h of library recaps)

Multi-mode, off by default. Each new recap is synthesized into a 1-2
paragraph overview via the relay (operator-absorbed) and cached onto the
session JSON; a daily 08:00 scan emails opted-in users their fresh
recaps, deduped by a per-user watermark that never skips a failed or
over-cap recap. One-click tokenized unsubscribe; settings-modal toggle;
admin test trigger. Bumps to 0.2.158.
This commit is contained in:
Keysat
2026-06-15 19:50:48 -05:00
parent 962423ca10
commit b4fa5d7be8
14 changed files with 1144 additions and 17 deletions
+248
View File
@@ -0,0 +1,248 @@
// Pure / injectable-logic tests for Daily Digest episode synthesis. The
// relay round-trip and FS cache write-back aren't exercised here (a fake
// provider stands in, save:false skips disk); this nails prompt shaping,
// the operator-string scrub backstop, and the get-or-generate cache gate.
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import {
buildOverviewPrompt,
scrubOperatorStrings,
synthesizeEpisodeOverview,
getOrCreateEpisodeOverview,
selectDigestEpisodes,
scopeForUser,
nextDigestWatermark,
} from "../daily-digest.js";
import { renderDigestEmail } from "../email-template.js";
const record = (over = {}) => ({
id: "1700000000000-abc",
title: "How Markets Work",
type: "podcast",
chunks: [
{ title: "Supply & demand", summary: "Prices clear where the two curves meet." },
{ title: "Information", summary: "Asymmetry distorts outcomes." },
],
...over,
});
// A provider stub recording calls, returning a fixed analyze result.
const fakeProvider = (text, sink = {}) => ({
async analyzeText(args) {
sink.calls = (sink.calls || 0) + 1;
sink.lastArgs = args;
return { text };
},
});
describe("buildOverviewPrompt", () => {
test("includes title, type, and each topic's title + summary", () => {
const p = buildOverviewPrompt(record());
assert.match(p, /"How Markets Work"/);
assert.match(p, /podcast episode/);
assert.match(p, /- Supply & demand: Prices clear/);
assert.match(p, /- Information: Asymmetry distorts/);
assert.match(p, /12 paragraph/);
});
test("a topic with no summary still contributes its title", () => {
const p = buildOverviewPrompt(
record({ chunks: [{ title: "Loose ends" }] }),
);
assert.match(p, /- Loose ends/);
assert.doesNotMatch(p, /Loose ends:/); // no trailing colon when summary absent
});
test("null-safe: missing fields fall back", () => {
const p = buildOverviewPrompt({});
assert.match(p, /"Untitled"/);
assert.match(p, /recording/); // unknown type
});
});
describe("scrubOperatorStrings", () => {
test("removes operator/infra tokens", () => {
const out = scrubOperatorStrings(
"Routed via Spark Control and the vLLM box on Parakeet.",
);
assert.doesNotMatch(out, /spark control/i);
assert.doesNotMatch(out, /vllm/i);
assert.doesNotMatch(out, /parakeet/i);
});
test("strips LAN hosts and private IPs, keeps public/content data", () => {
assert.doesNotMatch(
scrubOperatorStrings("see http://immense-voyage.local/admin"),
/\.local/,
);
assert.doesNotMatch(scrubOperatorStrings("host 192.168.1.42 here"), /192\.168/);
// A public dotted quad in content is the user's data, not a leak.
assert.match(scrubOperatorStrings("DNS is 8.8.8.8"), /8\.8\.8\.8/);
});
test("leaves ordinary prose intact", () => {
const clean = "The episode covers supply, demand, and information costs.";
assert.equal(scrubOperatorStrings(clean), clean);
});
test("null-safe", () => {
assert.equal(scrubOperatorStrings(null), "");
assert.equal(scrubOperatorStrings(""), "");
});
});
describe("synthesizeEpisodeOverview", () => {
test("scrubs the model result and passes a stable per-episode jobId", async () => {
const sink = {};
const out = await synthesizeEpisodeOverview(record(), {
provider: fakeProvider("A clear overview from Spark Control.", sink),
});
assert.doesNotMatch(out, /spark control/i);
assert.equal(sink.calls, 1);
assert.equal(sink.lastArgs.jobId, "digest-1700000000000-abc");
});
test("throws when there are no topics", async () => {
await assert.rejects(
() => synthesizeEpisodeOverview(record({ chunks: [] }), { provider: fakeProvider("x") }),
/no topic summaries/,
);
});
test("throws when the model returns nothing usable", async () => {
await assert.rejects(
() => synthesizeEpisodeOverview(record(), { provider: fakeProvider(" ") }),
/empty synthesis result/,
);
});
});
describe("getOrCreateEpisodeOverview", () => {
test("cache hit returns stored overview without calling the provider", async () => {
const sink = {};
const res = await getOrCreateEpisodeOverview({
record: record({ digestOverview: "Already done." }),
provider: fakeProvider("fresh", sink),
save: false,
});
assert.equal(res.cached, true);
assert.equal(res.overview, "Already done.");
assert.equal(sink.calls || 0, 0);
});
test("cache miss synthesizes (save:false skips disk)", async () => {
const sink = {};
const res = await getOrCreateEpisodeOverview({
record: record(),
provider: fakeProvider("A fresh overview.", sink),
save: false,
});
assert.equal(res.cached, false);
assert.equal(res.overview, "A fresh overview.");
assert.equal(sink.calls, 1);
});
});
describe("selectDigestEpisodes", () => {
const at = (iso, id) => ({ id, title: id, type: "youtube", url: "", createdAt: iso });
const sessions = [
at("2026-06-14T00:00:00.000Z", "old"),
at("2026-06-15T09:00:00.000Z", "a"),
at("2026-06-15T10:00:00.000Z", "b"),
];
const watermark = new Date("2026-06-15T08:00:00.000Z").getTime();
test("keeps only recaps created after the watermark, oldest first", () => {
const { episodes, total, overflow } = selectDigestEpisodes(sessions, watermark);
assert.deepEqual(episodes.map((e) => e.id), ["a", "b"]);
assert.equal(total, 2);
assert.equal(overflow, 0);
});
test("caps the list and reports the overflow", () => {
const many = Array.from({ length: 13 }, (_, i) =>
at(`2026-06-15T1${i % 10}:00:00.000Z`, `e${i}`),
);
const { episodes, overflow, total } = selectDigestEpisodes(many, 0, 10);
assert.equal(episodes.length, 10);
assert.equal(overflow, 3);
assert.equal(total, 13);
});
test("empty / malformed-date inputs", () => {
assert.deepEqual(selectDigestEpisodes([], watermark).episodes, []);
assert.deepEqual(selectDigestEpisodes(null, watermark).episodes, []);
assert.deepEqual(
selectDigestEpisodes([at("not-a-date", "x")], 0).episodes,
[],
);
});
test("null watermark treats everything as fresh", () => {
assert.equal(selectDigestEpisodes(sessions, null).total, 3);
});
});
describe("nextDigestWatermark", () => {
const t = (iso) => new Date(iso).getTime();
const e1 = "2026-06-15T09:00:00.000Z";
const e2 = "2026-06-15T10:00:00.000Z";
const e3 = "2026-06-15T11:00:00.000Z";
test("all sent → newest sent createdAt", () => {
assert.equal(nextDigestWatermark([e1, e2, e3], []), t(e3));
});
test("never advances past the oldest failure (so it's retried)", () => {
// sent e1 & e3, e2 failed → watermark just before e2, NOT now/e3.
assert.equal(nextDigestWatermark([e1, e3], [e2]), t(e2) - 1);
});
test("failures newer than everything sent don't pull the watermark back", () => {
assert.equal(nextDigestWatermark([e1, e2], [e3]), t(e2));
});
test("nothing sent → null (caller must not advance)", () => {
assert.equal(nextDigestWatermark([], [e1]), null);
assert.equal(nextDigestWatermark(null, null), null);
});
});
describe("scopeForUser", () => {
test("admin keeps the owner scope; everyone else is their id", () => {
assert.equal(scopeForUser({ id: "u1", is_admin: 1 }), "owner");
assert.equal(scopeForUser({ id: "u1", is_admin: 0 }), "u1");
});
});
describe("renderDigestEmail", () => {
const episodes = [
{ title: "First", type: "podcast", url: "https://x/1", overview: "Ov one." },
{ title: "Second", type: "youtube", url: "", overview: "Ov two." },
];
test("subject reflects count; body carries overviews + unsubscribe link", () => {
const m = renderDigestEmail({
episodes,
overflowCount: 3,
manageUrl: "https://recaps.cc/",
unsubscribeUrl: "https://recaps.cc/api/digest/unsubscribe?token=tok",
});
assert.match(m.subject, /2 new recaps/);
assert.match(m.html, /Ov one\./);
assert.match(m.html, /unsubscribe\?token=tok/);
assert.match(m.html, /3 more/);
assert.match(m.text, /Unsubscribe: https:\/\/recaps\.cc/);
});
test("singular subject for one recap", () => {
const m = renderDigestEmail({
episodes: [episodes[0]],
manageUrl: "https://recaps.cc/",
unsubscribeUrl: "https://recaps.cc/u",
});
assert.match(m.subject, /1 new recap\b/);
});
});