Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/db/migrations/1781255870971_chat-scopes-default-off.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate";

export const shorthands: ColumnDefinitions | undefined = undefined;

/**
* Flip chat scoping to **default-OFF**. A chat is now summarized/suggested only
* when it has an explicit `included = true` row; an unscoped chat is excluded.
* This complements the read-side change (listScopes / listIncludedGroupIds /
* selectActiveGroups), so categorizing a chat without explicitly including it no
* longer silently opts it in. Reversible — `down` restores the default-ON default.
*/
export async function up(pgm: MigrationBuilder): Promise<void> {
pgm.sql(`ALTER TABLE chat_scopes ALTER COLUMN included SET DEFAULT false;`);
}

export async function down(pgm: MigrationBuilder): Promise<void> {
pgm.sql(`ALTER TABLE chat_scopes ALTER COLUMN included SET DEFAULT true;`);
}
10 changes: 5 additions & 5 deletions src/db/repositories/chat-scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,19 @@ describe("chat-scopes repository", () => {
}

describe("listIncludedGroupIds (the digest filter)", () => {
it("default-on: a group with no scope row is included; excluded/removed are filtered", async () => {
it("default-off: only an explicitly-included group qualifies; unscoped/excluded/removed are filtered", async () => {
const noScope = await seedGroupWithMessage("cs-noscope");
const included = await seedGroupWithMessage("cs-included");
const excluded = await seedGroupWithMessage("cs-excluded");
const removed = await seedGroupWithMessage("cs-removed");

await upsertScope(pool, { groupId: included, included: true });
await upsertScope(pool, { groupId: excluded, included: false });
await upsertScope(pool, { groupId: removed, removed: true });
await upsertScope(pool, { groupId: removed, included: true, removed: true });

const ids = await listIncludedGroupIds(pool);
expect(ids).toContain(noScope);
expect(ids).toContain(included);
expect(ids).not.toContain(noScope);
expect(ids).not.toContain(excluded);
expect(ids).not.toContain(removed);
});
Expand All @@ -80,10 +80,10 @@ describe("chat-scopes repository", () => {
});

describe("listScopes", () => {
it("projects an un-scoped group as included/uncategorized/not-removed", async () => {
it("projects an un-scoped group as excluded/uncategorized/not-removed (default-off)", async () => {
await seedGroupWithMessage("cs-projection");
const row = (await listScopes(pool)).find((r) => r.group === "cs-projection")!;
expect(row).toMatchObject({ included: true, categoryId: null, removed: false });
expect(row).toMatchObject({ included: false, categoryId: null, removed: false });
expect(row.messageCount).toBe(1);
});
});
Expand Down
15 changes: 8 additions & 7 deletions src/db/repositories/chat-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export type ScopeUpdate = {

/**
* All groups LEFT-JOINed to their scope. A group with no chat_scopes row reports
* `included: true, categoryId: null, removed: false` — the default-on rule.
* `included: false, categoryId: null, removed: false` — the default-OFF rule:
* nothing is summarized until the user explicitly includes it.
*/
export async function listScopes(client: pg.Pool | pg.PoolClient): Promise<ScopeRow[]> {
const { rows } = await client.query<{
Expand All @@ -38,7 +39,7 @@ export async function listScopes(client: pg.Pool | pg.PoolClient): Promise<Scope
g.source,
COUNT(m.id) AS message_count,
MAX(m.sent_at) AS last_message_at,
COALESCE(cs.included, true) AS included,
COALESCE(cs.included, false) AS included,
cs.category_id,
(cs.removed_at IS NOT NULL) AS removed
FROM groups g
Expand Down Expand Up @@ -110,17 +111,17 @@ export async function upsertScopes(
}

/**
* The digest filter: group ids that should be summarized. A group with no scope
* row is included (default-on); explicit `included=false` or a set `removed_at`
* excludes it.
* The digest filter: group ids that should be summarized. Default-OFF — only a
* group with an explicit `included = true` row (and no `removed_at`) qualifies;
* an unscoped group is excluded until the user opts it in.
*/
export async function listIncludedGroupIds(client: pg.Pool | pg.PoolClient): Promise<number[]> {
const { rows } = await client.query<{ id: string }>(
`
SELECT g.id
FROM groups g
LEFT JOIN chat_scopes cs ON cs.group_id = g.id
WHERE cs.id IS NULL OR (cs.included AND cs.removed_at IS NULL)
JOIN chat_scopes cs ON cs.group_id = g.id
WHERE cs.included AND cs.removed_at IS NULL
ORDER BY g.id ASC
`,
);
Expand Down
26 changes: 17 additions & 9 deletions src/scheduler/enqueue-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,13 @@ function makeFakeBus(): JobBus & { calls: EnqueueCall[]; failForGroupId?: string
// Seed helpers
// ---------------------------------------------------------------------------

async function seedGroup(pool: pg.Pool, name: string): Promise<number> {
return upsertGroup(pool, { name, source: "import" });
// Scope is now default-OFF, so a group is only summarized when explicitly
// included. These tests exercise watermark/change logic, not scoping, so by
// default we opt every seeded group in; the scope-specific test opts out.
async function seedGroup(pool: pg.Pool, name: string, include = true): Promise<number> {
const id = await upsertGroup(pool, { name, source: "import" });
if (include) await upsertScope(pool, { groupId: id, included: true });
return id;
}

async function seedMessage(
Expand Down Expand Up @@ -146,27 +151,30 @@ describe("enqueueScheduledRun", () => {
expect(result.enqueued + result.skipped).toBeGreaterThanOrEqual(2);
});

it("skips excluded/removed chats but keeps un-scoped (default-on), even with all:true", async () => {
it("enqueues only explicitly-included chats; skips un-scoped/excluded/removed, even with all:true", async () => {
const bus = makeFakeBus();
const ts = new Date("2026-06-05T07:00:00Z");

const unscoped = await seedGroup(pool, "scope-unscoped");
const included = await seedGroup(pool, "scope-included"); // auto included:true
await seedMessage(pool, included, "scope-inc-1", ts);
const unscoped = await seedGroup(pool, "scope-unscoped", false); // no scope row
await seedMessage(pool, unscoped, "scope-uns-1", ts);
const excluded = await seedGroup(pool, "scope-excluded");
const excluded = await seedGroup(pool, "scope-excluded", false);
await seedMessage(pool, excluded, "scope-exc-1", ts);
const removed = await seedGroup(pool, "scope-removed");
const removed = await seedGroup(pool, "scope-removed", false);
await seedMessage(pool, removed, "scope-rem-1", ts);

await upsertScope(pool, { groupId: excluded, included: false });
await upsertScope(pool, { groupId: removed, removed: true });
await upsertScope(pool, { groupId: removed, included: true, removed: true });

// all:true ignores the watermark but must NOT resurrect excluded/removed chats.
// all:true ignores the watermark but must NOT resurrect un-scoped/excluded chats.
await enqueueScheduledRun(pool, bus, { all: true });

const ids = bus.calls
.filter((c) => c.type === "summarize.group")
.map((c) => String(c.payload["groupId"]));
expect(ids).toContain(String(unscoped));
expect(ids).toContain(String(included));
expect(ids).not.toContain(String(unscoped));
expect(ids).not.toContain(String(excluded));
expect(ids).not.toContain(String(removed));
});
Expand Down
8 changes: 4 additions & 4 deletions src/scheduler/enqueue-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ export async function enqueueScheduledRun(
let skipped = 0;

try {
// Only included chats are summarized (S4 scope filter). A group with no scope
// row is included (default-on); explicitly excluded/removed chats are skipped.
// `opts.all` ignores the watermark, NOT the scope — a forced run must not
// resurrect excluded chats.
// Only included chats are summarized (S4 scope filter, default-OFF): a group
// is processed only when it has an explicit `included = true` row — an
// un-scoped chat is skipped. `opts.all` ignores the watermark, NOT the scope
// — a forced run must not resurrect un-scoped/excluded chats.
const includedIds = await listIncludedGroupIds(pool);

for (const groupId of includedIds) {
Expand Down
13 changes: 10 additions & 3 deletions src/summarization/select-active-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("selectActiveGroups", () => {
await insertMessages(pool, [row]);
}

it("returns only chats with content in the range, ordered by name", async () => {
it("returns only included chats with content in the range, ordered by name", async () => {
const work = await upsertGroup(pool, { name: "Work", source: "import" });
const family = await upsertGroup(pool, { name: "Family", source: "import" });
const old = await upsertGroup(pool, { name: "Old", source: "import" });
Expand All @@ -48,6 +48,8 @@ describe("selectActiveGroups", () => {
await seed(work, new Date("2026-06-06T09:00:00.000Z"), "w1");
await seed(family, new Date("2026-06-06T10:00:00.000Z"), "f1");
await seed(old, new Date("2026-06-01T10:00:00.000Z"), "o1"); // before `since` → excluded
// Default-off: a chat is summarized only when explicitly included.
for (const groupId of [work, family, old]) await upsertScope(pool, { groupId, included: true });

const groups = await selectActiveGroups(pool, { since });
expect(groups.map((g) => g.name)).toEqual(["Family", "Work"]);
Expand All @@ -56,6 +58,7 @@ describe("selectActiveGroups", () => {
it("excludes chats whose only in-range messages are system/empty", async () => {
const since = new Date("2026-06-06T00:00:00.000Z");
const sys = await upsertGroup(pool, { name: "SystemOnly", source: "import" });
await upsertScope(pool, { groupId: sys, included: true });
const participantId = await upsertParticipant(pool, "Dana");
await insertMessages(pool, [
{
Expand All @@ -78,20 +81,24 @@ describe("selectActiveGroups", () => {
expect(groups.find((g) => g.name === "SystemOnly")).toBeUndefined();
});

it("excludes scope-excluded and removed chats, keeps un-scoped (default-on)", async () => {
it("keeps only explicitly-included chats; excludes un-scoped, excluded and removed (default-off)", async () => {
const since = new Date("2026-06-07T00:00:00.000Z");
const ts = new Date("2026-06-07T09:00:00.000Z");
const keep = await upsertGroup(pool, { name: "ScopeKeep", source: "import" });
const none = await upsertGroup(pool, { name: "ScopeNone", source: "import" });
const drop = await upsertGroup(pool, { name: "ScopeDrop", source: "import" });
const gone = await upsertGroup(pool, { name: "ScopeGone", source: "import" });
await seed(keep, ts, "sk1");
await seed(none, ts, "sn1");
await seed(drop, ts, "sd1");
await seed(gone, ts, "sg1");
await upsertScope(pool, { groupId: keep, included: true });
await upsertScope(pool, { groupId: drop, included: false });
await upsertScope(pool, { groupId: gone, removed: true });
await upsertScope(pool, { groupId: gone, included: true, removed: true });

const names = (await selectActiveGroups(pool, { since })).map((g) => g.name);
expect(names).toContain("ScopeKeep");
expect(names).not.toContain("ScopeNone"); // un-scoped is now excluded
expect(names).not.toContain("ScopeDrop");
expect(names).not.toContain("ScopeGone");
});
Expand Down
9 changes: 5 additions & 4 deletions src/summarization/select-active-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ export type ActiveGroup = { id: number; name: string };
* transcript/media content predicate as selectMessages so "active" matches what
* would actually be summarized. Ordered by name for stable display.
*
* Scope-filtered (S4): a chat explicitly excluded or removed in `chat_scopes` is
* left out of the cross-chat total; a chat with no scope row stays in (default-on).
* Scope-filtered (S4, default-OFF): only a chat with an explicit `included = true`
* row (and no `removed_at`) is summarized; a chat with no scope row is excluded
* until the user opts it in.
*/
export async function selectActiveGroups(
client: pg.Pool | pg.PoolClient,
Expand All @@ -29,9 +30,9 @@ export async function selectActiveGroups(
NULLIF(trim(a.description), ''),
NULLIF(trim(t.transcript), '')
) <> ''
AND NOT EXISTS (
AND EXISTS (
SELECT 1 FROM chat_scopes cs
WHERE cs.group_id = g.id AND (NOT cs.included OR cs.removed_at IS NOT NULL)
WHERE cs.group_id = g.id AND cs.included AND cs.removed_at IS NULL
)
GROUP BY g.id, g.name
ORDER BY g.name ASC
Expand Down
6 changes: 6 additions & 0 deletions src/summarization/total-summary.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pg from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { upsertScope } from "../db/repositories/chat-scopes.js";
import { upsertGroup } from "../db/repositories/groups.js";
import { insertMessages } from "../db/repositories/messages.js";
import { upsertParticipant } from "../db/repositories/participants.js";
Expand Down Expand Up @@ -55,6 +56,9 @@ describe("generateTotalSummary", () => {
const fam = await upsertGroup(pool, { name: "Family", source: "import" });
await seed(work, new Date("2026-06-06T09:00:00.000Z"), "w1", "תקציב");
await seed(fam, new Date("2026-06-06T09:30:00.000Z"), "f1", "שבת");
// Default-off scoping: include the seeded chats so they're summarized.
await upsertScope(pool, { groupId: work, included: true });
await upsertScope(pool, { groupId: fam, included: true });

const statuses: string[] = [];
const out = await generateTotalSummary(
Expand All @@ -75,6 +79,8 @@ describe("generateTotalSummary", () => {
const bbb = await upsertGroup(pool, { name: "Bbb", source: "import" });
await seed(aaa, new Date("2026-06-07T09:00:00.000Z"), "a1", "BOOM");
await seed(bbb, new Date("2026-06-07T09:30:00.000Z"), "b1", "hello");
await upsertScope(pool, { groupId: aaa, included: true });
await upsertScope(pool, { groupId: bbb, included: true });

async function* fakeStreamWithFailure(prompt: SummaryPrompt): AsyncGenerator<string> {
if (prompt.system.includes("דורש תשומת לב")) {
Expand Down
4 changes: 4 additions & 0 deletions src/web/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { upsertScope } from "../db/repositories/chat-scopes.js";
import { upsertGroup } from "../db/repositories/groups.js";
import { upsertJobRun } from "../db/repositories/job-runs.js";
import { insertMessages } from "../db/repositories/messages.js";
Expand Down Expand Up @@ -1182,6 +1183,9 @@ describe("GET /api/total-summary", () => {
};
await insertMessages(pool, [row]);
}
// Default-off scoping: include both chats so they're summarized.
await upsertScope(pool, { groupId: g1, included: true });
await upsertScope(pool, { groupId: g2, included: true });

const r = await fetch(`${base}/api/total-summary?since=${encodeURIComponent(since)}`);
const text = await r.text();
Expand Down