@growth-labs/cms 0.8.26 → 0.8.28

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.
Files changed (61) hide show
  1. package/README.md +23 -0
  2. package/dist/engine/published-content.d.ts +4 -0
  3. package/dist/engine/published-content.d.ts.map +1 -1
  4. package/dist/engine/published-content.js +52 -16
  5. package/dist/engine/published-content.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/integration/options.d.ts +226 -13
  11. package/dist/integration/options.d.ts.map +1 -1
  12. package/dist/integration/options.js +17 -2
  13. package/dist/integration/options.js.map +1 -1
  14. package/dist/providers/types.d.ts +10 -0
  15. package/dist/providers/types.d.ts.map +1 -1
  16. package/dist/providers/types.js.map +1 -1
  17. package/dist/routes/content-insights.d.ts +4 -0
  18. package/dist/routes/content-insights.d.ts.map +1 -1
  19. package/dist/routes/content-insights.js +104 -0
  20. package/dist/routes/content-insights.js.map +1 -1
  21. package/dist/routes/surveys.d.ts.map +1 -1
  22. package/dist/routes/surveys.js +80 -4
  23. package/dist/routes/surveys.js.map +1 -1
  24. package/dist/schema/insights-ingest.d.ts +64 -64
  25. package/dist/surveys/auto-label.d.ts +31 -4
  26. package/dist/surveys/auto-label.d.ts.map +1 -1
  27. package/dist/surveys/auto-label.js +160 -29
  28. package/dist/surveys/auto-label.js.map +1 -1
  29. package/dist/surveys/schema.d.ts +155 -0
  30. package/dist/surveys/schema.d.ts.map +1 -1
  31. package/dist/surveys/schema.js +49 -0
  32. package/dist/surveys/schema.js.map +1 -1
  33. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  34. package/dist/ui/api/content/insights-inputs.js +11 -1
  35. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  36. package/dist/ui/client/mount.d.ts.map +1 -1
  37. package/dist/ui/client/mount.js +1 -0
  38. package/dist/ui/client/mount.js.map +1 -1
  39. package/dist/ui/screens/SurveyResponsesScreen.d.ts +18 -3
  40. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  41. package/dist/ui/screens/SurveyResponsesScreen.js +75 -18
  42. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  43. package/dist/ui/screens/registry.d.ts +3 -1
  44. package/dist/ui/screens/registry.d.ts.map +1 -1
  45. package/dist/ui/screens/registry.js +4 -2
  46. package/dist/ui/screens/registry.js.map +1 -1
  47. package/dist/ui/styles/broadsheet.css +1 -0
  48. package/package.json +1 -1
  49. package/src/engine/published-content.ts +75 -16
  50. package/src/index.ts +1 -0
  51. package/src/integration/options.ts +21 -1
  52. package/src/providers/types.ts +9 -0
  53. package/src/routes/content-insights.ts +104 -0
  54. package/src/routes/surveys.ts +106 -4
  55. package/src/surveys/auto-label.ts +204 -33
  56. package/src/surveys/schema.ts +58 -0
  57. package/src/ui/api/content/insights-inputs.ts +8 -1
  58. package/src/ui/client/mount.tsx +1 -0
  59. package/src/ui/screens/SurveyResponsesScreen.tsx +198 -18
  60. package/src/ui/screens/registry.tsx +6 -2
  61. package/src/ui/styles/broadsheet.css +1 -0
package/src/index.ts CHANGED
@@ -85,5 +85,6 @@ export {
85
85
  type WorkspaceMembershipRow,
86
86
  type WorkspaceSettingsRow,
87
87
  } from './schema/index.js'
88
+ export { finalizeSurveyAutoLabel, kvSurveyAutoLabelAttemptStore } from './surveys/auto-label.js'
88
89
  export * from './surveys/errors.js'
89
90
  export * from './surveys/schema.js'
@@ -1,6 +1,6 @@
1
1
  import { isCanonicalHost } from '@growth-labs/foundry-signing'
2
2
  import { z } from 'zod'
3
- import { SurveyIdSchema } from '../surveys/schema.js'
3
+ import { SurveyFeedbackThemesSchema, SurveyIdSchema } from '../surveys/schema.js'
4
4
 
