diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 52af177..838e615 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -647,10 +647,11 @@ async function tick(): Promise { for (const workspaceId of await workspacesWithInternalBacklog(db)) { const cleared = await autoApproveInternal(db, { workspaceId }); - if (cleared.approved > 0) { + if (cleared.approved > 0 || cleared.cooledDown > 0) { console.log( `auto-approved ${cleared.approved} internal card(s) in ${workspaceId}` + `${cleared.queuedCrawls > 0 ? `, queued ${cleared.queuedCrawls} crawl(s)` : ''}` + + `${cleared.cooledDown > 0 ? `, ${cleared.cooledDown} already researched today` : ''}` + `${cleared.refused > 0 ? `, ${cleared.refused} refused by policy` : ''}`, ); } diff --git a/packages/pipeline/src/auto-approve.test.ts b/packages/pipeline/src/auto-approve.test.ts index 9de7127..eece804 100644 --- a/packages/pipeline/src/auto-approve.test.ts +++ b/packages/pipeline/src/auto-approve.test.ts @@ -194,9 +194,126 @@ describe('autoApproveInternal', () => { ); }); + test('does not re-research somebody researched today', async () => { + // The loop this closes. A research card exists because we had nothing to + // say; approving it re-crawls; if that yields nothing the card returns. + // Automating the click turned a visible backlog into invisible crawl + // traffic — one person reached three cards within minutes. + seeded = await seedDatabase('auto-cooldown'); + await withDomain(seeded.db); + await card(seeded.db, 'refresh_research'); + + const first = await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId }); + expect(first.approved).toBe(1); + expect(first.queuedCrawls).toBe(1); + + // The next pass proposes it again, as the pipeline does. + const again = await card(seeded.db, 'refresh_research'); + const second = await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId }); + + expect(second.approved).toBe(0); + expect(second.cooledDown).toBe(1); + // Crucially: no second crawl. That is what stops the loop. + expect(second.queuedCrawls).toBe(0); + + const rec = await queryOne<{ status: string }>( + seeded.db, + 'SELECT status FROM recommendations WHERE id = ?', + [again], + ); + // Closed, not approved — approving would claim we went and looked. + expect(rec?.status).toBe('skipped'); + }); + + test('a cooled-down card leaves an auditable reason', async () => { + seeded = await seedDatabase('auto-cooldown-audit'); + await withDomain(seeded.db); + await card(seeded.db, 'refresh_research'); + await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId }); + + const id = await card(seeded.db, 'refresh_research'); + await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId }); + + const audit = await queryOne<{ event_type: string }>( + seeded.db, + `SELECT event_type FROM audit_events WHERE entity_id = ? + AND event_type = 'recommendation.research_cooldown'`, + [id], + ); + + expect(audit?.event_type).toBe('recommendation.research_cooldown'); + }); + + test('research outside the cooldown runs again', async () => { + // The cooldown must expire, or a company that genuinely changed is never + // re-read. + seeded = await seedDatabase('auto-cooldown-expired'); + await withDomain(seeded.db); + await card(seeded.db, 'refresh_research'); + await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId }); + + // Age the recorded research past the window. + await seeded.db.execute({ + sql: `UPDATE actions SET created_at = ? WHERE kind = 'refresh_research'`, + args: [new Date(Date.now() - 48 * 3_600_000).toISOString()], + }); + + await card(seeded.db, 'refresh_research'); + const result = await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId }); + + expect(result.approved).toBe(1); + expect(result.cooledDown).toBe(0); + }); + + test('a human approval also suppresses the automatic re-run', async () => { + // The cooldown counts actions, not crawl jobs, so it does not matter who + // approved the card an hour ago. + seeded = await seedDatabase('auto-cooldown-human'); + await withDomain(seeded.db); + + // Stands in for a card a person clicked an hour ago: the action row is + // what both paths write, and is what the cooldown counts. + const clicked = await card(seeded.db, 'refresh_research'); + await seeded.db.execute({ + sql: `INSERT INTO actions (id, workspace_id, recommendation_id, person_id, kind, network, + mode, status, created_at) + VALUES (?, ?, ?, ?, 'refresh_research', 'website', 'manual', 'queued', ?)`, + args: [newId('action'), SEED.workspaceId, clicked, SEED.personId, now()], + }); + await seeded.db.execute({ + sql: `UPDATE recommendations SET status = 'approved' WHERE id = ?`, + args: [clicked], + }); + + await card(seeded.db, 'refresh_research'); + const result = await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId }); + + expect(result.cooledDown).toBe(1); + expect(result.queuedCrawls).toBe(0); + }); + test('honours the limit so one workspace cannot hold the tick', async () => { seeded = await seedDatabase('auto-limit'); - for (let index = 0; index < 5; index += 1) await card(seeded.db, 'refresh_research'); + + // Distinct people, because the cooldown is per person: five cards for one + // person is one approval and four cooldowns, which is correct and is not + // what this test is about. + for (let index = 0; index < 5; index += 1) { + const personId = `per_limit_${index}`; + await seeded.db.execute({ + sql: `INSERT INTO people (id, display_name, status, identity_confidence, created_at, + updated_at) VALUES (?, ?, 'qualified', 0.9, ?, ?)`, + args: [personId, `Limit ${index}`, now(), now()], + }); + await seeded.db.execute({ + sql: `INSERT INTO recommendations (id, workspace_id, campaign_id, person_id, action, + network, priority, reason, policy_status, policy_version, expected_goal, status, + created_at) + VALUES (?, ?, ?, ?, 'refresh_research', 'website', 50, 'because', + 'allow_with_approval', '2026-08-11', 'qualify', 'pending', ?)`, + args: [newId('recommendation'), SEED.workspaceId, SEED.campaignId, personId, now()], + }); + } const result = await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId, diff --git a/packages/pipeline/src/auto-approve.ts b/packages/pipeline/src/auto-approve.ts index 23dcc6f..297d0a9 100644 --- a/packages/pipeline/src/auto-approve.ts +++ b/packages/pipeline/src/auto-approve.ts @@ -42,11 +42,32 @@ import { enqueue } from './queue'; */ const AUTO_APPROVED: readonly ActionKind[] = ['refresh_research', 'observe', 'wait']; +/** + * How long a person is left alone after their research has been run. + * + * This exists because automating the approval turned a visible backlog into + * an invisible loop. A `refresh_research` card is proposed *because* we have + * nothing to say about someone; approving it re-reads their company's site; + * if that still yields nothing, the next pass proposes the same card again. + * While a human had to click, the cards simply piled up — production held 179 + * — and the waste was at least in plain sight. Approving them automatically + * closed the loop and turned it into crawl traffic nobody was watching: + * within minutes of shipping, one person had three cards and 195 crawls were + * queued. + * + * A day is chosen because that is the shortest interval over which a company + * website plausibly changes. Shorter re-reads the same bytes; much longer + * would delay picking up a genuine change. + */ +const RESEARCH_COOLDOWN_HOURS = 24; + export interface AutoApproveResult { readonly considered: number; readonly approved: number; readonly refused: number; readonly queuedCrawls: number; + /** Closed without re-crawling, because the answer would not have changed. */ + readonly cooledDown: number; } interface PendingRow { @@ -82,7 +103,7 @@ export async function autoApproveInternal( // Absent workspace or the setting turned off. Not an error: a workspace that // wants to watch its own research go by is entitled to. if (!workspace || workspace.auto_approve_internal !== 1) { - return { considered: 0, approved: 0, refused: 0, queuedCrawls: 0 }; + return { considered: 0, approved: 0, refused: 0, queuedCrawls: 0, cooledDown: 0 }; } const placeholders = AUTO_APPROVED.map(() => '?').join(', '); @@ -105,8 +126,18 @@ export async function autoApproveInternal( let approved = 0; let refused = 0; let queuedCrawls = 0; + let cooledDown = 0; for (const row of rows) { + // Research we have already done for this person, recently enough that + // doing it again would read the same page. Closed rather than approved: + // approving it would enqueue the crawl that creates the next card. + if (row.action === 'refresh_research' && (await researchedRecently(db, row.person_id))) { + await closeWithoutCrawling(db, input.workspaceId, row.id, row.action); + cooledDown += 1; + continue; + } + const decision = evaluatePolicy({ network: row.network as Network, action: row.action as ActionKind, @@ -209,7 +240,64 @@ export async function autoApproveInternal( } } - return { considered: rows.length, approved, refused, queuedCrawls }; + return { considered: rows.length, approved, refused, queuedCrawls, cooledDown }; +} + +/** + * Whether this person's research has already run inside the cooldown. + * + * Counted from `actions`, which is written on every approval — automatic or + * clicked — so a human who approved the card an hour ago also suppresses the + * automatic re-run. The alternative, looking at crawl jobs, would miss a + * person whose company has no domain and whose card therefore never produced + * one. + */ +async function researchedRecently(db: Client, personId: string): Promise { + const since = new Date(Date.now() - RESEARCH_COOLDOWN_HOURS * 3_600_000).toISOString(); + + const row = await queryOne<{ n: number }>( + db, + `SELECT count(*) AS n FROM actions + WHERE person_id = ? AND kind = 'refresh_research' AND created_at >= ?`, + [personId, since], + ); + + return Number(row?.n ?? 0) > 0; +} + +/** + * Retires a card whose answer is already known, without spending a crawl. + * + * `skipped` rather than `approved`, because approving would claim we went and + * looked. The status already exists for exactly this — a card that is over + * without having been acted on — and it keeps the card out of the queue + * without inventing a fourth outcome. + */ +async function closeWithoutCrawling( + db: Client, + workspaceId: string, + recommendationId: string, + action: string, +): Promise { + await db.batch([ + { + sql: `UPDATE recommendations SET status = 'skipped' WHERE id = ?`, + args: [recommendationId], + }, + { + sql: `INSERT INTO audit_events (id, workspace_id, actor_kind, actor_id, event_type, + entity_kind, entity_id, detail_json, occurred_at) + VALUES (?, ?, 'system', ?, 'recommendation.research_cooldown', 'recommendation', ?, ?, ?)`, + args: [ + newId('auditEvent'), + workspaceId, + AUTO_APPROVE_ACTOR, + recommendationId, + JSON.stringify({ action, cooldownHours: RESEARCH_COOLDOWN_HOURS }), + now(), + ], + }, + ]); } /** Who the audit trail credits for an unattended approval. */