Skip to content

Commit 8e06bc5

Browse files
bloveclaude
andauthored
fix(lifecycle): own enrichment provenance in code; fix(mailbox-poller): drop numeric separators (#985)
* fix(mailbox-poller): drop numeric separators so the Apps Script editor can parse Code.gs The Apps Script editor rejects numeric separator literals (8_000, 4_000, 1_000_000) with 'Unexpected token ILLEGAL' at line 321 and refuses to save. Plain literals keep the same values. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(lifecycle): own enrichment provenance in code so structured output stops failing Every production enrich job failed. The Anthropic structured-output grammar drops array-length, numeric, and enum bounds from the JSON schema, and the model was asked to echo provenance verbatim. In practice it returned one draft instead of three, dropped milliseconds from retrieved_at, cited score identifiers as sources, and fabricated placeholder sources in neutral mode. Each of those tripped the strict artifact parse or a verifier and the job went terminal after two attempts. The wire schema now carries only what the model judges: summary, confidence, cited signals, company profile, recommended angle, and drafts, with no grammar-unenforceable bounds. The code derives sources from the bounded evidence for cited ids, copies score metadata from the input, drops signals that cite anything outside the evidence, nulls drafts that point at a dropped source or an unknown angle, pads or truncates drafts to the three slots, and strips all company claims in neutral mode. The prompt states the slot count, the allowed angle ids, and which ids are citable. Verified live against the API: five of five runs succeed across company and neutral inputs, where zero succeeded before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(lifecycle): survive unusable company pages, keep angles distinct, close evidence sequences Three findings from vetting enrichment against live sites: - One oversized, missing, or slow company page aborted evidence for the whole company, and the enrich job then failed after retries. The fetcher now skips such a page and keeps the rest; only the caller's abort and SSRF violations (now CompanyFetchSecurityError) propagate. - Nothing stopped the model from selecting the same angle in two slots, which would send the same email twice. The normalizer keeps the first use of an angle and nulls repeats so those slots fall back to default copy; the prompt asks for distinct angles. - The default day-8 copy says it is the last automated follow-up but the evidence variants did not. renderEvidenceCampaignTemplate takes a finalStep option and the dispatcher sets it for step 3. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent e643849 commit 8e06bc5

9 files changed

Lines changed: 618 additions & 204 deletions

File tree

apps/lifecycle/src/campaign/send.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,40 @@ describe('prepareCampaignMessage', () => {
248248
).toMatchObject({ status: 'ready', subject: 'A practical place to start' });
249249
});
250250

251+
it('closes the sequence on the final step even when evidence copy is selected', () => {
252+
const cited = artifact({
253+
cited_signals: [
254+
{ signal: 'Bounded source fact', source_ids: ['source-1'] },
255+
],
256+
sources: [
257+
{
258+
id: 'source-1',
259+
url: 'https://example.com/about',
260+
retrieved_at: '2026-09-01T12:00:00.000Z',
261+
content_hash: 'a'.repeat(64),
262+
},
263+
],
264+
drafts: [
265+
{ angle_id: 'streaming_foundation', source_id: 'source-1' },
266+
{ angle_id: 'debugging_layers', source_id: 'source-1' },
267+
{ angle_id: 'event_state_boundary', source_id: 'source-1' },
268+
],
269+
});
270+
271+
const message = prepareCampaignMessage({
272+
context: context({ enrichmentArtifact: cited }),
273+
job: job('send_step', { campaign_version: 'v1', step: 3 }),
274+
now: new Date('2026-09-09T12:05:00.000Z'),
275+
unsubscribeUrl: UNSUBSCRIBE,
276+
});
277+
278+
expect(message).toMatchObject({
279+
status: 'ready',
280+
subject: 'One event-state boundary',
281+
});
282+
expect(JSON.stringify(message)).toContain('last automated follow-up');
283+
});
284+
251285
it('falls back per fixed step when an artifact draft violates copy checks', () => {
252286
const invalid = artifact({
253287
drafts: [

apps/lifecycle/src/campaign/send.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,9 @@ function draftFor(
240240
source_ids.includes(selection.source_id)
241241
);
242242
if (selection !== null && cited) {
243-
return renderEvidenceCampaignTemplate(selection.angle_id);
243+
return renderEvidenceCampaignTemplate(selection.angle_id, {
244+
finalStep: step === 3,
245+
});
244246
}
245247
}
246248
return renderCampaignTemplate(STEP_NAMES[step]);

apps/lifecycle/src/campaign/templates.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
campaignDraftViolations,
55
normalizeCampaignDraft,
66
renderCampaignTemplate,
7+
renderEvidenceCampaignTemplate,
78
} from './templates.js';
89

910
function wordCount(value: string): number {
@@ -34,6 +35,20 @@ describe('renderCampaignTemplate', () => {
3435
);
3536
});
3637

38+
it('appends the last-follow-up notice to an evidence template only for the final step', () => {
39+
const final = renderEvidenceCampaignTemplate('event_state_boundary', {
40+
finalStep: true,
41+
});
42+
const earlier = renderEvidenceCampaignTemplate('event_state_boundary');
43+
44+
expect(final.body).toContain('last automated follow-up');
45+
expect(final.body.endsWith('This is my last automated follow-up.')).toBe(
46+
true
47+
);
48+
expect(campaignDraftViolations(final)).toEqual([]);
49+
expect(earlier.body).not.toContain('last automated follow-up');
50+
});
51+
3752
it('rejects an unknown campaign step at runtime', () => {
3853
expect(() => renderCampaignTemplate('day-30' as never)).toThrow();
3954
});

apps/lifecycle/src/campaign/templates.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,16 @@ export function renderCampaignTemplate(step: CampaignStep): CampaignDraft {
194194
return normalizeCampaignDraft(CAMPAIGN_TEMPLATES[parsedStep]);
195195
}
196196

197+
const FINAL_STEP_NOTICE = 'This is my last automated follow-up.';
198+
197199
export function renderEvidenceCampaignTemplate(
198-
angle: CampaignEvidenceAngle
200+
angle: CampaignEvidenceAngle,
201+
options: { finalStep?: boolean } = {}
199202
): CampaignDraft {
200-
return normalizeCampaignDraft(EVIDENCE_TEMPLATES[angle]);
203+
const template = EVIDENCE_TEMPLATES[angle];
204+
return normalizeCampaignDraft(
205+
options.finalStep
206+
? { ...template, body: `${template.body}\n\n${FINAL_STEP_NOTICE}` }
207+
: template
208+
);
201209
}

0 commit comments

Comments
 (0)