5
5
  const workspaceSchema = z.object({
6
6
  id: z.string(),
@@ -121,9 +121,29 @@ const socialSharingSchema = z
121
121
 
122
122
  const surveyResultsSchema = z
123
123
  .object({
124
+ // The survey the screen opens on.
124
125
  surveyId: SurveyIdSchema,
126
+ // Every survey the screen can switch between, in picker order. Absent
127
+ // means only surveyId; when given it must include surveyId.
128
+ surveys: z
129
+ .array(
130
+ z
131
+ .object({
132
+ surveyId: SurveyIdSchema,
133
+ label: z.string().trim().min(1).max(80),
134
+ feedbackThemes: SurveyFeedbackThemesSchema.optional(),
135
+ })
136
+ .strict(),
137
+ )
138
+ .min(1)
139
+ .max(10)
140
+ .optional(),
125
141
  })
126
142
  .strict()
143
+ .refine(
144
+ (value) => !value.surveys || value.surveys.some((survey) => survey.surveyId === value.surveyId),
145
+ { message: 'surveyResults.surveys must include surveyResults.surveyId' },
146
+ )
127
147
 
128
148
  const surveysOptionsSchema = z
129
149
  .object({
@@ -440,8 +440,17 @@ export type LlmDispatchResult =
440
440
  | { text: string; status?: 'ok' }
441
441
  | { text: string; status: 'unavailable'; reason?: string }
442
442
  | ReadableStream
443
+ export type LlmDispatchSubmission =
444
+ | { status: 'accepted'; taskId: string }
445
+ | { status: 'unavailable'; reason: string }
443
446
  export interface LlmDispatch {
444
447
  dispatch(input: LlmDispatchInput): Promise<LlmDispatchResult>
448
+ /** Admit background work without waiting for the signed terminal callback.
449
+ * Persist correlation before the outbound request; failure must prevent dispatch. */
450
+ submit?(
451
+ input: LlmDispatchInput,
452
+ beforeDispatch: (taskId: string) => Promise<void>,
453
+ ): Promise<LlmDispatchSubmission>
445
454
  }
446
455
 
447
456
  export interface FoundryJobSpec {
@@ -10,6 +10,7 @@ import {
10
10
  undismissInsight,
11
11
  } from '../engine/content-insights.js'
12
12
  import { verifyHmac } from '../engine/foundry-dispatch.js'
13
+ import { listPublishedContent, PublishedContentReadError } from '../engine/published-content.js'
13
14
  import { InsightsIngestPayloadSchema } from '../schema/insights-ingest.js'
14
15
  import { InsightDismissalReasonCodeSchema } from '../schema/types.js'
15
16
  import type { CmsRouteConfig } from './config.js'
@@ -29,6 +30,103 @@ const DismissSchema = z
29
30
  .strict()
30
31
  const IntentKeySchema = z.object({ intentKey: z.string().min(1) })
31
32
 
33
+ const PublishedCursorSchema = z
34
+ .object({
35
+ publishedAt: z.number().int().nonnegative(),
36
+ contentId: z.string().min(1).max(256),
37
+ })
38
+ .strict()
39
+
40
+ const PublishedArticleStateSchema = z
41
+ .object({
42
+ content_id: z.string().min(1),
43
+ revision_id: z.string().min(1),
44
+ slug: z.string().min(1),
45
+ title: z.string().min(1),
46
+ canonical_url: z.string().url().nullable(),
47
+ published_at: z.number().int().nonnegative(),
48
+ source_id: z.string().nullable(),
49
+ youtube_video_id: z
50
+ .string()
51
+ .regex(/^[A-Za-z0-9_-]{11}$/)
52
+ .nullable(),
53
+ hero_image_id: z.string().nullable(),
54
+ hero_image_caption: z.string().nullable(),
55
+ })
56
+ .strict()
57
+
58
+ const PublishedInputsResponseSchema = z
59
+ .object({
60
+ ok: z.literal(true),
61
+ site: z.string().min(1),
62
+ observed_at: z.string().datetime(),
63
+ published: z.array(PublishedArticleStateSchema),
64
+ next_cursor: PublishedCursorSchema.nullable(),
65
+ complete: z.boolean(),
66
+ })
67
+ .strict()
68
+
69
+ // Additive read mode of the existing signed inputs route. Read the immutable
70
+ // published snapshot, never mutable draft fields. Reuse the CMS reader's
71
+ // revision checks and pagination; return only named public fields, not a raw
72
+ // payload that may contain internal machine trigger tokens.
73
+ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Promise<Response> {
74
+ if (url.searchParams.get('published') !== '1')
75
+ return json({ error: 'invalid_published_mode' }, 400)
76
+ if (siteId === 'default') return json({ error: 'publication_identity_not_configured' }, 503)
77
+ if (url.searchParams.get('site') !== siteId) return json({ error: 'publication_mismatch' }, 403)
78
+ const limit = z.coerce
79
+ .number()
80
+ .int()
81
+ .min(1)
82
+ .max(100)
83
+ .safeParse(url.searchParams.get('limit') ?? '100')
84
+ if (!limit.success) return json({ error: 'invalid_limit' }, 400)
85
+ let cursor: z.infer<typeof PublishedCursorSchema> | null = null
86
+ const cursorText = url.searchParams.get('cursor')
87
+ if (cursorText !== null) {
88
+ try {
89
+ const parsed = PublishedCursorSchema.safeParse(JSON.parse(cursorText))
90
+ if (!parsed.success) return json({ error: 'invalid_cursor' }, 400)
91
+ cursor = parsed.data
92
+ } catch {
93
+ return json({ error: 'invalid_cursor' }, 400)
94
+ }
95
+ }
96
+ try {
97
+ const page = await listPublishedContent(ctx.db, {
98
+ type: 'article',
99
+ bodies: false,
100
+ limit: limit.data,
101
+ cursor,
102
+ })
103
+ const result = PublishedInputsResponseSchema.safeParse({
104
+ ok: true,
105
+ site: siteId,
106
+ observed_at: new Date().toISOString(),
107
+ published: page.items.map((content) => ({
108
+ content_id: content.contentId,
109
+ revision_id: content.revisionId,
110
+ slug: content.slug,
111
+ title: content.payload.item.title,
112
+ canonical_url: content.payload.item.canonical_url ?? null,
113
+ published_at: content.publishedAt,
114
+ source_id: content.payload.item.source_id ?? null,
115
+ youtube_video_id: content.metadata.videoId ?? null,
116
+ hero_image_id: content.payload.item.hero_image_id ?? null,
117
+ hero_image_caption: content.payload.item.hero_image_caption ?? null,
118
+ })),
119
+ next_cursor: page.nextCursor,
120
+ complete: page.nextCursor === null,
121
+ })
122
+ if (!result.success) return json({ error: 'published_state_invalid' }, 409)
123
+ return json(result.data)
124
+ } catch (error) {
125
+ if (error instanceof PublishedContentReadError) return json({ error: error.code }, 409)
126
+ throw error
127
+ }
128
+ }
129
+
32
130
  /** Resolve YYYY-MM-DD; defaults to the current UTC date when no ?runDate is given. */
33
131
  function resolveRunDate(ctx: RouteContext): string {
34
132
  const url = new URL(ctx.request.url)
@@ -85,6 +183,10 @@ export interface ContentInsightsRouteHandlers {
85
183
  * fronts/schedule (X-Foundry-Timestamp + X-Foundry-Signature over the raw
86
184
  * query string, verified against FOUNDRY_HMAC_SECRET) -- a sibling poll,
87
185
  * not a new auth scheme.
186
+ * `published=1&site=<configured-publication>` reads a page of canonical
187
+ * published article revisions instead, with `limit` (1..100) and JSON
188
+ * `cursor` from the prior page. `complete` means the pagination ended;
189
+ * no total is inferred from one page. Missing URLs/video IDs are null.
88
190
  */
89
191
  insightsInputs(ctx: RouteContext): Promise<Response>
90
192
  }
@@ -231,6 +333,8 @@ export function createContentInsightsRoutes(
231
333
  )
232
334
  if (denied) return denied
233
335
 
336
+ if (url.searchParams.has('published')) return publishedInputs(ctx, url, siteId)
337
+
234
338
  const requestedSite = url.searchParams.get('site')
235
339
  const authorsResult = await ctx.db.prepare(AUTHORS_QUERY).all<{
236
340
  name: string
@@ -5,10 +5,11 @@ import { type CmsProviders, type LlmDispatch, ProviderHttpError } from '../provi
5
5
  import { canLabelSurveyAnswers } from '../surveys/access.js'
6
6
  import {
7
7
  containsPersonalDetails,
8
- SURVEY_AUTO_LABEL_BATCH_LIMIT,
8
+ SURVEY_AUTO_LABEL_MAX_ATTEMPTS,
9
9
  type SurveyAutoLabelAttemptStore,
10
10
  type SurveyAutoLabelCandidate,
11
11
  scheduleSurveyAutoLabel,
12
+ surveyAutoLabelAttemptKey,
12
13
  } from '../surveys/auto-label.js'
13
14
  import {
14
15
  SURVEY_ERROR_CODE_HEADER,
@@ -86,7 +87,15 @@ export function createSurveyRoutes(config: SurveyRouteConfig): SurveyRouteHandle
86
87
  scheduleAutoLabelIfEnabled(ctx, config, surveyId, unlabeled)
87
88
  return responseJson(
88
89
  canLabelSurveyAnswers(caller?.role)
89
- ? withOwnerLabelCounts(withOwnerLabels(parsed.data, labels), unlabeled)
90
+ ? await withOwnerClassificationStatus(
91
+ withOwnerLabelCounts(
92
+ withOwnerLabels(parsed.data, labels, !!config.autoLabel),
93
+ unlabeled,
94
+ ),
95
+ config,
96
+ surveyId,
97
+ unlabeled,
98
+ )
90
99
  : withholdNonGeneralAnswers(parsed.data, labels),
91
100
  )
92
101
  } catch (error) {
@@ -334,9 +343,43 @@ function nonOwnerAnswer(
334
343
  * automatic ones). Non-owner payloads are never decorated — they pass through
335
344
  * withholdNonGeneralAnswers untouched.
336
345
  */
337
- function withOwnerLabels(data: SurveyResults, labels: AnswerLabelIndex): SurveyResults {
346
+ function withOwnerLabels(
347
+ data: SurveyResults,
348
+ labels: AnswerLabelIndex,
349
+ exposeWrittenChoices = false,
350
+ ): SurveyResults {
351
+ const questions = questionIndex(data)
352
+ const writtenChoices = new Map(
353
+ unlabeledWrittenAnswers(data, new Map())
354
+ .filter((candidate) => !questions.textQuestionIds.has(candidate.questionId))
355
+ .map((candidate) => [
356
+ answerLabelKey(candidate.responseId, candidate.questionId),
357
+ candidate.answerText,
358
+ ]),
359
+ )
338
360
  return {
339
361
  ...data,
362
+ responses: exposeWrittenChoices
363
+ ? data.responses.map((response) => ({
364
+ ...response,
365
+ answers: response.answers?.map((answer) => {
366
+ const key = answerLabelKey(response.id, answer.questionId)
367
+ const writtenValue = writtenChoices.get(key)
368
+ if (writtenValue === undefined) return answer
369
+ const row = labels.get(key)
370
+ return {
371
+ ...answer,
372
+ writtenValue,
373
+ ...(row
374
+ ? {
375
+ label: row.label,
376
+ ...(isAnswerLabelSource(row.source) ? { labelSource: row.source } : {}),
377
+ }
378
+ : {}),
379
+ }
380
+ }),
381
+ }))
382
+ : data.responses,
340
383
  freeText: data.freeText.map((entry) => {
341
384
  const row = labels.get(answerLabelKey(entry.responseId, entry.questionId))
342
385
  if (row === undefined) return entry
@@ -372,6 +415,64 @@ function withOwnerLabelCounts(
372
415
  }
373
416
  }
374
417
 
418
+ /** Classification status contains fixed states and timestamps, never model replies/errors. */
419
+ async function withOwnerClassificationStatus(
420
+ data: SurveyResults,
421
+ config: SurveyRouteConfig,
422
+ surveyId: string,
423
+ unlabeled: SurveyAutoLabelCandidate[],
424
+ ): Promise<SurveyResults> {
425
+ if (!config.autoLabel) return data
426
+ type Status = Pick<SurveyResults['freeText'][number], 'classificationStatus' | 'nextAttemptAt'>
427
+ const states = new Map<string, Status>(
428
+ await Promise.all(
429
+ unlabeled.map(async (candidate): Promise<[string, Status]> => {
430
+ const key = answerLabelKey(candidate.responseId, candidate.questionId)
431
+ if (containsPersonalDetails(candidate.answerText))
432
+ return [key, { classificationStatus: 'manual_review' }]
433
+ try {
434
+ const record = await config.autoLabel!.attempts.get(
435
+ surveyAutoLabelAttemptKey(surveyId, candidate.responseId, candidate.questionId),
436
+ )
437
+ const now = Date.now()
438
+ const classificationStatus: Status['classificationStatus'] = !record
439
+ ? 'queued'
440
+ : record.inflightUntil > now
441
+ ? 'pending'
442
+ : record.inflightUntil > 0
443
+ ? 'expired'
444
+ : record.attempts >= SURVEY_AUTO_LABEL_MAX_ATTEMPTS
445
+ ? 'manual_review'
446
+ : record.reason
447
+ ? 'retry'
448
+ : 'queued'
449
+ return [
450
+ key,
451
+ { classificationStatus, ...(record?.nextAt ? { nextAttemptAt: record.nextAt } : {}) },
452
+ ]
453
+ } catch {
454
+ return [key, { classificationStatus: 'unavailable' }]
455
+ }
456
+ }),
457
+ ),
458
+ )
459
+ return {
460
+ ...data,
461
+ freeText: data.freeText.map((entry) => ({
462
+ ...entry,
463
+ ...states.get(answerLabelKey(entry.responseId, entry.questionId)),
464
+ })),
465
+ responses: data.responses.map((response) => ({
466
+ ...response,
467
+ answers: response.answers?.map((answer) =>
468
+ answer.writtenValue === undefined
469
+ ? answer
470
+ : { ...answer, ...states.get(answerLabelKey(response.id, answer.questionId)) },
471
+ ),
472
+ })),
473
+ }
474
+ }
475
+
375
476
  /**
376
477
  * Hand the current page's unlabeled written answers to the background
377
478
  * labeler (surveys.autoLabel). A no-op unless the option is on, the host
@@ -386,7 +487,7 @@ function scheduleAutoLabelIfEnabled(
386
487
  unlabeled: SurveyAutoLabelCandidate[],
387
488
  ): void {
388
489
  if (!config.autoLabel?.llm || !ctx.waitUntil) return
389
- const batch = unlabeled.slice(0, SURVEY_AUTO_LABEL_BATCH_LIMIT)
490
+ const batch = unlabeled
390
491
  if (batch.length === 0) return
391
492
  scheduleSurveyAutoLabel(
392
493
  {
@@ -418,6 +519,7 @@ function unlabeledWrittenAnswers(
418
519
  const candidates: SurveyAutoLabelCandidate[] = []
419
520
  const seen = new Set<string>()
420
521
  const consider = (responseId: string, questionId: string, answerText: string) => {
522
+ if (answerText.trim().length === 0) return
421
523
  const key = answerLabelKey(responseId, questionId)
422
524
  if (seen.has(key) || labels.has(key)) return
423
525
  seen.add(key)
@@ -11,9 +11,15 @@
11
11
  // pre-filter plus <answer> delimiting so personal details never reach the
12
12
  // model and reader text is framed as data, not instructions (B2).
13
13
 
14
+ import { z } from 'zod'
14
15
  import type { D1Database } from '../engine/d1.js'
15
- import type { LlmDispatch, LlmDispatchResult } from '../providers/types.js'
16
- import type { SurveyAnswerLabel } from './schema.js'
16
+ import type { LlmDispatch, LlmDispatchResult, LlmDispatchSubmission } from '../providers/types.js'
17
+ import {
18
+ BoundedRecordIdSchema,
19
+ type SurveyAnswerLabel,
20
+ SurveyIdSchema,
21
+ SurveyQuestionIdSchema,
22
+ } from './schema.js'
17
23
 
18
24
  /** The masthead-ai-assist task for survey classification. Foundry does not
19
25
  * validate `task` (articlechain/mastheadaiassist.go: "a sixth Masthead action
@@ -146,6 +152,9 @@ export interface SurveyAutoLabelAttemptRecord {
146
152
  nextAt: number
147
153
  /** Epoch ms until another request's dispatch claims this answer. */
148
154
  inflightUntil: number
155
+ /** Opaque Go correlation, never reader text or identity. */
156
+ taskId?: string
157
+ reason?: 'callback_expired' | 'dispatch_failed' | 'llm_failed' | 'unparseable'
149
158
  }
150
159
 
151
160
  /** Narrow persistence the batch runner needs; the KV adapter below satisfies
@@ -154,6 +163,8 @@ export interface SurveyAutoLabelAttemptStore {
154
163
  get(key: string): Promise<SurveyAutoLabelAttemptRecord | null>
155
164
  put(key: string, record: SurveyAutoLabelAttemptRecord, ttlSeconds: number): Promise<void>
156
165
  delete(key: string): Promise<void>
166
+ putTask?(taskId: string, record: SurveyAutoLabelTaskRecord): Promise<void>
167
+ getTask?(taskId: string): Promise<SurveyAutoLabelTaskRecord | null>
157
168
  }
158
169
 
159
170
  export function surveyAutoLabelAttemptKey(
@@ -175,31 +186,111 @@ interface KvLike {
175
186
  /** Attempt records in a Workers KV namespace (`survey-autolabel:` prefix).
176
187
  * The same namespace the host's LlmDispatch result slots live in is fine:
177
188
  * the key prefixes never collide (`masthead:llm:result:` vs ours). */
189
+ const AttemptRecordSchema = z
190
+ .object({
191
+ attempts: z.number().int().nonnegative(),
192
+ nextAt: z.number().nonnegative(),
193
+ inflightUntil: z.number().nonnegative(),
194
+ taskId: z.string().min(1).max(256).optional(),
195
+ reason: z.enum(['callback_expired', 'dispatch_failed', 'llm_failed', 'unparseable']).optional(),
196
+ })
197
+ .strict()
198
+ const TaskRecordSchema = z
199
+ .object({
200
+ surveyId: SurveyIdSchema,
201
+ responseId: BoundedRecordIdSchema,
202
+ questionId: SurveyQuestionIdSchema,
203
+ attempts: z.number().int().nonnegative(),
204
+ })
205
+ .strict()
206
+ export type SurveyAutoLabelTaskRecord = z.infer<typeof TaskRecordSchema>
207
+ export const SURVEY_AUTO_LABEL_TASK_PREFIX = 'survey-autolabel-task:'
208
+
178
209
  export function kvSurveyAutoLabelAttemptStore(kv: KvLike): SurveyAutoLabelAttemptStore {
179
210
  return {
180
211
  async get(key) {
181
212
  const raw = await kv.get(key)
182
- if (raw === null) return null
183
- try {
184
- const value = JSON.parse(raw) as Partial<SurveyAutoLabelAttemptRecord>
185
- return {
186
- attempts: typeof value.attempts === 'number' ? value.attempts : 0,
187
- nextAt: typeof value.nextAt === 'number' ? value.nextAt : 0,
188
- inflightUntil: typeof value.inflightUntil === 'number' ? value.inflightUntil : 0,
189
- }
190
- } catch {
191
- return null
192
- }
213
+ return raw === null ? null : AttemptRecordSchema.parse(JSON.parse(raw))
193
214
  },
194
215
  async put(key, record, ttlSeconds) {
195
- await kv.put(key, JSON.stringify(record), { expirationTtl: ttlSeconds })
216
+ await kv.put(key, JSON.stringify(AttemptRecordSchema.parse(record)), {
217
+ expirationTtl: ttlSeconds,
218
+ })
196
219
  },
197
220
  async delete(key) {
198
221
  await kv.delete(key)
199
222
  },
223
+ async putTask(taskId, record) {
224
+ await kv.put(
225
+ `${SURVEY_AUTO_LABEL_TASK_PREFIX}${taskId}`,
226
+ JSON.stringify(TaskRecordSchema.parse(record)),
227
+ { expirationTtl: SURVEY_AUTO_LABEL_ATTEMPT_TTL_SECONDS },
228
+ )
229
+ },
230
+ async getTask(taskId) {
231
+ const raw = await kv.get(`${SURVEY_AUTO_LABEL_TASK_PREFIX}${taskId}`)
232
+ return raw === null ? null : TaskRecordSchema.parse(JSON.parse(raw))
233
+ },
200
234
  }
201
235
  }
202
236
 
237
+ /** Finalize through the existing signed callback, independently of waitUntil.
238
+ * Missing correlation/persistence throws so Go retries rather than acknowledging lost work. */
239
+ export async function finalizeSurveyAutoLabel(
240
+ db: D1Database,
241
+ store: SurveyAutoLabelAttemptStore,
242
+ taskId: string,
243
+ result: { terminalState: 'completed' | 'failed'; result?: string },
244
+ now = Date.now(),
245
+ ): Promise<void> {
246
+ const target = await store.getTask?.(taskId)
247
+ if (!target) throw new Error('survey correlation unavailable')
248
+ const key = surveyAutoLabelAttemptKey(target.surveyId, target.responseId, target.questionId)
249
+ const label =
250
+ result.terminalState === 'completed' ? parseSurveyLabelReply(result.result ?? '') : null
251
+ if (label) {
252
+ await db
253
+ .prepare(`INSERT INTO survey_answer_labels
254
+ (survey_id, response_id, question_id, label, source, labeled_by, labeled_at)
255
+ VALUES (?, ?, ?, ?, 'auto', NULL, ?)
256
+ ON CONFLICT(survey_id, response_id, question_id) DO NOTHING`)
257
+ .bind(target.surveyId, target.responseId, target.questionId, label, now)
258
+ .run()
259
+ const current = await store.get(key)
260
+ if (current?.taskId === taskId) await store.delete(key)
261
+ return
262
+ }
263
+ const existing = await db
264
+ .prepare(
265
+ 'SELECT label FROM survey_answer_labels WHERE survey_id = ? AND response_id = ? AND question_id = ? LIMIT 1',
266
+ )
267
+ .bind(target.surveyId, target.responseId, target.questionId)
268
+ .first()
269
+ if (existing) return
270
+ const current = await store.get(key)
271
+ if (!current) throw new Error('survey attempt unavailable')
272
+ if (
273
+ current.taskId === taskId &&
274
+ current.inflightUntil === 0 &&
275
+ current.attempts > target.attempts
276
+ )
277
+ return
278
+ // A delayed failed callback must not reset a newer retry or a completed label.
279
+ if (current?.taskId !== taskId) return
280
+ const attempts = target.attempts + 1
281
+ await store.put(
282
+ key,
283
+ {
284
+ attempts,
285
+ nextAt: now + SURVEY_AUTO_LABEL_BACKOFF_MS[Math.min(attempts, 3) - 1],
286
+ inflightUntil: 0,
287
+ taskId,
288
+ reason: result.terminalState === 'failed' ? 'llm_failed' : 'unparseable',
289
+ },
290
+ SURVEY_AUTO_LABEL_ATTEMPT_TTL_SECONDS,
291
+ )
292
+ }
293
+
203
294
  /**
204
295
  * In-isolate in-flight gate. KV has no compare-and-swap, so a dedupe window
205
296
  * this small is enforced synchronously in module scope (all concurrent
@@ -256,18 +347,34 @@ async function runSurveyAutoLabelBatch(
256
347
  batch: SurveyAutoLabelCandidate[],
257
348
  ): Promise<void> {
258
349
  let cursor = 0
350
+ let admitted = 0
351
+ const reserveAdmission = () => {
352
+ if (admitted >= SURVEY_AUTO_LABEL_BATCH_LIMIT) return false
353
+ admitted += 1
354
+ return true
355
+ }
259
356
  let stored = 0
357
+ let processed = 0
260
358
  let deferred = 0
261
359
  let halted: string | null = null
360
+ const admissionDeadline = (options.now ?? Date.now)() + 20_000
262
361
  const skipReasons = new Set<string>()
263
362
 
264
363
  const worker = async (): Promise<void> => {
265
364
  for (;;) {
266
- if (halted !== null) return
365
+ if (halted !== null || admitted >= SURVEY_AUTO_LABEL_BATCH_LIMIT) return
366
+ if ((options.now ?? Date.now)() >= admissionDeadline) {
367
+ halted = 'admission_window_ended'
368
+ return
369
+ }
267
370
  const next = cursor
268
371
  cursor += 1
269
372
  if (next >= batch.length) return
270
- const outcome = await classifyAndStore(options, batch[next])
373
+ const outcome = await classifyAndStore(options, batch[next], reserveAdmission).catch(() => ({
374
+ kind: 'unavailable' as const,
375
+ reason: 'attempt_write_failed',
376
+ }))
377
+ processed += 1
271
378
  if (outcome.kind === 'stored') {
272
379
  stored += 1
273
380
  continue
@@ -292,7 +399,7 @@ async function runSurveyAutoLabelBatch(
292
399
 
293
400
  // Failure-class skips only: a wholly deferred batch (all in flight or
294
401
  // backing off) is the steady state of the dedupe, not a failure to report.
295
- const skipped = batch.length - stored - deferred
402
+ const skipped = (halted === null ? processed : batch.length) - stored - deferred
296
403
  if (skipped === 0) return
297
404
  const reasons = new Set(skipReasons)
298
405
  if (halted !== null) reasons.add(halted)
@@ -305,14 +412,19 @@ async function runSurveyAutoLabelBatch(
305
412
 
306
413
  type ClassifyOutcome =
307
414
  | { kind: 'stored' }
308
- | { kind: 'deferred'; reason: 'in_flight' | 'backoff' | 'gave_up' | 'pii' }
415
+ | {
416
+ kind: 'deferred'
417
+ reason: 'in_flight' | 'backoff' | 'gave_up' | 'pii' | 'empty' | 'batch_limit'
418
+ }
309
419
  | { kind: 'skipped'; reason: string }
310
420
  | { kind: 'unavailable'; reason: string }
311
421
 
312
422
  async function classifyAndStore(
313
423
  options: SurveyAutoLabelOptions,
314
424
  candidate: SurveyAutoLabelCandidate,
425
+ reserveAdmission: () => boolean,
315
426
  ): Promise<ClassifyOutcome> {
427
+ if (candidate.answerText.trim().length === 0) return { kind: 'deferred', reason: 'empty' }
316
428
  // B2 first, before any store or dispatch work: personal details never
317
429
  // leave the Worker, and the skip consumes no attempt.
318
430
  if (containsPersonalDetails(candidate.answerText)) return { kind: 'deferred', reason: 'pii' }
@@ -327,12 +439,25 @@ async function classifyAndStore(
327
439
  try {
328
440
  record = await options.attempts.get(key)
329
441
  } catch {
330
- // The attempt store is dedupe hygiene, not label correctness: an
331
- // unreadable record proceeds as if absent (worst case: one duplicate
332
- // dispatch, still bounded by the in-isolate gate).
442
+ return { kind: 'unavailable', reason: 'attempt_read_failed' }
333
443
  }
334
444
  if (record) {
335
445
  if (record.inflightUntil > now) return { kind: 'deferred', reason: 'in_flight' }
446
+ if (record.inflightUntil > 0) {
447
+ const attempts = record.attempts + 1
448
+ await options.attempts.put(
449
+ key,
450
+ {
451
+ attempts,
452
+ nextAt: now + SURVEY_AUTO_LABEL_BACKOFF_MS[Math.min(attempts, 3) - 1],
453
+ inflightUntil: 0,
454
+ ...(record.taskId ? { taskId: record.taskId } : {}),
455
+ reason: 'callback_expired',
456
+ },
457
+ SURVEY_AUTO_LABEL_ATTEMPT_TTL_SECONDS,
458
+ )
459
+ return { kind: 'deferred', reason: 'backoff' }
460
+ }
336
461
  if (record.attempts >= SURVEY_AUTO_LABEL_MAX_ATTEMPTS) {
337
462
  return { kind: 'deferred', reason: 'gave_up' }
338
463
  }
@@ -340,6 +465,7 @@ async function classifyAndStore(
340
465
  }
341
466
  if (!reserveInFlight(key, now)) return { kind: 'deferred', reason: 'in_flight' }
342
467
  try {
468
+ if (!reserveAdmission()) return { kind: 'deferred', reason: 'batch_limit' }
343
469
  // Claimed. Record the failure (or the backoff it earns) at each exit;
344
470
  // the KV marker goes out BEFORE the dispatch so a concurrent request
345
471
  // in another isolate defers instead of duplicating the work.
@@ -352,23 +478,68 @@ async function classifyAndStore(
352
478
  await options.attempts
353
479
  .put(
354
480
  key,
355
- { attempts, nextAt: now + backoff, inflightUntil: 0 },
481
+ {
482
+ attempts,
483
+ nextAt: now + backoff,
484
+ inflightUntil: 0,
485
+ reason: outcome.reason === 'unparseable' ? 'unparseable' : 'dispatch_failed',
486
+ },
356
487
  SURVEY_AUTO_LABEL_ATTEMPT_TTL_SECONDS,
357
488
  )
358
489
  .catch(() => {})
359
490
  return outcome
360
491
  }
361
- await options.attempts
362
- .put(
363
- key,
364
- {
365
- attempts: record?.attempts ?? 0,
366
- nextAt: record?.nextAt ?? 0,
367
- inflightUntil: now + SURVEY_AUTO_LABEL_INFLIGHT_MS,
368
- },
369
- SURVEY_AUTO_LABEL_ATTEMPT_TTL_SECONDS,
370
- )
371
- .catch(() => {})
492
+ if (!options.llm.submit)
493
+ await options.attempts
494
+ .put(
495
+ key,
496
+ {
497
+ attempts: record?.attempts ?? 0,
498
+ nextAt: record?.nextAt ?? 0,
499
+ inflightUntil: now + SURVEY_AUTO_LABEL_INFLIGHT_MS,
500
+ },
501
+ SURVEY_AUTO_LABEL_ATTEMPT_TTL_SECONDS,
502
+ )
503
+ .catch(() => {
504
+ throw new Error('attempt_write_failed')
505
+ })
506
+
507
+ if (options.llm.submit) {
508
+ if (!options.attempts.putTask)
509
+ return await failure({ kind: 'unavailable', reason: 'no_correlation_store' })
510
+ let admission: LlmDispatchSubmission
511
+ try {
512
+ admission = await options.llm.submit(
513
+ {
514
+ task: SURVEY_AUTO_LABEL_TASK,
515
+ prompt: buildSurveyLabelPrompt(candidate.questionPrompt, candidate.answerText),
516
+ },
517
+ async (taskId) => {
518
+ await options.attempts.putTask!(taskId, {
519
+ surveyId: options.surveyId,
520
+ responseId: candidate.responseId,
521
+ questionId: candidate.questionId,
522
+ attempts: record?.attempts ?? 0,
523
+ })
524
+ await options.attempts.put(
525
+ key,
526
+ {
527
+ attempts: record?.attempts ?? 0,
528
+ nextAt: 0,
529
+ inflightUntil: now + SURVEY_AUTO_LABEL_INFLIGHT_MS,
530
+ taskId,
531
+ },
532
+ SURVEY_AUTO_LABEL_ATTEMPT_TTL_SECONDS,
533
+ )
534
+ },
535
+ )
536
+ } catch {
537
+ return await failure({ kind: 'unavailable', reason: 'dispatch_threw' })
538
+ }
539
+ if (admission.status === 'unavailable')
540
+ return await failure({ kind: 'unavailable', reason: admission.reason })
541
+ return { kind: 'deferred', reason: 'in_flight' }
542
+ }
372
543
 
373
544
  let result: LlmDispatchResult
374
545
  try {